Compare commits
4
Commits
aac9e82766
...
dev
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6eef839526 | ||
|
|
be255341e5 | ||
|
|
d188ca41b8 | ||
|
|
e047499fed |
@@ -54,6 +54,7 @@ type createActionGroupItemRequest struct {
|
||||
RequiresSudo bool `json:"requires_sudo"`
|
||||
SetVariable string `json:"set_variable"`
|
||||
RunIf string `json:"run_if"`
|
||||
WidgetLabel string `json:"widget_label"`
|
||||
}
|
||||
|
||||
type patchActionGroupItemRequest struct {
|
||||
@@ -65,6 +66,7 @@ type patchActionGroupItemRequest struct {
|
||||
RequiresSudo *bool `json:"requires_sudo"`
|
||||
SetVariable *string `json:"set_variable"`
|
||||
RunIf *string `json:"run_if"`
|
||||
WidgetLabel *string `json:"widget_label"`
|
||||
}
|
||||
|
||||
type reorderActionGroupItemsRequest struct {
|
||||
@@ -332,7 +334,7 @@ func (app *App) actionGroupItemsPatchHandler(writer http.ResponseWriter, request
|
||||
return
|
||||
}
|
||||
item := store.Groups[groupIndex].Items[itemIndex]
|
||||
editingBodyFields := payload.Name != nil || payload.Description != nil || payload.Kind != nil || payload.Body != nil || payload.Env != nil || payload.RequiresSudo != nil
|
||||
editingBodyFields := payload.Name != nil || payload.Description != nil || payload.Kind != nil || payload.Body != nil || payload.RequiresSudo != nil
|
||||
if item.Source != settings.ActionItemSourceLocal && editingBodyFields {
|
||||
writeJSON(writer, http.StatusBadRequest, apiErrorResponse{Error: "library actions must be cloned before editing"})
|
||||
return
|
||||
@@ -364,6 +366,19 @@ func (app *App) actionGroupItemsPatchHandler(writer http.ResponseWriter, request
|
||||
writeJSON(writer, http.StatusBadRequest, apiErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
} else if payload.Env != nil {
|
||||
env := *payload.Env
|
||||
if env == nil {
|
||||
env = []settings.ActionEnvVar{}
|
||||
}
|
||||
for index := range env {
|
||||
env[index].Name = strings.TrimSpace(env[index].Name)
|
||||
if env[index].Name == "" || !envVarNamePattern.MatchString(env[index].Name) {
|
||||
writeJSON(writer, http.StatusBadRequest, apiErrorResponse{Error: "environment variable name must match [A-Za-z_][A-Za-z0-9_]*"})
|
||||
return
|
||||
}
|
||||
}
|
||||
item.Env = env
|
||||
}
|
||||
|
||||
if payload.SetVariable != nil {
|
||||
@@ -382,6 +397,9 @@ func (app *App) actionGroupItemsPatchHandler(writer http.ResponseWriter, request
|
||||
}
|
||||
item.RunIf = runIf
|
||||
}
|
||||
if payload.WidgetLabel != nil {
|
||||
item.WidgetLabel = strings.TrimSpace(*payload.WidgetLabel)
|
||||
}
|
||||
|
||||
store.Groups[groupIndex].Items[itemIndex] = item
|
||||
store.Groups[groupIndex].UpdatedAt = time.Now().UTC()
|
||||
@@ -500,6 +518,7 @@ func (app *App) actionGroupItemsDeleteHandler(writer http.ResponseWriter, reques
|
||||
writeJSON(writer, http.StatusInternalServerError, apiErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
_ = settings.DeleteActionWidgetsForItem(app.ConfigDir, node.ID, itemID)
|
||||
writeJSON(writer, http.StatusOK, actionGroupResponse{Group: store.Groups[groupIndex]})
|
||||
}
|
||||
|
||||
@@ -745,13 +764,24 @@ func buildNodeActionItem(payload createActionGroupItemRequest) (settings.NodeAct
|
||||
if libraryID == "" {
|
||||
return settings.NodeActionItem{}, errors.New("library_action_id is required")
|
||||
}
|
||||
env := payload.Env
|
||||
if env == nil {
|
||||
env = []settings.ActionEnvVar{}
|
||||
}
|
||||
for index := range env {
|
||||
env[index].Name = strings.TrimSpace(env[index].Name)
|
||||
if env[index].Name == "" || !envVarNamePattern.MatchString(env[index].Name) {
|
||||
return settings.NodeActionItem{}, errors.New("environment variable name must match [A-Za-z_][A-Za-z0-9_]*")
|
||||
}
|
||||
}
|
||||
return settings.NodeActionItem{
|
||||
ID: itemID,
|
||||
Source: settings.ActionItemSourceLibrary,
|
||||
LibraryActionID: libraryID,
|
||||
Env: []settings.ActionEnvVar{},
|
||||
Env: env,
|
||||
SetVariable: setVariable,
|
||||
RunIf: runIf,
|
||||
WidgetLabel: strings.TrimSpace(payload.WidgetLabel),
|
||||
}, nil
|
||||
case settings.ActionItemSourceLocal:
|
||||
env := payload.Env
|
||||
@@ -774,6 +804,7 @@ func buildNodeActionItem(payload createActionGroupItemRequest) (settings.NodeAct
|
||||
RequiresSudo: payload.RequiresSudo,
|
||||
SetVariable: setVariable,
|
||||
RunIf: runIf,
|
||||
WidgetLabel: strings.TrimSpace(payload.WidgetLabel),
|
||||
}, nil
|
||||
default:
|
||||
return settings.NodeActionItem{}, errors.New("source must be library or local")
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"codeberg.org/SquidSE/ClusterCanvas/service/internal/settings"
|
||||
)
|
||||
|
||||
type actionWidgetsResponse struct {
|
||||
Widgets []settings.ActionWidgetSnapshot `json:"widgets"`
|
||||
}
|
||||
|
||||
func (app *App) actionWidgetsListHandler(writer http.ResponseWriter, request *http.Request) {
|
||||
node, ok := app.loadAccessibleNode(writer, request)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
store, err := settings.LoadActionWidgetsOrEmpty(app.ConfigDir)
|
||||
if err != nil {
|
||||
writeJSON(writer, http.StatusInternalServerError, apiErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
writeJSON(writer, http.StatusOK, actionWidgetsResponse{
|
||||
Widgets: settings.WidgetsForNode(store, node.ID),
|
||||
})
|
||||
}
|
||||
|
||||
// actionWidgetsListAllHandler returns widget snapshots for every node the caller can access.
|
||||
func (app *App) actionWidgetsListAllHandler(writer http.ResponseWriter, request *http.Request) {
|
||||
if err := app.authorizeNodesRead(request); err != nil {
|
||||
writeJSON(writer, http.StatusForbidden, apiErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
user, ok := UserFromContext(request.Context())
|
||||
if !ok {
|
||||
writeJSON(writer, http.StatusUnauthorized, apiErrorResponse{Error: "authentication required"})
|
||||
return
|
||||
}
|
||||
|
||||
settingsPayload, err := loadSettingsOrDefault(app.ConfigDir)
|
||||
if err != nil {
|
||||
writeJSON(writer, http.StatusInternalServerError, apiErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
nodesStore, err := settings.LoadNodesOrEmpty(app.ConfigDir)
|
||||
if err != nil {
|
||||
writeJSON(writer, http.StatusInternalServerError, apiErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
accessibleNodeIDs := make(map[string]struct{}, len(nodesStore.Nodes))
|
||||
for _, node := range nodesStore.Nodes {
|
||||
if userCanAccessNode(user, settingsPayload.Groups, node) {
|
||||
accessibleNodeIDs[node.ID] = struct{}{}
|
||||
}
|
||||
}
|
||||
|
||||
widgetStore, err := settings.LoadActionWidgetsOrEmpty(app.ConfigDir)
|
||||
if err != nil {
|
||||
writeJSON(writer, http.StatusInternalServerError, apiErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
filtered := make([]settings.ActionWidgetSnapshot, 0)
|
||||
for _, widget := range widgetStore.Widgets {
|
||||
if _, ok := accessibleNodeIDs[widget.NodeID]; ok {
|
||||
filtered = append(filtered, widget)
|
||||
}
|
||||
}
|
||||
|
||||
writeJSON(writer, http.StatusOK, actionWidgetsResponse{Widgets: filtered})
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
package api
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"codeberg.org/SquidSE/ClusterCanvas/service/internal/auth"
|
||||
"codeberg.org/SquidSE/ClusterCanvas/service/internal/settings"
|
||||
)
|
||||
|
||||
func TestActionWidgetsListAllFiltersByNodeAccess(t *testing.T) {
|
||||
configDir := t.TempDir()
|
||||
_ = seedCompletedSetup(t, configDir)
|
||||
|
||||
keyBytes, err := settings.KeyFromEnv()
|
||||
if err != nil {
|
||||
t.Fatalf("KeyFromEnv: %v", err)
|
||||
}
|
||||
now := time.Now().UTC()
|
||||
passwordHash, err := auth.HashPassword("correct horse battery staple extra")
|
||||
if err != nil {
|
||||
t.Fatalf("HashPassword: %v", err)
|
||||
}
|
||||
|
||||
payload := settings.Settings{
|
||||
SetupCompleted: true,
|
||||
Groups: []settings.Group{
|
||||
{
|
||||
Name: settings.AdministratorsGroupName,
|
||||
ScopeKind: settings.GroupScopeGroup,
|
||||
ScopeName: "Admin Group",
|
||||
Permissions: allPermissionList(),
|
||||
},
|
||||
{
|
||||
Name: "TeamA",
|
||||
ScopeKind: settings.GroupScopeGroup,
|
||||
ScopeName: "Team A",
|
||||
Permissions: []string{"nodes.read"},
|
||||
},
|
||||
},
|
||||
Security: settings.DefaultSecuritySettings(),
|
||||
Network: settings.DefaultNetworkSettings(),
|
||||
}
|
||||
if err := settings.SaveSettings(configDir, payload); err != nil {
|
||||
t.Fatalf("SaveSettings: %v", err)
|
||||
}
|
||||
if err := settings.SavePasswords(configDir, settings.PasswordStore{
|
||||
Users: []settings.UserCredential{
|
||||
{
|
||||
ID: "admin-id",
|
||||
Username: "Admin",
|
||||
PasswordHash: passwordHash,
|
||||
Enabled: true,
|
||||
GroupNames: []string{settings.AdministratorsGroupName},
|
||||
CreatedAt: now,
|
||||
PasswordChangedAt: now,
|
||||
},
|
||||
{
|
||||
ID: "reader-id",
|
||||
Username: "reader",
|
||||
PasswordHash: passwordHash,
|
||||
Enabled: true,
|
||||
GroupNames: []string{"TeamA"},
|
||||
CreatedAt: now,
|
||||
PasswordChangedAt: now,
|
||||
},
|
||||
},
|
||||
}, keyBytes); err != nil {
|
||||
t.Fatalf("SavePasswords: %v", err)
|
||||
}
|
||||
|
||||
accessibleNodeID := "aaaaaaaa-bbbb-4ccc-8ddd-eeeeeeeeeeee"
|
||||
hiddenNodeID := "bbbbbbbb-bbbb-4ccc-8ddd-eeeeeeeeeeee"
|
||||
if err := settings.SaveNodes(configDir, settings.NodeStore{
|
||||
Nodes: []settings.Node{
|
||||
{
|
||||
ID: accessibleNodeID,
|
||||
Kind: settings.NodeKindContainer,
|
||||
Name: "visible",
|
||||
HostIP: "10.0.0.1",
|
||||
Username: "root",
|
||||
GroupName: "TeamA",
|
||||
CreatedAt: now,
|
||||
},
|
||||
{
|
||||
ID: hiddenNodeID,
|
||||
Kind: settings.NodeKindVM,
|
||||
Name: "hidden",
|
||||
HostIP: "10.0.0.2",
|
||||
Username: "root",
|
||||
GroupName: settings.AdministratorsGroupName,
|
||||
CreatedAt: now,
|
||||
},
|
||||
},
|
||||
}); err != nil {
|
||||
t.Fatalf("SaveNodes: %v", err)
|
||||
}
|
||||
|
||||
if err := settings.SaveActionWidgets(configDir, settings.ActionWidgetStore{
|
||||
Widgets: []settings.ActionWidgetSnapshot{
|
||||
{
|
||||
NodeID: accessibleNodeID,
|
||||
ItemID: "item-visible",
|
||||
GroupID: "group-1",
|
||||
WidgetType: settings.ActionWidgetTypeMemory,
|
||||
Title: "Memory",
|
||||
UpdatedAt: now,
|
||||
Memory: &settings.MemoryWidgetData{
|
||||
Total: 1024,
|
||||
Free: 512,
|
||||
},
|
||||
},
|
||||
{
|
||||
NodeID: hiddenNodeID,
|
||||
ItemID: "item-hidden",
|
||||
GroupID: "group-2",
|
||||
WidgetType: settings.ActionWidgetTypeDisk,
|
||||
Title: "Disk",
|
||||
UpdatedAt: now,
|
||||
Disk: &settings.DiskWidgetData{
|
||||
Size: "50G",
|
||||
Used: "12G",
|
||||
UsePercent: "24%",
|
||||
MountedOn: "/",
|
||||
},
|
||||
},
|
||||
},
|
||||
}); err != nil {
|
||||
t.Fatalf("SaveActionWidgets: %v", err)
|
||||
}
|
||||
|
||||
router := NewRouter(configDir)
|
||||
loginRecorder := httptest.NewRecorder()
|
||||
loginRequest := httptest.NewRequest(
|
||||
http.MethodPost,
|
||||
"/api/v1/auth/login",
|
||||
bytes.NewReader([]byte(`{"username":"reader","password":"correct horse battery staple extra"}`)),
|
||||
)
|
||||
loginRequest.Header.Set("Content-Type", "application/json")
|
||||
router.ServeHTTP(loginRecorder, loginRequest)
|
||||
if loginRecorder.Code != http.StatusOK {
|
||||
t.Fatalf("login status = %d body=%s", loginRecorder.Code, loginRecorder.Body.String())
|
||||
}
|
||||
var readerCookie *http.Cookie
|
||||
for _, cookie := range loginRecorder.Result().Cookies() {
|
||||
if cookie.Name == auth.SessionCookieName {
|
||||
readerCookie = cookie
|
||||
break
|
||||
}
|
||||
}
|
||||
if readerCookie == nil {
|
||||
t.Fatal("reader session cookie missing")
|
||||
}
|
||||
|
||||
listRequest := withSession(
|
||||
httptest.NewRequest(http.MethodGet, "/api/v1/action-widgets", nil),
|
||||
readerCookie,
|
||||
)
|
||||
listRecorder := httptest.NewRecorder()
|
||||
router.ServeHTTP(listRecorder, listRequest)
|
||||
if listRecorder.Code != http.StatusOK {
|
||||
t.Fatalf("list status = %d body=%s", listRecorder.Code, listRecorder.Body.String())
|
||||
}
|
||||
|
||||
var response actionWidgetsResponse
|
||||
if err := json.Unmarshal(listRecorder.Body.Bytes(), &response); err != nil {
|
||||
t.Fatalf("decode: %v", err)
|
||||
}
|
||||
if len(response.Widgets) != 1 {
|
||||
t.Fatalf("widgets = %+v", response.Widgets)
|
||||
}
|
||||
if response.Widgets[0].NodeID != accessibleNodeID {
|
||||
t.Fatalf("node_id = %q", response.Widgets[0].NodeID)
|
||||
}
|
||||
}
|
||||
@@ -23,6 +23,8 @@ type createActionRequest struct {
|
||||
Body string `json:"body"`
|
||||
Env []settings.ActionEnvVar `json:"env"`
|
||||
RequiresSudo bool `json:"requires_sudo"`
|
||||
ShowWidget bool `json:"show_widget"`
|
||||
WidgetType settings.ActionWidgetType `json:"widget_type"`
|
||||
}
|
||||
|
||||
type patchActionRequest struct {
|
||||
@@ -31,6 +33,8 @@ type patchActionRequest struct {
|
||||
Body *string `json:"body"`
|
||||
Env *[]settings.ActionEnvVar `json:"env"`
|
||||
RequiresSudo *bool `json:"requires_sudo"`
|
||||
ShowWidget *bool `json:"show_widget"`
|
||||
WidgetType *settings.ActionWidgetType `json:"widget_type"`
|
||||
}
|
||||
|
||||
var envVarNamePattern = regexp.MustCompile(`^[A-Za-z_][A-Za-z0-9_]*$`)
|
||||
@@ -41,7 +45,11 @@ var (
|
||||
errActionKindInvalid = errors.New("action kind must be shell or script")
|
||||
errActionNotFound = errors.New("action not found")
|
||||
errActionBuiltinRO = errors.New("built-in actions cannot be modified")
|
||||
errActionBuiltinBodyRO = errors.New("built-in action body and name cannot be modified")
|
||||
errActionIDRequired = errors.New("action id is required")
|
||||
errActionWidgetTypeNeeded = errors.New("widget_type is required when show_widget is true")
|
||||
errActionWidgetTypeBad = errors.New("widget_type must be memory, disk, or raw")
|
||||
errActionInUse = errors.New("action is used by node action groups; remove or clone to customize first")
|
||||
)
|
||||
|
||||
func actionsGetHandler(configDir string) http.HandlerFunc {
|
||||
@@ -120,12 +128,22 @@ func actionsPatchHandler(configDir string) http.HandlerFunc {
|
||||
writeJSON(writer, http.StatusNotFound, apiErrorResponse{Error: errActionNotFound.Error()})
|
||||
return
|
||||
}
|
||||
if store.Actions[index].Builtin {
|
||||
|
||||
updated := store.Actions[index]
|
||||
isBuiltin := updated.Builtin
|
||||
if isBuiltin {
|
||||
if payload.Name != nil || payload.Description != nil || payload.Body != nil ||
|
||||
payload.Env != nil || payload.RequiresSudo != nil {
|
||||
writeJSON(writer, http.StatusBadRequest, apiErrorResponse{Error: errActionBuiltinBodyRO.Error()})
|
||||
return
|
||||
}
|
||||
if payload.ShowWidget == nil && payload.WidgetType == nil {
|
||||
writeJSON(writer, http.StatusBadRequest, apiErrorResponse{Error: errActionBuiltinRO.Error()})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
updated := store.Actions[index]
|
||||
if !isBuiltin {
|
||||
if payload.Name != nil {
|
||||
updated.Name = strings.TrimSpace(*payload.Name)
|
||||
}
|
||||
@@ -141,12 +159,26 @@ func actionsPatchHandler(configDir string) http.HandlerFunc {
|
||||
if payload.RequiresSudo != nil {
|
||||
updated.RequiresSudo = *payload.RequiresSudo
|
||||
}
|
||||
}
|
||||
if payload.ShowWidget != nil {
|
||||
updated.ShowWidget = *payload.ShowWidget
|
||||
}
|
||||
if payload.WidgetType != nil {
|
||||
updated.WidgetType = *payload.WidgetType
|
||||
}
|
||||
if !updated.ShowWidget {
|
||||
updated.WidgetType = settings.ActionWidgetTypeNone
|
||||
}
|
||||
updated.UpdatedAt = time.Now().UTC()
|
||||
|
||||
if err := validateActionFields(updated.Name, updated.Kind, updated.Body, updated.Env); err != nil {
|
||||
writeJSON(writer, http.StatusBadRequest, apiErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
if err := validateActionWidget(updated.ShowWidget, updated.WidgetType); err != nil {
|
||||
writeJSON(writer, http.StatusBadRequest, apiErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
if updated.Env == nil {
|
||||
updated.Env = []settings.ActionEnvVar{}
|
||||
}
|
||||
@@ -190,6 +222,16 @@ func actionsDeleteHandler(configDir string) http.HandlerFunc {
|
||||
return
|
||||
}
|
||||
|
||||
groups, err := settings.LoadNodeActionGroupsOrEmpty(configDir)
|
||||
if err != nil {
|
||||
writeJSON(writer, http.StatusInternalServerError, apiErrorResponse{Error: err.Error()})
|
||||
return
|
||||
}
|
||||
if settings.LibraryActionInUse(groups, actionID) {
|
||||
writeJSON(writer, http.StatusConflict, apiErrorResponse{Error: errActionInUse.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
store.Actions = append(store.Actions[:index], store.Actions[index+1:]...)
|
||||
if err := settings.SaveActions(configDir, store); err != nil {
|
||||
writeJSON(writer, http.StatusInternalServerError, apiErrorResponse{Error: err.Error()})
|
||||
@@ -212,6 +254,13 @@ func buildCustomAction(payload createActionRequest) (settings.Action, error) {
|
||||
if err := validateActionFields(name, payload.Kind, body, env); err != nil {
|
||||
return settings.Action{}, err
|
||||
}
|
||||
widgetType := payload.WidgetType
|
||||
if !payload.ShowWidget {
|
||||
widgetType = settings.ActionWidgetTypeNone
|
||||
}
|
||||
if err := validateActionWidget(payload.ShowWidget, widgetType); err != nil {
|
||||
return settings.Action{}, err
|
||||
}
|
||||
|
||||
actionID, err := auth.NewUUID()
|
||||
if err != nil {
|
||||
@@ -227,6 +276,8 @@ func buildCustomAction(payload createActionRequest) (settings.Action, error) {
|
||||
Body: body,
|
||||
Env: env,
|
||||
RequiresSudo: payload.RequiresSudo,
|
||||
ShowWidget: payload.ShowWidget,
|
||||
WidgetType: widgetType,
|
||||
Builtin: false,
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
@@ -257,6 +308,20 @@ func validateActionFields(
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateActionWidget(showWidget bool, widgetType settings.ActionWidgetType) error {
|
||||
if !showWidget {
|
||||
return nil
|
||||
}
|
||||
switch widgetType {
|
||||
case settings.ActionWidgetTypeMemory, settings.ActionWidgetTypeDisk, settings.ActionWidgetTypeRaw:
|
||||
return nil
|
||||
case settings.ActionWidgetTypeNone:
|
||||
return errActionWidgetTypeNeeded
|
||||
default:
|
||||
return errActionWidgetTypeBad
|
||||
}
|
||||
}
|
||||
|
||||
func findActionIndex(actions []settings.Action, actionID string) int {
|
||||
for index := range actions {
|
||||
if actions[index].ID == actionID {
|
||||
|
||||
@@ -31,8 +31,8 @@ func TestActionsGetSeedsBuiltins(t *testing.T) {
|
||||
if err := json.NewDecoder(recorder.Body).Decode(&payload); err != nil {
|
||||
t.Fatalf("decode response: %v", err)
|
||||
}
|
||||
if len(payload.Actions) != 3 {
|
||||
t.Fatalf("expected 3 builtins, got %d", len(payload.Actions))
|
||||
if len(payload.Actions) != 4 {
|
||||
t.Fatalf("expected 4 builtins, got %d", len(payload.Actions))
|
||||
}
|
||||
for _, action := range payload.Actions {
|
||||
if !action.Builtin {
|
||||
@@ -65,10 +65,10 @@ func TestActionsCreatePatchDelete(t *testing.T) {
|
||||
if err := json.NewDecoder(createRec.Body).Decode(&created); err != nil {
|
||||
t.Fatalf("decode create: %v", err)
|
||||
}
|
||||
if len(created.Actions) != 4 {
|
||||
t.Fatalf("expected 4 actions after create, got %d", len(created.Actions))
|
||||
if len(created.Actions) != 5 {
|
||||
t.Fatalf("expected 5 actions after create, got %d", len(created.Actions))
|
||||
}
|
||||
custom := created.Actions[3]
|
||||
custom := created.Actions[4]
|
||||
if custom.Builtin || custom.Name != "Echo host" || custom.Kind != settings.ActionKindShell {
|
||||
t.Fatalf("custom = %#v", custom)
|
||||
}
|
||||
@@ -121,8 +121,8 @@ func TestActionsCreatePatchDelete(t *testing.T) {
|
||||
if err := json.NewDecoder(deleteRec.Body).Decode(&deleted); err != nil {
|
||||
t.Fatalf("decode delete: %v", err)
|
||||
}
|
||||
if len(deleted.Actions) != 3 {
|
||||
t.Fatalf("expected 3 after delete, got %d", len(deleted.Actions))
|
||||
if len(deleted.Actions) != 4 {
|
||||
t.Fatalf("expected 4 after delete, got %d", len(deleted.Actions))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -151,6 +151,33 @@ func TestActionsRejectBuiltinMutationAndInvalidInput(t *testing.T) {
|
||||
t.Fatalf("expected builtin patch 400, got %d", patchRec.Code)
|
||||
}
|
||||
|
||||
widgetBody := []byte(`{"show_widget":true,"widget_type":"raw"}`)
|
||||
widgetReq := withSession(
|
||||
httptest.NewRequest(http.MethodPatch, "/api/v1/actions?id="+builtinID, bytes.NewReader(widgetBody)),
|
||||
cookie,
|
||||
)
|
||||
widgetRec := httptest.NewRecorder()
|
||||
router.ServeHTTP(widgetRec, widgetReq)
|
||||
if widgetRec.Code != http.StatusOK {
|
||||
t.Fatalf("expected builtin widget patch 200, got %d body=%s", widgetRec.Code, widgetRec.Body.String())
|
||||
}
|
||||
var widgetPatched actionsResponseTest
|
||||
if err := json.NewDecoder(widgetRec.Body).Decode(&widgetPatched); err != nil {
|
||||
t.Fatalf("decode widget patch: %v", err)
|
||||
}
|
||||
foundWidget := false
|
||||
for _, action := range widgetPatched.Actions {
|
||||
if action.ID == builtinID {
|
||||
foundWidget = true
|
||||
if !action.ShowWidget || action.WidgetType != settings.ActionWidgetTypeRaw {
|
||||
t.Fatalf("widget patched = %#v", action)
|
||||
}
|
||||
}
|
||||
}
|
||||
if !foundWidget {
|
||||
t.Fatal("builtin missing after widget patch")
|
||||
}
|
||||
|
||||
deleteReq := withSession(
|
||||
httptest.NewRequest(http.MethodDelete, "/api/v1/actions?id="+builtinID, nil),
|
||||
cookie,
|
||||
@@ -230,3 +257,147 @@ func TestActionsMutationsRequirePermissions(t *testing.T) {
|
||||
t.Fatalf("expected delete 403, got %d", deleteRec.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestActionsDeleteBlockedWhenLibraryReferenced(t *testing.T) {
|
||||
configDir := t.TempDir()
|
||||
cookie := seedCompletedSetup(t, configDir)
|
||||
node := seedNodeForActions(t, configDir)
|
||||
router := NewRouter(configDir)
|
||||
|
||||
createBody := []byte(`{"name":"Echo host","kind":"shell","body":"echo hi"}`)
|
||||
createReq := withSession(httptest.NewRequest(http.MethodPost, "/api/v1/actions", bytes.NewReader(createBody)), cookie)
|
||||
createRec := httptest.NewRecorder()
|
||||
router.ServeHTTP(createRec, createReq)
|
||||
if createRec.Code != http.StatusOK {
|
||||
t.Fatalf("create status %d body=%s", createRec.Code, createRec.Body.String())
|
||||
}
|
||||
var created actionsResponseTest
|
||||
if err := json.NewDecoder(createRec.Body).Decode(&created); err != nil {
|
||||
t.Fatalf("decode create: %v", err)
|
||||
}
|
||||
customID := ""
|
||||
for _, action := range created.Actions {
|
||||
if !action.Builtin {
|
||||
customID = action.ID
|
||||
break
|
||||
}
|
||||
}
|
||||
if customID == "" {
|
||||
t.Fatal("custom action missing")
|
||||
}
|
||||
|
||||
groupBody, _ := json.Marshal(map[string]any{
|
||||
"name": "Nightly",
|
||||
"schedule": map[string]any{
|
||||
"enabled": false,
|
||||
"kind": "interval",
|
||||
"every_seconds": 3600,
|
||||
"times": []any{},
|
||||
},
|
||||
})
|
||||
groupReq := withSession(
|
||||
httptest.NewRequest(http.MethodPost, "/api/v1/nodes/"+node.ID+"/action-groups", bytes.NewReader(groupBody)),
|
||||
cookie,
|
||||
)
|
||||
groupRec := httptest.NewRecorder()
|
||||
router.ServeHTTP(groupRec, groupReq)
|
||||
if groupRec.Code != http.StatusOK {
|
||||
t.Fatalf("create group status=%d body=%s", groupRec.Code, groupRec.Body.String())
|
||||
}
|
||||
var groupResp actionGroupResponse
|
||||
if err := json.Unmarshal(groupRec.Body.Bytes(), &groupResp); err != nil {
|
||||
t.Fatalf("decode group: %v", err)
|
||||
}
|
||||
groupID := groupResp.Group.ID
|
||||
|
||||
addBody, _ := json.Marshal(map[string]any{
|
||||
"source": "library",
|
||||
"library_action_id": customID,
|
||||
})
|
||||
addReq := withSession(
|
||||
httptest.NewRequest(
|
||||
http.MethodPost,
|
||||
"/api/v1/nodes/"+node.ID+"/action-groups/"+groupID+"/items",
|
||||
bytes.NewReader(addBody),
|
||||
),
|
||||
cookie,
|
||||
)
|
||||
addRec := httptest.NewRecorder()
|
||||
router.ServeHTTP(addRec, addReq)
|
||||
if addRec.Code != http.StatusOK {
|
||||
t.Fatalf("add item status=%d body=%s", addRec.Code, addRec.Body.String())
|
||||
}
|
||||
var withItem actionGroupResponse
|
||||
if err := json.Unmarshal(addRec.Body.Bytes(), &withItem); err != nil {
|
||||
t.Fatalf("decode add: %v", err)
|
||||
}
|
||||
itemID := withItem.Group.Items[0].ID
|
||||
|
||||
blockedReq := withSession(
|
||||
httptest.NewRequest(http.MethodDelete, "/api/v1/actions?id="+customID, nil),
|
||||
cookie,
|
||||
)
|
||||
blockedRec := httptest.NewRecorder()
|
||||
router.ServeHTTP(blockedRec, blockedReq)
|
||||
if blockedRec.Code != http.StatusConflict {
|
||||
t.Fatalf("expected delete conflict %d, got %d body=%s", http.StatusConflict, blockedRec.Code, blockedRec.Body.String())
|
||||
}
|
||||
var blockedErr apiErrorResponse
|
||||
if err := json.NewDecoder(blockedRec.Body).Decode(&blockedErr); err != nil {
|
||||
t.Fatalf("decode conflict: %v", err)
|
||||
}
|
||||
if blockedErr.Error != errActionInUse.Error() {
|
||||
t.Fatalf("conflict error = %q, want %q", blockedErr.Error, errActionInUse.Error())
|
||||
}
|
||||
|
||||
listReq := withSession(httptest.NewRequest(http.MethodGet, "/api/v1/actions", nil), cookie)
|
||||
listRec := httptest.NewRecorder()
|
||||
router.ServeHTTP(listRec, listReq)
|
||||
var listed actionsResponseTest
|
||||
if err := json.NewDecoder(listRec.Body).Decode(&listed); err != nil {
|
||||
t.Fatalf("decode list: %v", err)
|
||||
}
|
||||
stillPresent := false
|
||||
for _, action := range listed.Actions {
|
||||
if action.ID == customID {
|
||||
stillPresent = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !stillPresent {
|
||||
t.Fatal("action should remain after blocked delete")
|
||||
}
|
||||
|
||||
cloneReq := withSession(
|
||||
httptest.NewRequest(
|
||||
http.MethodPost,
|
||||
"/api/v1/nodes/"+node.ID+"/action-groups/"+groupID+"/items/"+itemID+"/clone",
|
||||
nil,
|
||||
),
|
||||
cookie,
|
||||
)
|
||||
cloneRec := httptest.NewRecorder()
|
||||
router.ServeHTTP(cloneRec, cloneReq)
|
||||
if cloneRec.Code != http.StatusOK {
|
||||
t.Fatalf("clone status=%d body=%s", cloneRec.Code, cloneRec.Body.String())
|
||||
}
|
||||
|
||||
deleteReq := withSession(
|
||||
httptest.NewRequest(http.MethodDelete, "/api/v1/actions?id="+customID, nil),
|
||||
cookie,
|
||||
)
|
||||
deleteRec := httptest.NewRecorder()
|
||||
router.ServeHTTP(deleteRec, deleteReq)
|
||||
if deleteRec.Code != http.StatusOK {
|
||||
t.Fatalf("delete after clone status %d body=%s", deleteRec.Code, deleteRec.Body.String())
|
||||
}
|
||||
var deleted actionsResponseTest
|
||||
if err := json.NewDecoder(deleteRec.Body).Decode(&deleted); err != nil {
|
||||
t.Fatalf("decode delete: %v", err)
|
||||
}
|
||||
for _, action := range deleted.Actions {
|
||||
if action.ID == customID {
|
||||
t.Fatal("custom action should be gone after delete")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -317,6 +317,15 @@ func (app *App) nodesPatchHandler(writer http.ResponseWriter, request *http.Requ
|
||||
node,
|
||||
fmt.Sprintf("health_check_interval_seconds=%d", node.HealthCheckIntervalSeconds),
|
||||
)
|
||||
|
||||
if node.HealthCheckIntervalSeconds > 0 && app.HealthChecker != nil {
|
||||
nodeID := node.ID
|
||||
actor := user.Username
|
||||
go func() {
|
||||
_, _ = app.HealthChecker.CheckNodeByID(nodeID, actor)
|
||||
}()
|
||||
}
|
||||
|
||||
writeJSON(writer, http.StatusOK, nodeResponse{Node: node})
|
||||
}
|
||||
|
||||
|
||||
@@ -6,7 +6,9 @@ import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"codeberg.org/SquidSE/ClusterCanvas/service/internal/auth"
|
||||
)
|
||||
@@ -171,6 +173,154 @@ func TestNodesPatchHealthInterval(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestNodesPatchHealthIntervalTriggersBackgroundCheck(t *testing.T) {
|
||||
configDir := t.TempDir()
|
||||
cookie := seedCompletedSetup(t, configDir)
|
||||
router, app := NewRouterWithApp(configDir)
|
||||
|
||||
app.HealthChecker.PingFn = func(hostIP string) auth.PingResult {
|
||||
return auth.PingResult{OK: true, Method: auth.PingMethodICMP}
|
||||
}
|
||||
app.HealthChecker.SSHFn = func(hostIP string, username string, privateKeyPEM string, passphrase string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
const nodeID = "ffffffff-bbbb-4ccc-8ddd-eeeeeeeeeeee"
|
||||
createBody := []byte(`{
|
||||
"id":"` + nodeID + `",
|
||||
"kind":"container",
|
||||
"name":"bg-check-node",
|
||||
"host_ip":"10.20.30.44",
|
||||
"group_name":"Administrators",
|
||||
"generate":{"algorithm":"ed25519"}
|
||||
}`)
|
||||
createRequest := withSession(
|
||||
httptest.NewRequest(http.MethodPost, "/api/v1/nodes", bytes.NewReader(createBody)),
|
||||
cookie,
|
||||
)
|
||||
createRecorder := httptest.NewRecorder()
|
||||
router.ServeHTTP(createRecorder, createRequest)
|
||||
if createRecorder.Code != http.StatusCreated {
|
||||
t.Fatalf("create status = %d body=%s", createRecorder.Code, createRecorder.Body.String())
|
||||
}
|
||||
|
||||
patchBody := []byte(`{"health_check_interval_seconds":60}`)
|
||||
patchRequest := withSession(
|
||||
httptest.NewRequest(
|
||||
http.MethodPatch,
|
||||
"/api/v1/nodes/"+nodeID,
|
||||
bytes.NewReader(patchBody),
|
||||
),
|
||||
cookie,
|
||||
)
|
||||
patchRecorder := httptest.NewRecorder()
|
||||
router.ServeHTTP(patchRecorder, patchRequest)
|
||||
if patchRecorder.Code != http.StatusOK {
|
||||
t.Fatalf("patch status = %d body=%s", patchRecorder.Code, patchRecorder.Body.String())
|
||||
}
|
||||
|
||||
deadline := time.Now().Add(2 * time.Second)
|
||||
for {
|
||||
getRequest := withSession(
|
||||
httptest.NewRequest(http.MethodGet, "/api/v1/nodes/"+nodeID, nil),
|
||||
cookie,
|
||||
)
|
||||
getRecorder := httptest.NewRecorder()
|
||||
router.ServeHTTP(getRecorder, getRequest)
|
||||
if getRecorder.Code != http.StatusOK {
|
||||
t.Fatalf("get status = %d body=%s", getRecorder.Code, getRecorder.Body.String())
|
||||
}
|
||||
|
||||
var response nodeResponse
|
||||
if err := json.Unmarshal(getRecorder.Body.Bytes(), &response); err != nil {
|
||||
t.Fatalf("decode: %v", err)
|
||||
}
|
||||
if response.Node.HealthOK != nil && *response.Node.HealthOK {
|
||||
return
|
||||
}
|
||||
if time.Now().After(deadline) {
|
||||
t.Fatalf("timed out waiting for background health check, node=%#v", response.Node)
|
||||
}
|
||||
time.Sleep(20 * time.Millisecond)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNodesPatchHealthIntervalZeroSkipsBackgroundCheck(t *testing.T) {
|
||||
configDir := t.TempDir()
|
||||
cookie := seedCompletedSetup(t, configDir)
|
||||
router, app := NewRouterWithApp(configDir)
|
||||
|
||||
checked := atomic.Bool{}
|
||||
app.HealthChecker.PingFn = func(hostIP string) auth.PingResult {
|
||||
checked.Store(true)
|
||||
return auth.PingResult{OK: true, Method: auth.PingMethodICMP}
|
||||
}
|
||||
app.HealthChecker.SSHFn = func(hostIP string, username string, privateKeyPEM string, passphrase string) error {
|
||||
checked.Store(true)
|
||||
return nil
|
||||
}
|
||||
|
||||
const nodeID = "aaaaaaaa-cccc-4ddd-8eee-ffffffffffff"
|
||||
createBody := []byte(`{
|
||||
"id":"` + nodeID + `",
|
||||
"kind":"vm",
|
||||
"name":"no-bg-check-node",
|
||||
"host_ip":"10.20.30.45",
|
||||
"group_name":"Administrators",
|
||||
"generate":{"algorithm":"ed25519"}
|
||||
}`)
|
||||
createRequest := withSession(
|
||||
httptest.NewRequest(http.MethodPost, "/api/v1/nodes", bytes.NewReader(createBody)),
|
||||
cookie,
|
||||
)
|
||||
createRecorder := httptest.NewRecorder()
|
||||
router.ServeHTTP(createRecorder, createRequest)
|
||||
if createRecorder.Code != http.StatusCreated {
|
||||
t.Fatalf("create status = %d body=%s", createRecorder.Code, createRecorder.Body.String())
|
||||
}
|
||||
|
||||
patchBody := []byte(`{"health_check_interval_seconds":0}`)
|
||||
patchRequest := withSession(
|
||||
httptest.NewRequest(
|
||||
http.MethodPatch,
|
||||
"/api/v1/nodes/"+nodeID,
|
||||
bytes.NewReader(patchBody),
|
||||
),
|
||||
cookie,
|
||||
)
|
||||
patchRecorder := httptest.NewRecorder()
|
||||
router.ServeHTTP(patchRecorder, patchRequest)
|
||||
if patchRecorder.Code != http.StatusOK {
|
||||
t.Fatalf("patch status = %d body=%s", patchRecorder.Code, patchRecorder.Body.String())
|
||||
}
|
||||
|
||||
time.Sleep(100 * time.Millisecond)
|
||||
|
||||
getRequest := withSession(
|
||||
httptest.NewRequest(http.MethodGet, "/api/v1/nodes/"+nodeID, nil),
|
||||
cookie,
|
||||
)
|
||||
getRecorder := httptest.NewRecorder()
|
||||
router.ServeHTTP(getRecorder, getRequest)
|
||||
if getRecorder.Code != http.StatusOK {
|
||||
t.Fatalf("get status = %d body=%s", getRecorder.Code, getRecorder.Body.String())
|
||||
}
|
||||
|
||||
var response nodeResponse
|
||||
if err := json.Unmarshal(getRecorder.Body.Bytes(), &response); err != nil {
|
||||
t.Fatalf("decode: %v", err)
|
||||
}
|
||||
if checked.Load() {
|
||||
t.Fatal("expected no background health check when interval is 0")
|
||||
}
|
||||
if response.Node.HealthOK != nil {
|
||||
t.Fatalf("expected health_ok unset, got %#v", response.Node.HealthOK)
|
||||
}
|
||||
if response.Node.HealthLastCheckedAt != nil {
|
||||
t.Fatalf("expected health_last_checked_at unset, got %#v", response.Node.HealthLastCheckedAt)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNodesPatchHealthIntervalRejectsNegative(t *testing.T) {
|
||||
configDir := t.TempDir()
|
||||
cookie := seedCompletedSetup(t, configDir)
|
||||
|
||||
@@ -83,6 +83,8 @@ func NewRouterWithApp(configDir string) (http.Handler, *App) {
|
||||
mux.HandleFunc("POST /api/v1/nodes/{id}/action-groups/{gid}/items/{iid}/run", app.actionGroupItemsRunHandler)
|
||||
mux.HandleFunc("GET /api/v1/nodes/{id}/action-logs", app.actionLogsListHandler)
|
||||
mux.HandleFunc("GET /api/v1/nodes/{id}/action-logs/{filename}", app.actionLogsGetHandler)
|
||||
mux.HandleFunc("GET /api/v1/nodes/{id}/action-widgets", app.actionWidgetsListHandler)
|
||||
mux.HandleFunc("GET /api/v1/action-widgets", app.actionWidgetsListAllHandler)
|
||||
mux.HandleFunc("GET /api/v1/node-logs", app.nodeLogsListHandler)
|
||||
mux.HandleFunc("GET /api/v1/auth-logs", app.authLogsListHandler)
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"time"
|
||||
|
||||
"codeberg.org/SquidSE/ClusterCanvas/service/internal/auth"
|
||||
"codeberg.org/SquidSE/ClusterCanvas/service/internal/runner/widgetparse"
|
||||
"codeberg.org/SquidSE/ClusterCanvas/service/internal/settings"
|
||||
)
|
||||
|
||||
@@ -126,10 +127,7 @@ func ResolveItem(item settings.NodeActionItem, library []settings.Action) (Resol
|
||||
}
|
||||
for _, action := range library {
|
||||
if action.ID == item.LibraryActionID {
|
||||
env := action.Env
|
||||
if env == nil {
|
||||
env = []settings.ActionEnvVar{}
|
||||
}
|
||||
env := mergeActionEnv(action.Env, item.Env)
|
||||
return ResolvedAction{
|
||||
Name: action.Name,
|
||||
Description: action.Description,
|
||||
@@ -170,6 +168,36 @@ func ResolveItem(item settings.NodeActionItem, library []settings.Action) (Resol
|
||||
}
|
||||
}
|
||||
|
||||
// mergeActionEnv returns library env with item overrides (item wins on name clash).
|
||||
func mergeActionEnv(
|
||||
libraryEnv []settings.ActionEnvVar,
|
||||
itemEnv []settings.ActionEnvVar,
|
||||
) []settings.ActionEnvVar {
|
||||
merged := make([]settings.ActionEnvVar, 0, len(libraryEnv)+len(itemEnv))
|
||||
indexByName := map[string]int{}
|
||||
for _, envVar := range libraryEnv {
|
||||
name := strings.TrimSpace(envVar.Name)
|
||||
if name == "" {
|
||||
continue
|
||||
}
|
||||
indexByName[name] = len(merged)
|
||||
merged = append(merged, settings.ActionEnvVar{Name: name, Value: envVar.Value})
|
||||
}
|
||||
for _, envVar := range itemEnv {
|
||||
name := strings.TrimSpace(envVar.Name)
|
||||
if name == "" {
|
||||
continue
|
||||
}
|
||||
if index, exists := indexByName[name]; exists {
|
||||
merged[index].Value = envVar.Value
|
||||
continue
|
||||
}
|
||||
indexByName[name] = len(merged)
|
||||
merged = append(merged, settings.ActionEnvVar{Name: name, Value: envVar.Value})
|
||||
}
|
||||
return merged
|
||||
}
|
||||
|
||||
// WrapShellWithSudo prepends "sudo -n " when requiresSudo is true.
|
||||
func WrapShellWithSudo(command string, requiresSudo bool) string {
|
||||
if !requiresSudo {
|
||||
@@ -390,6 +418,17 @@ func (executor *Executor) runItems(
|
||||
itemResult.VariableValue = captured
|
||||
}
|
||||
results = append(results, itemResult)
|
||||
|
||||
if item.Source == settings.ActionItemSourceLibrary {
|
||||
_ = persistWidgetSnapshot(
|
||||
executor.ConfigDir,
|
||||
group,
|
||||
item,
|
||||
libraryStore.Actions,
|
||||
itemResult,
|
||||
envMap,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
finishedAt := time.Now().UTC()
|
||||
@@ -447,3 +486,68 @@ func (executor *Executor) loadNodeCredentials(nodeID string) (settings.Node, str
|
||||
}
|
||||
return settings.Node{}, "", "", fmt.Errorf("node private key not found")
|
||||
}
|
||||
|
||||
func persistWidgetSnapshot(
|
||||
configDir string,
|
||||
group settings.NodeActionGroup,
|
||||
item settings.NodeActionItem,
|
||||
library []settings.Action,
|
||||
itemResult settings.ActionItemRunResult,
|
||||
envMap map[string]string,
|
||||
) error {
|
||||
var libraryAction *settings.Action
|
||||
for index := range library {
|
||||
if library[index].ID == item.LibraryActionID {
|
||||
libraryAction = &library[index]
|
||||
break
|
||||
}
|
||||
}
|
||||
if libraryAction == nil || !libraryAction.ShowWidget {
|
||||
return nil
|
||||
}
|
||||
widgetType := libraryAction.WidgetType
|
||||
if widgetType == settings.ActionWidgetTypeNone {
|
||||
widgetType = settings.ActionWidgetTypeRaw
|
||||
}
|
||||
|
||||
title := strings.TrimSpace(item.WidgetLabel)
|
||||
if title == "" {
|
||||
title = libraryAction.Name
|
||||
if mount := strings.TrimSpace(envMap["MOUNT"]); mount != "" && widgetType == settings.ActionWidgetTypeDisk {
|
||||
title = fmt.Sprintf("%s (%s)", libraryAction.Name, mount)
|
||||
}
|
||||
}
|
||||
|
||||
snapshot := settings.ActionWidgetSnapshot{
|
||||
NodeID: group.NodeID,
|
||||
ItemID: item.ID,
|
||||
GroupID: group.ID,
|
||||
LibraryActionID: item.LibraryActionID,
|
||||
WidgetType: widgetType,
|
||||
Title: title,
|
||||
UpdatedAt: itemResult.FinishedAt,
|
||||
ExitCode: itemResult.ExitCode,
|
||||
Error: itemResult.Error,
|
||||
RawStdout: itemResult.Stdout,
|
||||
}
|
||||
|
||||
if itemResult.Skipped {
|
||||
snapshot.Error = itemResult.SkipReason
|
||||
return settings.UpsertActionWidgetSnapshot(configDir, snapshot)
|
||||
}
|
||||
|
||||
mountHint := strings.TrimSpace(envMap["MOUNT"])
|
||||
if itemResult.ExitCode == 0 && itemResult.Error == "" {
|
||||
memory, disk, parseErr := widgetparse.ParseStdout(widgetType, itemResult.Stdout, mountHint)
|
||||
if parseErr != nil {
|
||||
snapshot.Error = parseErr.Error()
|
||||
} else {
|
||||
snapshot.Memory = memory
|
||||
snapshot.Disk = disk
|
||||
}
|
||||
} else if snapshot.Error == "" && itemResult.ExitCode != 0 {
|
||||
snapshot.Error = fmt.Sprintf("exit code %d", itemResult.ExitCode)
|
||||
}
|
||||
|
||||
return settings.UpsertActionWidgetSnapshot(configDir, snapshot)
|
||||
}
|
||||
|
||||
@@ -15,6 +15,16 @@ func TestResolveItemLibraryAndLocal(t *testing.T) {
|
||||
Body: "uptime",
|
||||
Env: []settings.ActionEnvVar{},
|
||||
},
|
||||
{
|
||||
ID: settings.BuiltinActionDiskUsageID,
|
||||
Name: "Disk usage",
|
||||
Kind: settings.ActionKindShell,
|
||||
Body: "df -h {{env.MOUNT}}",
|
||||
Env: []settings.ActionEnvVar{
|
||||
{Name: "MOUNT", Value: "/"},
|
||||
{Name: "EXTRA", Value: "lib"},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
resolved, err := ResolveItem(settings.NodeActionItem{
|
||||
@@ -29,6 +39,32 @@ func TestResolveItemLibraryAndLocal(t *testing.T) {
|
||||
t.Fatalf("resolved = %#v", resolved)
|
||||
}
|
||||
|
||||
merged, err := ResolveItem(settings.NodeActionItem{
|
||||
ID: "i-disk",
|
||||
Source: settings.ActionItemSourceLibrary,
|
||||
LibraryActionID: settings.BuiltinActionDiskUsageID,
|
||||
Env: []settings.ActionEnvVar{
|
||||
{Name: "MOUNT", Value: "/data"},
|
||||
{Name: "ITEM_ONLY", Value: "yes"},
|
||||
},
|
||||
}, library)
|
||||
if err != nil {
|
||||
t.Fatalf("ResolveItem merge: %v", err)
|
||||
}
|
||||
envByName := map[string]string{}
|
||||
for _, envVar := range merged.Env {
|
||||
envByName[envVar.Name] = envVar.Value
|
||||
}
|
||||
if envByName["MOUNT"] != "/data" {
|
||||
t.Fatalf("MOUNT override = %#v", merged.Env)
|
||||
}
|
||||
if envByName["EXTRA"] != "lib" {
|
||||
t.Fatalf("library EXTRA lost: %#v", merged.Env)
|
||||
}
|
||||
if envByName["ITEM_ONLY"] != "yes" {
|
||||
t.Fatalf("item env missing: %#v", merged.Env)
|
||||
}
|
||||
|
||||
local, err := ResolveItem(settings.NodeActionItem{
|
||||
ID: "i2",
|
||||
Source: settings.ActionItemSourceLocal,
|
||||
|
||||
@@ -0,0 +1,201 @@
|
||||
package widgetparse
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"codeberg.org/SquidSE/ClusterCanvas/service/internal/settings"
|
||||
)
|
||||
|
||||
var (
|
||||
whitespaceSplit = regexp.MustCompile(`\s+`)
|
||||
sizeUnitPattern = regexp.MustCompile(`(?i)^([0-9]*\.?[0-9]+)([KMGTP]?i?B?)$`)
|
||||
)
|
||||
|
||||
// ParseStdout parses action stdout into typed widget data for the given widget type.
|
||||
// mountHint selects a df row when widgetType is disk (e.g. "/" or "/data").
|
||||
func ParseStdout(
|
||||
widgetType settings.ActionWidgetType,
|
||||
stdout string,
|
||||
mountHint string,
|
||||
) (memory *settings.MemoryWidgetData, disk *settings.DiskWidgetData, parseErr error) {
|
||||
switch widgetType {
|
||||
case settings.ActionWidgetTypeMemory:
|
||||
data, err := ParseFreeM(stdout)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
return &data, nil, nil
|
||||
case settings.ActionWidgetTypeDisk:
|
||||
data, err := ParseDfH(stdout, mountHint)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
return nil, &data, nil
|
||||
case settings.ActionWidgetTypeRaw:
|
||||
return nil, nil, nil
|
||||
default:
|
||||
return nil, nil, fmt.Errorf("unsupported widget type %q", widgetType)
|
||||
}
|
||||
}
|
||||
|
||||
// ParseFreeM parses `free -m` output and returns the Mem: line figures.
|
||||
func ParseFreeM(stdout string) (settings.MemoryWidgetData, error) {
|
||||
lines := strings.Split(stdout, "\n")
|
||||
for _, line := range lines {
|
||||
trimmed := strings.TrimSpace(line)
|
||||
if trimmed == "" {
|
||||
continue
|
||||
}
|
||||
fields := whitespaceSplit.Split(trimmed, -1)
|
||||
if len(fields) < 4 {
|
||||
continue
|
||||
}
|
||||
if !strings.EqualFold(strings.TrimSuffix(fields[0], ":"), "Mem") {
|
||||
continue
|
||||
}
|
||||
total, err := strconv.ParseInt(fields[1], 10, 64)
|
||||
if err != nil {
|
||||
return settings.MemoryWidgetData{}, fmt.Errorf("parse free total: %w", err)
|
||||
}
|
||||
used, err := strconv.ParseInt(fields[2], 10, 64)
|
||||
if err != nil {
|
||||
return settings.MemoryWidgetData{}, fmt.Errorf("parse free used: %w", err)
|
||||
}
|
||||
free, err := strconv.ParseInt(fields[3], 10, 64)
|
||||
if err != nil {
|
||||
return settings.MemoryWidgetData{}, fmt.Errorf("parse free free: %w", err)
|
||||
}
|
||||
data := settings.MemoryWidgetData{
|
||||
Total: total,
|
||||
Used: used,
|
||||
Free: free,
|
||||
}
|
||||
if len(fields) > 4 {
|
||||
shared, err := strconv.ParseInt(fields[4], 10, 64)
|
||||
if err != nil {
|
||||
return settings.MemoryWidgetData{}, fmt.Errorf("parse free shared: %w", err)
|
||||
}
|
||||
data.Shared = shared
|
||||
}
|
||||
if len(fields) > 5 {
|
||||
buffCache, err := strconv.ParseInt(fields[5], 10, 64)
|
||||
if err != nil {
|
||||
return settings.MemoryWidgetData{}, fmt.Errorf("parse free buff/cache: %w", err)
|
||||
}
|
||||
data.BuffCache = buffCache
|
||||
}
|
||||
if len(fields) > 6 {
|
||||
available, err := strconv.ParseInt(fields[6], 10, 64)
|
||||
if err != nil {
|
||||
return settings.MemoryWidgetData{}, fmt.Errorf("parse free available: %w", err)
|
||||
}
|
||||
data.Available = available
|
||||
}
|
||||
return data, nil
|
||||
}
|
||||
return settings.MemoryWidgetData{}, fmt.Errorf("no Mem line found in free output")
|
||||
}
|
||||
|
||||
// ParseDfH parses `df -h` output. When mountHint is non-empty, prefers a row whose
|
||||
// Mounted on column matches that path; otherwise uses the first data row.
|
||||
func ParseDfH(stdout string, mountHint string) (settings.DiskWidgetData, error) {
|
||||
lines := strings.Split(stdout, "\n")
|
||||
var dataRows []settings.DiskWidgetData
|
||||
for _, line := range lines {
|
||||
trimmed := strings.TrimSpace(line)
|
||||
if trimmed == "" {
|
||||
continue
|
||||
}
|
||||
fields := whitespaceSplit.Split(trimmed, -1)
|
||||
if len(fields) < 6 {
|
||||
continue
|
||||
}
|
||||
if strings.EqualFold(fields[0], "Filesystem") {
|
||||
continue
|
||||
}
|
||||
// df wraps long filesystem names; prefer last 5 columns as Size Used Avail Use% Mounted.
|
||||
mountedOn := fields[len(fields)-1]
|
||||
usePercent := fields[len(fields)-2]
|
||||
avail := fields[len(fields)-3]
|
||||
used := fields[len(fields)-4]
|
||||
size := fields[len(fields)-5]
|
||||
filesystem := strings.Join(fields[:len(fields)-5], " ")
|
||||
if filesystem == "" {
|
||||
continue
|
||||
}
|
||||
row := settings.DiskWidgetData{
|
||||
Filesystem: filesystem,
|
||||
Size: size,
|
||||
Used: used,
|
||||
Avail: avail,
|
||||
UsePercent: usePercent,
|
||||
MountedOn: mountedOn,
|
||||
SizeMiB: parseSizeToMiB(size),
|
||||
UsedMiB: parseSizeToMiB(used),
|
||||
AvailMiB: parseSizeToMiB(avail),
|
||||
}
|
||||
dataRows = append(dataRows, row)
|
||||
}
|
||||
if len(dataRows) == 0 {
|
||||
return settings.DiskWidgetData{}, fmt.Errorf("no df data rows found")
|
||||
}
|
||||
|
||||
hint := strings.TrimSpace(mountHint)
|
||||
if hint != "" {
|
||||
for _, row := range dataRows {
|
||||
if row.MountedOn == hint {
|
||||
return row, nil
|
||||
}
|
||||
}
|
||||
// Also accept trailing-slash normalized match.
|
||||
hintTrim := strings.TrimSuffix(hint, "/")
|
||||
if hintTrim == "" {
|
||||
hintTrim = "/"
|
||||
}
|
||||
for _, row := range dataRows {
|
||||
mounted := strings.TrimSuffix(row.MountedOn, "/")
|
||||
if mounted == "" {
|
||||
mounted = "/"
|
||||
}
|
||||
if mounted == hintTrim {
|
||||
return row, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
return dataRows[0], nil
|
||||
}
|
||||
|
||||
func parseSizeToMiB(raw string) int64 {
|
||||
trimmed := strings.TrimSpace(raw)
|
||||
if trimmed == "" || trimmed == "-" {
|
||||
return 0
|
||||
}
|
||||
matches := sizeUnitPattern.FindStringSubmatch(trimmed)
|
||||
if matches == nil {
|
||||
return 0
|
||||
}
|
||||
value, err := strconv.ParseFloat(matches[1], 64)
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
unit := strings.ToUpper(matches[2])
|
||||
switch unit {
|
||||
case "", "B":
|
||||
return int64(value / (1024 * 1024))
|
||||
case "K", "KB", "KIB":
|
||||
return int64(value / 1024)
|
||||
case "M", "MB", "MIB":
|
||||
return int64(value)
|
||||
case "G", "GB", "GIB":
|
||||
return int64(value * 1024)
|
||||
case "T", "TB", "TIB":
|
||||
return int64(value * 1024 * 1024)
|
||||
case "P", "PB", "PIB":
|
||||
return int64(value * 1024 * 1024 * 1024)
|
||||
default:
|
||||
return 0
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
package widgetparse
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"codeberg.org/SquidSE/ClusterCanvas/service/internal/settings"
|
||||
)
|
||||
|
||||
func TestParseFreeM(t *testing.T) {
|
||||
stdout := ` total used free shared buff/cache available
|
||||
Mem: 16014 4521 2341 412 9151 11012
|
||||
Swap: 8191 0 8191
|
||||
`
|
||||
data, err := ParseFreeM(stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("ParseFreeM: %v", err)
|
||||
}
|
||||
if data.Total != 16014 || data.Used != 4521 || data.Free != 2341 {
|
||||
t.Fatalf("core fields = %+v", data)
|
||||
}
|
||||
if data.Shared != 412 || data.BuffCache != 9151 || data.Available != 11012 {
|
||||
t.Fatalf("extra fields = %+v", data)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseDfHPrefersMount(t *testing.T) {
|
||||
stdout := `Filesystem Size Used Avail Use% Mounted on
|
||||
/dev/sda1 100G 40G 55G 42% /
|
||||
/dev/sdb1 200G 10G 180G 6% /data
|
||||
`
|
||||
data, err := ParseDfH(stdout, "/data")
|
||||
if err != nil {
|
||||
t.Fatalf("ParseDfH: %v", err)
|
||||
}
|
||||
if data.MountedOn != "/data" || data.Size != "200G" || data.Used != "10G" {
|
||||
t.Fatalf("row = %+v", data)
|
||||
}
|
||||
if data.SizeMiB != 200*1024 || data.UsedMiB != 10*1024 {
|
||||
t.Fatalf("mib = %+v", data)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseDfHFirstRow(t *testing.T) {
|
||||
stdout := `Filesystem Size Used Avail Use% Mounted on
|
||||
tmpfs 1.6G 0 1.6G 0% /run
|
||||
/dev/sda1 100G 40G 55G 42% /
|
||||
`
|
||||
data, err := ParseDfH(stdout, "")
|
||||
if err != nil {
|
||||
t.Fatalf("ParseDfH: %v", err)
|
||||
}
|
||||
if data.MountedOn != "/run" {
|
||||
t.Fatalf("expected first row, got %+v", data)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseStdoutMemory(t *testing.T) {
|
||||
stdout := "Mem: 100 40 20 1 39 50\n"
|
||||
memory, disk, err := ParseStdout(settings.ActionWidgetTypeMemory, stdout, "")
|
||||
if err != nil {
|
||||
t.Fatalf("ParseStdout: %v", err)
|
||||
}
|
||||
if memory == nil || disk != nil || memory.Total != 100 {
|
||||
t.Fatalf("memory=%v disk=%v", memory, disk)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
package settings
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
// LoadActionWidgets reads and parses node-action-widgets.json from dir.
|
||||
func LoadActionWidgets(dir string) (ActionWidgetStore, error) {
|
||||
path := filepath.Join(dir, NodeActionWidgetsFileName)
|
||||
payload, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return ActionWidgetStore{}, err
|
||||
}
|
||||
|
||||
var store ActionWidgetStore
|
||||
if err := json.Unmarshal(payload, &store); err != nil {
|
||||
return ActionWidgetStore{}, fmt.Errorf("parse action widgets: %w", err)
|
||||
}
|
||||
if store.Widgets == nil {
|
||||
store.Widgets = []ActionWidgetSnapshot{}
|
||||
}
|
||||
return store, nil
|
||||
}
|
||||
|
||||
// LoadActionWidgetsOrEmpty returns an empty store when the file is missing.
|
||||
func LoadActionWidgetsOrEmpty(dir string) (ActionWidgetStore, error) {
|
||||
store, err := LoadActionWidgets(dir)
|
||||
if err != nil {
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return ActionWidgetStore{Widgets: []ActionWidgetSnapshot{}}, nil
|
||||
}
|
||||
return ActionWidgetStore{}, err
|
||||
}
|
||||
return store, nil
|
||||
}
|
||||
|
||||
// SaveActionWidgets writes node-action-widgets.json to dir with mode 0640.
|
||||
func SaveActionWidgets(dir string, store ActionWidgetStore) error {
|
||||
if err := ensureConfigDir(dir); err != nil {
|
||||
return err
|
||||
}
|
||||
if store.Widgets == nil {
|
||||
store.Widgets = []ActionWidgetSnapshot{}
|
||||
}
|
||||
|
||||
payload, err := json.MarshalIndent(store, "", " ")
|
||||
if err != nil {
|
||||
return fmt.Errorf("encode action widgets: %w", err)
|
||||
}
|
||||
payload = append(payload, '\n')
|
||||
|
||||
path := filepath.Join(dir, NodeActionWidgetsFileName)
|
||||
return os.WriteFile(path, payload, 0o640)
|
||||
}
|
||||
|
||||
// WidgetsForNode returns snapshots belonging to nodeID.
|
||||
func WidgetsForNode(store ActionWidgetStore, nodeID string) []ActionWidgetSnapshot {
|
||||
out := make([]ActionWidgetSnapshot, 0)
|
||||
for _, widget := range store.Widgets {
|
||||
if widget.NodeID == nodeID {
|
||||
out = append(out, widget)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// UpsertActionWidgetSnapshot replaces or appends a snapshot keyed by node_id + item_id.
|
||||
func UpsertActionWidgetSnapshot(dir string, snapshot ActionWidgetSnapshot) error {
|
||||
if strings.TrimSpace(snapshot.NodeID) == "" {
|
||||
return fmt.Errorf("node id is required")
|
||||
}
|
||||
if strings.TrimSpace(snapshot.ItemID) == "" {
|
||||
return fmt.Errorf("item id is required")
|
||||
}
|
||||
if snapshot.UpdatedAt.IsZero() {
|
||||
snapshot.UpdatedAt = time.Now().UTC()
|
||||
}
|
||||
|
||||
store, err := LoadActionWidgetsOrEmpty(dir)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
found := false
|
||||
for index := range store.Widgets {
|
||||
if store.Widgets[index].NodeID == snapshot.NodeID &&
|
||||
store.Widgets[index].ItemID == snapshot.ItemID {
|
||||
store.Widgets[index] = snapshot
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
store.Widgets = append(store.Widgets, snapshot)
|
||||
}
|
||||
return SaveActionWidgets(dir, store)
|
||||
}
|
||||
|
||||
// DeleteActionWidgetsForItem removes the snapshot for a node action item, if any.
|
||||
func DeleteActionWidgetsForItem(dir string, nodeID string, itemID string) error {
|
||||
store, err := LoadActionWidgetsOrEmpty(dir)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
filtered := make([]ActionWidgetSnapshot, 0, len(store.Widgets))
|
||||
changed := false
|
||||
for _, widget := range store.Widgets {
|
||||
if widget.NodeID == nodeID && widget.ItemID == itemID {
|
||||
changed = true
|
||||
continue
|
||||
}
|
||||
filtered = append(filtered, widget)
|
||||
}
|
||||
if !changed {
|
||||
return nil
|
||||
}
|
||||
store.Widgets = filtered
|
||||
return SaveActionWidgets(dir, store)
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
package settings
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestEnsureBuiltinActionsAddsMemoryAndUpdatesDisk(t *testing.T) {
|
||||
now := time.Now().UTC()
|
||||
store := ActionStore{
|
||||
Actions: []Action{
|
||||
{
|
||||
ID: BuiltinActionUptimeID,
|
||||
Name: "Uptime",
|
||||
Kind: ActionKindShell,
|
||||
Body: "uptime",
|
||||
Env: []ActionEnvVar{},
|
||||
Builtin: true,
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
},
|
||||
{
|
||||
ID: BuiltinActionDiskUsageID,
|
||||
Name: "Disk usage",
|
||||
Kind: ActionKindShell,
|
||||
Body: "df -h",
|
||||
Env: []ActionEnvVar{},
|
||||
Builtin: true,
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
},
|
||||
{
|
||||
ID: BuiltinActionSysInfoID,
|
||||
Name: "System info",
|
||||
Kind: ActionKindScript,
|
||||
Body: "uname -a",
|
||||
Env: []ActionEnvVar{},
|
||||
Builtin: true,
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
},
|
||||
{
|
||||
ID: "custom-1",
|
||||
Name: "Custom",
|
||||
Kind: ActionKindShell,
|
||||
Body: "echo hi",
|
||||
Env: []ActionEnvVar{},
|
||||
Builtin: false,
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
updated, changed := EnsureBuiltinActions(store)
|
||||
if !changed {
|
||||
t.Fatal("expected changes")
|
||||
}
|
||||
|
||||
foundMemory := false
|
||||
foundCustom := false
|
||||
for _, action := range updated.Actions {
|
||||
if action.ID == BuiltinActionMemoryID {
|
||||
foundMemory = true
|
||||
if !action.ShowWidget || action.WidgetType != ActionWidgetTypeMemory {
|
||||
t.Fatalf("memory widget = %+v", action)
|
||||
}
|
||||
if action.Body != "free -m" {
|
||||
t.Fatalf("memory body = %q", action.Body)
|
||||
}
|
||||
}
|
||||
if action.ID == BuiltinActionDiskUsageID {
|
||||
if action.Body != "df -h {{env.MOUNT}}" {
|
||||
t.Fatalf("disk body = %q", action.Body)
|
||||
}
|
||||
if len(action.Env) != 1 || action.Env[0].Name != "MOUNT" || action.Env[0].Value != "/" {
|
||||
t.Fatalf("disk env = %+v", action.Env)
|
||||
}
|
||||
if !action.ShowWidget || action.WidgetType != ActionWidgetTypeDisk {
|
||||
t.Fatalf("disk widget = %+v", action)
|
||||
}
|
||||
}
|
||||
if action.ID == "custom-1" {
|
||||
foundCustom = true
|
||||
}
|
||||
}
|
||||
if !foundMemory {
|
||||
t.Fatal("memory builtin missing")
|
||||
}
|
||||
if !foundCustom {
|
||||
t.Fatal("custom action was dropped")
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpsertActionWidgetSnapshot(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
first := ActionWidgetSnapshot{
|
||||
NodeID: "node-1",
|
||||
ItemID: "item-1",
|
||||
GroupID: "group-1",
|
||||
WidgetType: ActionWidgetTypeMemory,
|
||||
Title: "Memory",
|
||||
UpdatedAt: time.Now().UTC(),
|
||||
ExitCode: 0,
|
||||
RawStdout: "Mem: 1 1 0 0 0 0",
|
||||
Memory: &MemoryWidgetData{Total: 1, Used: 1},
|
||||
}
|
||||
if err := UpsertActionWidgetSnapshot(dir, first); err != nil {
|
||||
t.Fatalf("upsert: %v", err)
|
||||
}
|
||||
second := first
|
||||
second.Title = "RAM"
|
||||
second.Memory = &MemoryWidgetData{Total: 2, Used: 1, Free: 1}
|
||||
if err := UpsertActionWidgetSnapshot(dir, second); err != nil {
|
||||
t.Fatalf("upsert2: %v", err)
|
||||
}
|
||||
|
||||
store, err := LoadActionWidgets(dir)
|
||||
if err != nil {
|
||||
t.Fatalf("load: %v", err)
|
||||
}
|
||||
if len(store.Widgets) != 1 {
|
||||
t.Fatalf("len = %d", len(store.Widgets))
|
||||
}
|
||||
if store.Widgets[0].Title != "RAM" || store.Widgets[0].Memory.Total != 2 {
|
||||
t.Fatalf("widget = %+v", store.Widgets[0])
|
||||
}
|
||||
|
||||
path := filepath.Join(dir, NodeActionWidgetsFileName)
|
||||
if _, err := os.Stat(path); err != nil {
|
||||
t.Fatalf("missing file: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -14,6 +14,7 @@ const (
|
||||
BuiltinActionUptimeID = "00000000-0000-4000-8000-000000000001"
|
||||
BuiltinActionDiskUsageID = "00000000-0000-4000-8000-000000000002"
|
||||
BuiltinActionSysInfoID = "00000000-0000-4000-8000-000000000003"
|
||||
BuiltinActionMemoryID = "00000000-0000-4000-8000-000000000004"
|
||||
)
|
||||
|
||||
// DefaultBuiltinActions returns the read-only seed actions.
|
||||
@@ -27,6 +28,8 @@ func DefaultBuiltinActions() []Action {
|
||||
Kind: ActionKindShell,
|
||||
Body: "uptime",
|
||||
Env: []ActionEnvVar{},
|
||||
ShowWidget: true,
|
||||
WidgetType: ActionWidgetTypeRaw,
|
||||
Builtin: true,
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
@@ -36,8 +39,12 @@ func DefaultBuiltinActions() []Action {
|
||||
Name: "Disk usage",
|
||||
Description: "Show filesystem disk space usage on the target.",
|
||||
Kind: ActionKindShell,
|
||||
Body: "df -h",
|
||||
Env: []ActionEnvVar{},
|
||||
Body: "df -h {{env.MOUNT}}",
|
||||
Env: []ActionEnvVar{
|
||||
{Name: "MOUNT", Value: "/"},
|
||||
},
|
||||
ShowWidget: true,
|
||||
WidgetType: ActionWidgetTypeDisk,
|
||||
Builtin: true,
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
@@ -53,6 +60,21 @@ if [ -f /etc/os-release ]; then
|
||||
echo "OS: ${NAME:-unknown} ${VERSION:-}"
|
||||
fi`,
|
||||
Env: []ActionEnvVar{},
|
||||
ShowWidget: true,
|
||||
WidgetType: ActionWidgetTypeRaw,
|
||||
Builtin: true,
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
},
|
||||
{
|
||||
ID: BuiltinActionMemoryID,
|
||||
Name: "Memory",
|
||||
Description: "Show memory usage on the target (free -m).",
|
||||
Kind: ActionKindShell,
|
||||
Body: "free -m",
|
||||
Env: []ActionEnvVar{},
|
||||
ShowWidget: true,
|
||||
WidgetType: ActionWidgetTypeMemory,
|
||||
Builtin: true,
|
||||
CreatedAt: now,
|
||||
UpdatedAt: now,
|
||||
@@ -84,6 +106,7 @@ func LoadActions(dir string) (ActionStore, error) {
|
||||
}
|
||||
|
||||
// LoadActionsOrSeed returns seeded builtins (and persists them) when actions.json is missing.
|
||||
// Existing installs are updated via EnsureBuiltinActions.
|
||||
func LoadActionsOrSeed(dir string) (ActionStore, error) {
|
||||
store, err := LoadActions(dir)
|
||||
if err != nil {
|
||||
@@ -96,9 +119,102 @@ func LoadActionsOrSeed(dir string) (ActionStore, error) {
|
||||
}
|
||||
return ActionStore{}, err
|
||||
}
|
||||
updated, changed := EnsureBuiltinActions(store)
|
||||
if changed {
|
||||
if saveErr := SaveActions(dir, updated); saveErr != nil {
|
||||
return ActionStore{}, saveErr
|
||||
}
|
||||
return updated, nil
|
||||
}
|
||||
return store, nil
|
||||
}
|
||||
|
||||
// EnsureBuiltinActions merges expected builtin definitions into the store.
|
||||
// Custom (non-builtin) actions are preserved. Builtin widget settings already
|
||||
// customized by the user are kept when the action already exists with matching ID;
|
||||
// body/env/name for known builtins are refreshed to the current seed values.
|
||||
// Returns the updated store and whether anything changed.
|
||||
func EnsureBuiltinActions(store ActionStore) (ActionStore, bool) {
|
||||
if store.Actions == nil {
|
||||
store.Actions = []Action{}
|
||||
}
|
||||
defaults := DefaultBuiltinActions()
|
||||
byID := make(map[string]int, len(store.Actions))
|
||||
for index := range store.Actions {
|
||||
byID[store.Actions[index].ID] = index
|
||||
}
|
||||
|
||||
changed := false
|
||||
now := time.Now().UTC()
|
||||
for _, desired := range defaults {
|
||||
index, exists := byID[desired.ID]
|
||||
if !exists {
|
||||
desired.CreatedAt = now
|
||||
desired.UpdatedAt = now
|
||||
store.Actions = append(store.Actions, desired)
|
||||
byID[desired.ID] = len(store.Actions) - 1
|
||||
changed = true
|
||||
continue
|
||||
}
|
||||
current := store.Actions[index]
|
||||
// Preserve user-chosen widget toggle/type on existing builtins.
|
||||
showWidget := current.ShowWidget
|
||||
widgetType := current.WidgetType
|
||||
if !current.Builtin {
|
||||
// ID collision with a custom action: leave it alone.
|
||||
continue
|
||||
}
|
||||
needsUpdate := current.Name != desired.Name ||
|
||||
current.Description != desired.Description ||
|
||||
current.Kind != desired.Kind ||
|
||||
current.Body != desired.Body ||
|
||||
!envVarsEqual(current.Env, desired.Env) ||
|
||||
current.RequiresSudo != desired.RequiresSudo
|
||||
if !needsUpdate {
|
||||
// Still ensure widget defaults if never set and seed wants widgets.
|
||||
if !current.ShowWidget && desired.ShowWidget && current.WidgetType == "" {
|
||||
current.ShowWidget = desired.ShowWidget
|
||||
current.WidgetType = desired.WidgetType
|
||||
current.UpdatedAt = now
|
||||
store.Actions[index] = current
|
||||
changed = true
|
||||
}
|
||||
continue
|
||||
}
|
||||
current.Name = desired.Name
|
||||
current.Description = desired.Description
|
||||
current.Kind = desired.Kind
|
||||
current.Body = desired.Body
|
||||
current.Env = append([]ActionEnvVar(nil), desired.Env...)
|
||||
current.RequiresSudo = desired.RequiresSudo
|
||||
current.Builtin = true
|
||||
// Keep existing widget prefs; only fill defaults when unset.
|
||||
if current.WidgetType == "" && desired.WidgetType != "" {
|
||||
current.ShowWidget = desired.ShowWidget
|
||||
current.WidgetType = desired.WidgetType
|
||||
} else {
|
||||
current.ShowWidget = showWidget
|
||||
current.WidgetType = widgetType
|
||||
}
|
||||
current.UpdatedAt = now
|
||||
store.Actions[index] = current
|
||||
changed = true
|
||||
}
|
||||
return store, changed
|
||||
}
|
||||
|
||||
func envVarsEqual(left, right []ActionEnvVar) bool {
|
||||
if len(left) != len(right) {
|
||||
return false
|
||||
}
|
||||
for index := range left {
|
||||
if left[index].Name != right[index].Name || left[index].Value != right[index].Value {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// SaveActions writes actions.json to dir with mode 0640.
|
||||
func SaveActions(dir string, store ActionStore) error {
|
||||
if err := ensureConfigDir(dir); err != nil {
|
||||
@@ -120,8 +236,5 @@ func SaveActions(dir string, store ActionStore) error {
|
||||
payload = append(payload, '\n')
|
||||
|
||||
path := filepath.Join(dir, ActionsFileName)
|
||||
if err := os.WriteFile(path, payload, 0o640); err != nil {
|
||||
return fmt.Errorf("write actions: %w", err)
|
||||
}
|
||||
return nil
|
||||
return os.WriteFile(path, payload, 0o640)
|
||||
}
|
||||
|
||||
@@ -13,8 +13,8 @@ func TestLoadActionsOrSeedCreatesBuiltins(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("LoadActionsOrSeed: %v", err)
|
||||
}
|
||||
if len(store.Actions) != 3 {
|
||||
t.Fatalf("expected 3 builtins, got %d", len(store.Actions))
|
||||
if len(store.Actions) != 4 {
|
||||
t.Fatalf("expected 4 builtins, got %d", len(store.Actions))
|
||||
}
|
||||
for _, action := range store.Actions {
|
||||
if !action.Builtin {
|
||||
@@ -31,7 +31,7 @@ func TestLoadActionsOrSeedCreatesBuiltins(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("LoadActions: %v", err)
|
||||
}
|
||||
if len(reloaded.Actions) != 3 {
|
||||
if len(reloaded.Actions) != 4 {
|
||||
t.Fatalf("reloaded len = %d", len(reloaded.Actions))
|
||||
}
|
||||
}
|
||||
@@ -58,10 +58,10 @@ func TestActionsRoundTrip(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("LoadActions: %v", err)
|
||||
}
|
||||
if len(loaded.Actions) != 4 {
|
||||
if len(loaded.Actions) != 5 {
|
||||
t.Fatalf("loaded len = %d", len(loaded.Actions))
|
||||
}
|
||||
custom := loaded.Actions[3]
|
||||
custom := loaded.Actions[4]
|
||||
if custom.Name != "Custom" || len(custom.Env) != 1 || custom.Env[0].Name != "APP_HOME" {
|
||||
t.Fatalf("custom = %#v", custom)
|
||||
}
|
||||
|
||||
@@ -128,6 +128,23 @@ func GroupsForNode(store NodeActionGroupStore, nodeID string) []NodeActionGroup
|
||||
return result
|
||||
}
|
||||
|
||||
// LibraryActionInUse reports whether any group item still references actionID as a library source.
|
||||
// Local clones (source local) do not count even if they previously came from that action.
|
||||
func LibraryActionInUse(store NodeActionGroupStore, actionID string) bool {
|
||||
actionID = strings.TrimSpace(actionID)
|
||||
if actionID == "" {
|
||||
return false
|
||||
}
|
||||
for _, group := range store.Groups {
|
||||
for _, item := range group.Items {
|
||||
if item.Source == ActionItemSourceLibrary && item.LibraryActionID == actionID {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// UpdateGroupLastRunAt sets LastRunAt and UpdatedAt for a group and saves.
|
||||
func UpdateGroupLastRunAt(dir string, groupID string, at time.Time) error {
|
||||
store, err := LoadNodeActionGroupsOrEmpty(dir)
|
||||
|
||||
@@ -66,6 +66,48 @@ func TestNodeActionGroupsRoundTrip(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestLibraryActionInUse(t *testing.T) {
|
||||
store := NodeActionGroupStore{
|
||||
Groups: []NodeActionGroup{
|
||||
{
|
||||
ID: "g1",
|
||||
NodeID: "n1",
|
||||
Name: "Nightly",
|
||||
Items: []NodeActionItem{
|
||||
{
|
||||
ID: "lib-item",
|
||||
Source: ActionItemSourceLibrary,
|
||||
LibraryActionID: "custom-1",
|
||||
},
|
||||
{
|
||||
ID: "local-item",
|
||||
Source: ActionItemSourceLocal,
|
||||
Name: "Echo",
|
||||
Kind: ActionKindShell,
|
||||
Body: "echo hi",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
if !LibraryActionInUse(store, "custom-1") {
|
||||
t.Fatal("expected library action custom-1 to be in use")
|
||||
}
|
||||
if LibraryActionInUse(store, "other") {
|
||||
t.Fatal("expected unrelated action not in use")
|
||||
}
|
||||
store.Groups[0].Items[0] = NodeActionItem{
|
||||
ID: "cloned",
|
||||
Source: ActionItemSourceLocal,
|
||||
Name: "Echo host",
|
||||
Kind: ActionKindShell,
|
||||
Body: "echo",
|
||||
}
|
||||
if LibraryActionInUse(store, "custom-1") {
|
||||
t.Fatal("expected cloned local item not to count as in use")
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsScheduleDueInterval(t *testing.T) {
|
||||
now := time.Date(2026, 7, 19, 12, 0, 0, 0, time.Local)
|
||||
group := NodeActionGroup{
|
||||
|
||||
@@ -22,6 +22,7 @@ const (
|
||||
AuthAuditFileName = "auth-audit.json"
|
||||
ActionsFileName = "actions.json"
|
||||
NodeActionGroupsFileName = "node-action-groups.json"
|
||||
NodeActionWidgetsFileName = "node-action-widgets.json"
|
||||
ActionLogsDirName = "action-logs"
|
||||
)
|
||||
|
||||
|
||||
@@ -128,6 +128,16 @@ const (
|
||||
ActionKindScript ActionKind = "script"
|
||||
)
|
||||
|
||||
// ActionWidgetType identifies how action stdout is parsed for Overview widgets.
|
||||
type ActionWidgetType string
|
||||
|
||||
const (
|
||||
ActionWidgetTypeNone ActionWidgetType = ""
|
||||
ActionWidgetTypeMemory ActionWidgetType = "memory"
|
||||
ActionWidgetTypeDisk ActionWidgetType = "disk"
|
||||
ActionWidgetTypeRaw ActionWidgetType = "raw"
|
||||
)
|
||||
|
||||
// ActionEnvVar is one environment variable applied before an action runs.
|
||||
type ActionEnvVar struct {
|
||||
Name string `json:"name"`
|
||||
@@ -143,6 +153,8 @@ type Action struct {
|
||||
Body string `json:"body"`
|
||||
Env []ActionEnvVar `json:"env"`
|
||||
RequiresSudo bool `json:"requires_sudo"`
|
||||
ShowWidget bool `json:"show_widget"`
|
||||
WidgetType ActionWidgetType `json:"widget_type,omitempty"`
|
||||
Builtin bool `json:"builtin"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
@@ -196,6 +208,7 @@ type NodeActionItem struct {
|
||||
RequiresSudo bool `json:"requires_sudo,omitempty"` // local items; library refs inherit from library
|
||||
SetVariable string `json:"set_variable,omitempty"` // capture trimmed stdout into this run-scoped name
|
||||
RunIf string `json:"run_if,omitempty"` // e.g. "if $UpdateCount >= 1"
|
||||
WidgetLabel string `json:"widget_label,omitempty"` // Overview widget title override
|
||||
}
|
||||
|
||||
// NodeActionGroup is a named ordered set of actions with one schedule on a node.
|
||||
@@ -273,6 +286,51 @@ type ActionLogFileInfo struct {
|
||||
SizeBytes int64 `json:"size_bytes"`
|
||||
}
|
||||
|
||||
// MemoryWidgetData holds parsed free -m memory figures (MiB).
|
||||
type MemoryWidgetData struct {
|
||||
Total int64 `json:"total"`
|
||||
Used int64 `json:"used"`
|
||||
Free int64 `json:"free"`
|
||||
Shared int64 `json:"shared"`
|
||||
BuffCache int64 `json:"buff_cache"`
|
||||
Available int64 `json:"available"`
|
||||
}
|
||||
|
||||
// DiskWidgetData holds parsed df -h figures for one filesystem row.
|
||||
type DiskWidgetData struct {
|
||||
Filesystem string `json:"filesystem"`
|
||||
Size string `json:"size"`
|
||||
Used string `json:"used"`
|
||||
Avail string `json:"avail"`
|
||||
UsePercent string `json:"use_percent"`
|
||||
MountedOn string `json:"mounted_on"`
|
||||
// Numeric MiB estimates for chart proportions (0 when unknown).
|
||||
SizeMiB int64 `json:"size_mib,omitempty"`
|
||||
UsedMiB int64 `json:"used_mib,omitempty"`
|
||||
AvailMiB int64 `json:"avail_mib,omitempty"`
|
||||
}
|
||||
|
||||
// ActionWidgetSnapshot is the last widget payload for one action-group item on a node.
|
||||
type ActionWidgetSnapshot struct {
|
||||
NodeID string `json:"node_id"`
|
||||
ItemID string `json:"item_id"`
|
||||
GroupID string `json:"group_id"`
|
||||
LibraryActionID string `json:"library_action_id"`
|
||||
WidgetType ActionWidgetType `json:"widget_type"`
|
||||
Title string `json:"title"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
ExitCode int `json:"exit_code"`
|
||||
Error string `json:"error,omitempty"`
|
||||
RawStdout string `json:"raw_stdout"`
|
||||
Memory *MemoryWidgetData `json:"memory,omitempty"`
|
||||
Disk *DiskWidgetData `json:"disk,omitempty"`
|
||||
}
|
||||
|
||||
// ActionWidgetStore is the plain JSON payload in node-action-widgets.json.
|
||||
type ActionWidgetStore struct {
|
||||
Widgets []ActionWidgetSnapshot `json:"widgets"`
|
||||
}
|
||||
|
||||
// NodeKeyEntry holds private key material for one node, keyed by node UUID.
|
||||
type NodeKeyEntry struct {
|
||||
NodeID string `json:"node_id"`
|
||||
|
||||
@@ -0,0 +1,341 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import {
|
||||
type ActionWidgetSnapshot,
|
||||
type DiskWidgetData,
|
||||
type MemoryWidgetData,
|
||||
fetchNodeActionWidgets,
|
||||
} from './api/client'
|
||||
|
||||
type RingSegment = {
|
||||
value: number
|
||||
color: string
|
||||
label: string
|
||||
}
|
||||
|
||||
function formatMiB(value: number): string {
|
||||
if (value >= 1024) {
|
||||
return `${(value / 1024).toFixed(1)} GiB`
|
||||
}
|
||||
return `${value} MiB`
|
||||
}
|
||||
|
||||
function RingChart({
|
||||
segments,
|
||||
size = 120,
|
||||
strokeWidth = 16,
|
||||
}: {
|
||||
segments: ReadonlyArray<RingSegment>
|
||||
size?: number
|
||||
strokeWidth?: number
|
||||
}) {
|
||||
const total = segments.reduce((sum, segment) => sum + Math.max(0, segment.value), 0)
|
||||
const radius = (size - strokeWidth) / 2
|
||||
const circumference = 2 * Math.PI * radius
|
||||
let offset = 0
|
||||
|
||||
if (total <= 0) {
|
||||
return (
|
||||
<svg
|
||||
className="action-widget-ring"
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox={`0 0 ${size} ${size}`}
|
||||
aria-hidden="true"
|
||||
>
|
||||
<circle
|
||||
cx={size / 2}
|
||||
cy={size / 2}
|
||||
r={radius}
|
||||
fill="none"
|
||||
stroke="var(--ctp-surface1)"
|
||||
strokeWidth={strokeWidth}
|
||||
/>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<svg
|
||||
className="action-widget-ring"
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox={`0 0 ${size} ${size}`}
|
||||
role="img"
|
||||
aria-label={segments.map((segment) => `${segment.label} ${segment.value}`).join(', ')}
|
||||
>
|
||||
<g transform={`rotate(-90 ${size / 2} ${size / 2})`}>
|
||||
{segments.map((segment) => {
|
||||
const value = Math.max(0, segment.value)
|
||||
const length = (value / total) * circumference
|
||||
const dashOffset = -offset
|
||||
offset += length
|
||||
return (
|
||||
<circle
|
||||
key={segment.label}
|
||||
cx={size / 2}
|
||||
cy={size / 2}
|
||||
r={radius}
|
||||
fill="none"
|
||||
stroke={segment.color}
|
||||
strokeWidth={strokeWidth}
|
||||
strokeDasharray={`${length} ${circumference - length}`}
|
||||
strokeDashoffset={dashOffset}
|
||||
strokeLinecap="butt"
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</g>
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
function MemoryRingWidget({
|
||||
title,
|
||||
data,
|
||||
updatedAt,
|
||||
error,
|
||||
}: {
|
||||
title: string
|
||||
data?: MemoryWidgetData
|
||||
updatedAt?: string
|
||||
error?: string
|
||||
}) {
|
||||
const used = data?.used ?? 0
|
||||
const free = data?.free ?? 0
|
||||
const buffCache = data?.buff_cache ?? 0
|
||||
const total = data?.total ?? used + free + buffCache
|
||||
|
||||
return (
|
||||
<article className="action-widget action-widget-memory">
|
||||
<header className="action-widget-header">
|
||||
<h3>{title}</h3>
|
||||
{updatedAt ? (
|
||||
<time dateTime={updatedAt}>{new Date(updatedAt).toLocaleString()}</time>
|
||||
) : null}
|
||||
</header>
|
||||
{error ? <p className="action-widget-error">{error}</p> : null}
|
||||
{!data && !error ? (
|
||||
<p className="config-hint">Run this action to populate the widget.</p>
|
||||
) : null}
|
||||
{data ? (
|
||||
<div className="action-widget-body">
|
||||
<RingChart
|
||||
segments={[
|
||||
{ value: used, color: 'var(--ctp-red)', label: 'Used' },
|
||||
{ value: free, color: 'var(--ctp-green)', label: 'Free' },
|
||||
{
|
||||
value: buffCache,
|
||||
color: 'var(--ctp-yellow)',
|
||||
label: 'Buff/cache',
|
||||
},
|
||||
]}
|
||||
/>
|
||||
<div className="action-widget-stats">
|
||||
<p className="action-widget-total">Total {formatMiB(total)}</p>
|
||||
<ul className="action-widget-legend">
|
||||
<li>
|
||||
<span className="action-widget-swatch action-widget-swatch-used" />
|
||||
Used {formatMiB(used)}
|
||||
</li>
|
||||
<li>
|
||||
<span className="action-widget-swatch action-widget-swatch-free" />
|
||||
Free {formatMiB(free)}
|
||||
</li>
|
||||
<li>
|
||||
<span className="action-widget-swatch action-widget-swatch-cache" />
|
||||
Buff/cache {formatMiB(buffCache)}
|
||||
</li>
|
||||
<li>Shared {formatMiB(data.shared)}</li>
|
||||
<li>Available {formatMiB(data.available)}</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</article>
|
||||
)
|
||||
}
|
||||
|
||||
function DiskRingWidget({
|
||||
title,
|
||||
data,
|
||||
updatedAt,
|
||||
error,
|
||||
}: {
|
||||
title: string
|
||||
data?: DiskWidgetData
|
||||
updatedAt?: string
|
||||
error?: string
|
||||
}) {
|
||||
const usedMiB = data?.used_mib ?? 0
|
||||
const availMiB = data?.avail_mib ?? 0
|
||||
const sizeLabel = data?.size || '—'
|
||||
const mount = data?.mounted_on || ''
|
||||
|
||||
return (
|
||||
<article className="action-widget action-widget-disk">
|
||||
<header className="action-widget-header">
|
||||
<h3>{title}</h3>
|
||||
{updatedAt ? (
|
||||
<time dateTime={updatedAt}>{new Date(updatedAt).toLocaleString()}</time>
|
||||
) : null}
|
||||
</header>
|
||||
{error ? <p className="action-widget-error">{error}</p> : null}
|
||||
{!data && !error ? (
|
||||
<p className="config-hint">Run this action to populate the widget.</p>
|
||||
) : null}
|
||||
{data ? (
|
||||
<div className="action-widget-body">
|
||||
<RingChart
|
||||
segments={[
|
||||
{ value: usedMiB || 1, color: 'var(--ctp-yellow)', label: 'Used' },
|
||||
{
|
||||
value: availMiB || 1,
|
||||
color: 'var(--ctp-blue)',
|
||||
label: 'Available',
|
||||
},
|
||||
]}
|
||||
/>
|
||||
<div className="action-widget-stats">
|
||||
<p className="action-widget-total">
|
||||
Size {sizeLabel}
|
||||
{mount ? ` · ${mount}` : ''}
|
||||
</p>
|
||||
<ul className="action-widget-legend">
|
||||
<li>
|
||||
<span className="action-widget-swatch action-widget-swatch-disk-used" />
|
||||
Used {data.used} ({data.use_percent})
|
||||
</li>
|
||||
<li>
|
||||
<span className="action-widget-swatch action-widget-swatch-disk-avail" />
|
||||
Avail {data.avail}
|
||||
</li>
|
||||
<li className="action-widget-fs">{data.filesystem}</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</article>
|
||||
)
|
||||
}
|
||||
|
||||
function RawWidget({
|
||||
title,
|
||||
stdout,
|
||||
updatedAt,
|
||||
error,
|
||||
}: {
|
||||
title: string
|
||||
stdout: string
|
||||
updatedAt?: string
|
||||
error?: string
|
||||
}) {
|
||||
return (
|
||||
<article className="action-widget action-widget-raw">
|
||||
<header className="action-widget-header">
|
||||
<h3>{title}</h3>
|
||||
{updatedAt ? (
|
||||
<time dateTime={updatedAt}>{new Date(updatedAt).toLocaleString()}</time>
|
||||
) : null}
|
||||
</header>
|
||||
{error ? <p className="action-widget-error">{error}</p> : null}
|
||||
{!stdout && !error ? (
|
||||
<p className="config-hint">Run this action to populate the widget.</p>
|
||||
) : null}
|
||||
{stdout ? <pre className="action-widget-pre">{stdout}</pre> : null}
|
||||
</article>
|
||||
)
|
||||
}
|
||||
|
||||
function ActionWidgetCard({ widget }: { widget: ActionWidgetSnapshot }) {
|
||||
if (widget.widget_type === 'memory') {
|
||||
return (
|
||||
<MemoryRingWidget
|
||||
title={widget.title || 'Memory'}
|
||||
data={widget.memory}
|
||||
updatedAt={widget.updated_at}
|
||||
error={widget.error}
|
||||
/>
|
||||
)
|
||||
}
|
||||
if (widget.widget_type === 'disk') {
|
||||
return (
|
||||
<DiskRingWidget
|
||||
title={widget.title || 'Disk'}
|
||||
data={widget.disk}
|
||||
updatedAt={widget.updated_at}
|
||||
error={widget.error}
|
||||
/>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<RawWidget
|
||||
title={widget.title || 'Output'}
|
||||
stdout={widget.raw_stdout || ''}
|
||||
updatedAt={widget.updated_at}
|
||||
error={widget.error}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
export function NodeActionWidgetsPanel({ nodeId }: { nodeId: string }) {
|
||||
const [widgets, setWidgets] = useState<ActionWidgetSnapshot[]>([])
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
const [loadError, setLoadError] = useState<string | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
let isCancelled = false
|
||||
|
||||
async function loadWidgets() {
|
||||
setIsLoading(true)
|
||||
setLoadError(null)
|
||||
try {
|
||||
const response = await fetchNodeActionWidgets(nodeId)
|
||||
if (!isCancelled) {
|
||||
setWidgets([...response.widgets])
|
||||
}
|
||||
} catch (err) {
|
||||
if (!isCancelled) {
|
||||
setLoadError(
|
||||
err instanceof Error ? err.message : 'Failed to load widgets',
|
||||
)
|
||||
}
|
||||
} finally {
|
||||
if (!isCancelled) {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void loadWidgets()
|
||||
return () => {
|
||||
isCancelled = true
|
||||
}
|
||||
}, [nodeId])
|
||||
|
||||
if (isLoading) {
|
||||
return <p className="config-hint">Loading widgets…</p>
|
||||
}
|
||||
if (loadError) {
|
||||
return (
|
||||
<p className="config-empty groups-error" role="alert">
|
||||
{loadError}
|
||||
</p>
|
||||
)
|
||||
}
|
||||
if (widgets.length === 0) {
|
||||
return (
|
||||
<p className="config-hint">
|
||||
No action widgets yet. Enable Show Widget on a library action, add it to
|
||||
an action group, and run it.
|
||||
</p>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="action-widgets-grid">
|
||||
{widgets.map((widget) => (
|
||||
<ActionWidgetCard key={`${widget.node_id}-${widget.item_id}`} widget={widget} />
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
+249
-1
@@ -156,6 +156,10 @@
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.nav-count-badge + .nav-count-badge {
|
||||
margin-left: 0;
|
||||
}
|
||||
|
||||
.nav-count-badge-alert {
|
||||
border-color: color-mix(in srgb, var(--ctp-red) 45%, var(--ctp-surface1));
|
||||
background: color-mix(in srgb, var(--ctp-red) 14%, transparent);
|
||||
@@ -1488,7 +1492,29 @@
|
||||
}
|
||||
|
||||
.node-detail-back {
|
||||
margin-bottom: 0.75rem;
|
||||
appearance: none;
|
||||
align-self: flex-start;
|
||||
width: fit-content;
|
||||
margin: 0;
|
||||
padding: 0.15rem 0;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--ctp-subtext1);
|
||||
font: inherit;
|
||||
font-size: 0.9rem;
|
||||
font-weight: 500;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.node-detail-back:hover {
|
||||
color: var(--ctp-text);
|
||||
}
|
||||
|
||||
.node-detail-back:focus-visible {
|
||||
outline: 2px solid var(--ctp-mauve);
|
||||
outline-offset: 2px;
|
||||
border-radius: 0.2rem;
|
||||
}
|
||||
|
||||
.node-detail-header {
|
||||
@@ -1901,3 +1927,225 @@ button.save-dirty {
|
||||
color: var(--ctp-green);
|
||||
border-color: var(--ctp-green);
|
||||
}
|
||||
|
||||
.node-detail-overview-wrap {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
.node-action-widgets-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.node-action-widgets-heading {
|
||||
margin: 0;
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.action-widgets-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(16rem, 1fr));
|
||||
gap: 0.85rem;
|
||||
}
|
||||
|
||||
.action-widget {
|
||||
border: 1px solid var(--ctp-surface1);
|
||||
border-radius: 0.45rem;
|
||||
padding: 0.85rem 1rem;
|
||||
background: color-mix(in srgb, var(--ctp-mantle) 55%, transparent);
|
||||
}
|
||||
|
||||
.action-widget-header {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
gap: 0.35rem 0.75rem;
|
||||
margin-bottom: 0.65rem;
|
||||
}
|
||||
|
||||
.action-widget-header h3 {
|
||||
margin: 0;
|
||||
font-size: 0.95rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.action-widget-header time {
|
||||
font-size: 0.75rem;
|
||||
color: var(--ctp-subtext0);
|
||||
}
|
||||
|
||||
.action-widget-body {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.85rem;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.action-widget-ring {
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.action-widget-stats {
|
||||
flex: 1 1 8rem;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.action-widget-total {
|
||||
margin: 0 0 0.4rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.action-widget-legend {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
display: grid;
|
||||
gap: 0.25rem;
|
||||
font-size: 0.85rem;
|
||||
color: var(--ctp-subtext1);
|
||||
}
|
||||
|
||||
.action-widget-legend li {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
}
|
||||
|
||||
.action-widget-swatch {
|
||||
width: 0.65rem;
|
||||
height: 0.65rem;
|
||||
border-radius: 0.15rem;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
.action-widget-swatch-used {
|
||||
background: var(--ctp-red);
|
||||
}
|
||||
|
||||
.action-widget-swatch-free {
|
||||
background: var(--ctp-green);
|
||||
}
|
||||
|
||||
.action-widget-swatch-cache {
|
||||
background: var(--ctp-yellow);
|
||||
}
|
||||
|
||||
.action-widget-swatch-disk-used {
|
||||
background: var(--ctp-yellow);
|
||||
}
|
||||
|
||||
.action-widget-swatch-disk-avail {
|
||||
background: var(--ctp-blue);
|
||||
}
|
||||
|
||||
.action-widget-fs {
|
||||
word-break: break-all;
|
||||
color: var(--ctp-subtext0);
|
||||
}
|
||||
|
||||
.action-widget-error {
|
||||
margin: 0 0 0.5rem;
|
||||
color: var(--ctp-red);
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.action-widget-pre {
|
||||
margin: 0;
|
||||
padding: 0.55rem 0.65rem;
|
||||
max-height: 12rem;
|
||||
overflow: auto;
|
||||
border-radius: 0.35rem;
|
||||
background: color-mix(in srgb, var(--ctp-crust) 70%, transparent);
|
||||
font-size: 0.8rem;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.overview-panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
.overview-kind-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.65rem;
|
||||
}
|
||||
|
||||
.overview-kind-heading {
|
||||
margin: 0;
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
color: var(--ctp-subtext1);
|
||||
}
|
||||
|
||||
.overview-node-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(13.75rem, 1fr));
|
||||
gap: 0.65rem;
|
||||
}
|
||||
|
||||
.overview-node-card {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 0.35rem;
|
||||
margin: 0;
|
||||
padding: 0.65rem 0.75rem;
|
||||
border: 1px solid var(--ctp-surface1);
|
||||
border-radius: 0.45rem;
|
||||
background: color-mix(in srgb, var(--ctp-surface0) 55%, transparent);
|
||||
color: inherit;
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
min-width: 0;
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
.overview-node-card:hover {
|
||||
border-color: var(--ctp-overlay0);
|
||||
background: color-mix(in srgb, var(--ctp-surface0) 75%, transparent);
|
||||
}
|
||||
|
||||
.overview-node-card:focus-visible {
|
||||
outline: 2px solid var(--ctp-blue);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.overview-node-card-heading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
min-width: 0;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.overview-node-card-title {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-size: 0.95rem;
|
||||
}
|
||||
|
||||
.overview-node-metric {
|
||||
margin: 0;
|
||||
font-size: 0.78rem;
|
||||
color: var(--ctp-subtext0);
|
||||
line-height: 1.35;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.overview-node-metric-label {
|
||||
display: inline-block;
|
||||
min-width: 2.4rem;
|
||||
margin-right: 0.35rem;
|
||||
color: var(--ctp-overlay1);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
|
||||
+138
-1
@@ -276,7 +276,8 @@ describe('App', () => {
|
||||
expect(
|
||||
screen.getByRole('heading', { name: 'Overview', level: 2 }),
|
||||
).toBeInTheDocument()
|
||||
expect(screen.getByText('Coming soon')).toBeInTheDocument()
|
||||
expect(await screen.findByText('No nodes yet.')).toBeInTheDocument()
|
||||
expect(screen.queryByText('Coming soon')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows node count badges on resource nav items', async () => {
|
||||
@@ -373,6 +374,142 @@ describe('App', () => {
|
||||
).toBeNull()
|
||||
})
|
||||
|
||||
it('clears the red health badge when all nodes become healthy', async () => {
|
||||
const user = userEvent.setup()
|
||||
let containerHealthOk: boolean | null = false
|
||||
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn().mockImplementation((input: RequestInfo | URL, init?: RequestInit) => {
|
||||
const url = typeof input === 'string' ? input : input.toString()
|
||||
const method = init?.method ?? 'GET'
|
||||
|
||||
if (url.includes('/api/v1/setup/status')) {
|
||||
return Promise.resolve({
|
||||
ok: true,
|
||||
json: async () => ({ completed: true, client_ip: '127.0.0.1' }),
|
||||
})
|
||||
}
|
||||
|
||||
if (url.includes('/api/v1/auth/me')) {
|
||||
return Promise.resolve({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
user_id: 'u1',
|
||||
username: 'Admin',
|
||||
groups: ['Administrators'],
|
||||
totp_confirmed: false,
|
||||
totp_enabled: false,
|
||||
permissions: ['nodes.read', 'nodes.exec', 'logs.read'],
|
||||
}),
|
||||
})
|
||||
}
|
||||
|
||||
if (url.includes('/api/v1/status')) {
|
||||
return Promise.resolve({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
service: 'clustercanvas',
|
||||
version: '0.1.0',
|
||||
}),
|
||||
})
|
||||
}
|
||||
|
||||
if (url.includes('/api/v1/auth-logs')) {
|
||||
return Promise.resolve({
|
||||
ok: true,
|
||||
json: async () => ({ events: [] }),
|
||||
})
|
||||
}
|
||||
|
||||
if (
|
||||
method === 'POST' &&
|
||||
url.includes('/api/v1/nodes/c1/health-check')
|
||||
) {
|
||||
containerHealthOk = true
|
||||
return Promise.resolve({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
node: {
|
||||
id: 'c1',
|
||||
kind: 'container',
|
||||
name: 'alpha',
|
||||
health_ok: true,
|
||||
health_message: 'ping ok (icmp); ssh ok',
|
||||
},
|
||||
}),
|
||||
})
|
||||
}
|
||||
|
||||
if (url.includes('/api/v1/nodes')) {
|
||||
return Promise.resolve({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
nodes: [
|
||||
{
|
||||
id: 'c1',
|
||||
kind: 'container',
|
||||
name: 'alpha',
|
||||
host_ip: '10.0.0.1',
|
||||
username: 'root',
|
||||
group_name: 'Administrators',
|
||||
key_algo: 'ed25519',
|
||||
health_ok: containerHealthOk,
|
||||
health_message:
|
||||
containerHealthOk === false
|
||||
? 'unreachable'
|
||||
: containerHealthOk
|
||||
? 'ping ok (icmp); ssh ok'
|
||||
: undefined,
|
||||
},
|
||||
],
|
||||
}),
|
||||
})
|
||||
}
|
||||
|
||||
return Promise.resolve({
|
||||
ok: true,
|
||||
json: async () => ({}),
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
render(<App />)
|
||||
|
||||
const containersButton = await screen.findByRole('button', {
|
||||
name: /Containers/,
|
||||
})
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
containersButton.querySelector('.nav-count-badge-alert'),
|
||||
).toHaveTextContent('1')
|
||||
})
|
||||
expect(
|
||||
containersButton.querySelector(
|
||||
'.nav-count-badge:not(.nav-count-badge-alert)',
|
||||
),
|
||||
).toHaveTextContent('1')
|
||||
|
||||
await user.click(containersButton)
|
||||
const healthCheckButton = await screen.findByRole('button', {
|
||||
name: 'Health check',
|
||||
})
|
||||
await user.click(healthCheckButton)
|
||||
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
screen
|
||||
.getByRole('button', { name: /Containers/ })
|
||||
.querySelector('.nav-count-badge-alert'),
|
||||
).toBeNull()
|
||||
})
|
||||
expect(
|
||||
screen
|
||||
.getByRole('button', { name: /Containers/ })
|
||||
.querySelector('.nav-count-badge'),
|
||||
).toHaveTextContent('1')
|
||||
})
|
||||
|
||||
it('shows a red Activity badge for auth failures today', async () => {
|
||||
const todayIso = new Date(
|
||||
new Date().getFullYear(),
|
||||
|
||||
+128
-10
@@ -1,4 +1,5 @@
|
||||
import { useEffect, useState, type FormEvent } from 'react'
|
||||
import { OverviewPanel } from './OverviewPanel'
|
||||
import {
|
||||
beginMyTOTP,
|
||||
changeMyPassword,
|
||||
@@ -34,6 +35,7 @@ import {
|
||||
type Action,
|
||||
type ActionEnvVar,
|
||||
type ActionKind,
|
||||
type ActionWidgetType,
|
||||
type AuthAuditEvent,
|
||||
type Group,
|
||||
type GroupScopeKind,
|
||||
@@ -1121,13 +1123,18 @@ function ActionsConfigTab() {
|
||||
const [draftBody, setDraftBody] = useState('')
|
||||
const [draftEnv, setDraftEnv] = useState<ActionEnvVar[]>([emptyEnvRow()])
|
||||
const [draftRequiresSudo, setDraftRequiresSudo] = useState(false)
|
||||
const [draftShowWidget, setDraftShowWidget] = useState(false)
|
||||
const [draftWidgetType, setDraftWidgetType] =
|
||||
useState<ActionWidgetType>('raw')
|
||||
const [isSaving, setIsSaving] = useState(false)
|
||||
const [editingIsBuiltin, setEditingIsBuiltin] = useState(false)
|
||||
|
||||
const isEditing = editingActionId !== null
|
||||
const showEditor = editorKind !== null
|
||||
const canCreateActions = Boolean(me?.permissions?.includes('actions.create'))
|
||||
const canUpdateActions = Boolean(me?.permissions?.includes('actions.update'))
|
||||
const canDeleteActions = Boolean(me?.permissions?.includes('actions.delete'))
|
||||
const fieldsLocked = editingIsBuiltin
|
||||
|
||||
useEffect(() => {
|
||||
let isCancelled = false
|
||||
@@ -1173,6 +1180,9 @@ function ActionsConfigTab() {
|
||||
setDraftBody('')
|
||||
setDraftEnv([emptyEnvRow()])
|
||||
setDraftRequiresSudo(false)
|
||||
setDraftShowWidget(false)
|
||||
setDraftWidgetType('raw')
|
||||
setEditingIsBuiltin(false)
|
||||
setSubmitError(null)
|
||||
}
|
||||
|
||||
@@ -1185,21 +1195,28 @@ function ActionsConfigTab() {
|
||||
setShowTypePicker(false)
|
||||
setEditorKind(kind)
|
||||
setEditingActionId(null)
|
||||
setEditingIsBuiltin(false)
|
||||
setDraftName('')
|
||||
setDraftDescription('')
|
||||
setDraftBody('')
|
||||
setDraftEnv([emptyEnvRow()])
|
||||
setDraftRequiresSudo(false)
|
||||
setDraftShowWidget(false)
|
||||
setDraftWidgetType('raw')
|
||||
setSubmitError(null)
|
||||
}
|
||||
|
||||
function startEdit(action: Action) {
|
||||
if (action.builtin || !canUpdateActions) {
|
||||
if (!canUpdateActions) {
|
||||
return
|
||||
}
|
||||
if (action.builtin && !canUpdateActions) {
|
||||
return
|
||||
}
|
||||
setShowTypePicker(false)
|
||||
setEditorKind(action.kind)
|
||||
setEditingActionId(action.id)
|
||||
setEditingIsBuiltin(Boolean(action.builtin))
|
||||
setDraftName(action.name)
|
||||
setDraftDescription(action.description)
|
||||
setDraftBody(action.body)
|
||||
@@ -1209,6 +1226,14 @@ function ActionsConfigTab() {
|
||||
: [emptyEnvRow()],
|
||||
)
|
||||
setDraftRequiresSudo(Boolean(action.requires_sudo))
|
||||
setDraftShowWidget(Boolean(action.show_widget))
|
||||
setDraftWidgetType(
|
||||
action.widget_type === 'memory' ||
|
||||
action.widget_type === 'disk' ||
|
||||
action.widget_type === 'raw'
|
||||
? action.widget_type
|
||||
: 'raw',
|
||||
)
|
||||
setSubmitError(null)
|
||||
}
|
||||
|
||||
@@ -1250,14 +1275,25 @@ function ActionsConfigTab() {
|
||||
setIsSaving(true)
|
||||
try {
|
||||
const env = normalizedEnv()
|
||||
const widgetType = draftShowWidget ? draftWidgetType : ('' as const)
|
||||
const response = isEditing
|
||||
? await patchAction(editingActionId!, {
|
||||
? await patchAction(
|
||||
editingActionId!,
|
||||
editingIsBuiltin
|
||||
? {
|
||||
show_widget: draftShowWidget,
|
||||
widget_type: widgetType,
|
||||
}
|
||||
: {
|
||||
name: draftName,
|
||||
description: draftDescription,
|
||||
body: draftBody,
|
||||
env,
|
||||
requires_sudo: draftRequiresSudo,
|
||||
})
|
||||
show_widget: draftShowWidget,
|
||||
widget_type: widgetType,
|
||||
},
|
||||
)
|
||||
: await createAction({
|
||||
name: draftName,
|
||||
description: draftDescription,
|
||||
@@ -1265,6 +1301,8 @@ function ActionsConfigTab() {
|
||||
body: draftBody,
|
||||
env,
|
||||
requires_sudo: draftRequiresSudo,
|
||||
show_widget: draftShowWidget,
|
||||
widget_type: widgetType,
|
||||
})
|
||||
setActions([...response.actions])
|
||||
clearEditor()
|
||||
@@ -1281,6 +1319,9 @@ function ActionsConfigTab() {
|
||||
if (action.builtin || !canDeleteActions) {
|
||||
return
|
||||
}
|
||||
if (!window.confirm(`Delete action “${action.name}”?`)) {
|
||||
return
|
||||
}
|
||||
setSubmitError(null)
|
||||
try {
|
||||
const response = await deleteAction(action.id)
|
||||
@@ -1355,6 +1396,14 @@ function ActionsConfigTab() {
|
||||
sudo
|
||||
</span>
|
||||
) : null}
|
||||
{action.show_widget ? (
|
||||
<span className="user-badge">
|
||||
widget
|
||||
{action.widget_type
|
||||
? `: ${action.widget_type}`
|
||||
: ''}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="groups-li-meta">
|
||||
{action.description || 'No description'}
|
||||
@@ -1371,12 +1420,12 @@ function ActionsConfigTab() {
|
||||
<button
|
||||
type="button"
|
||||
className="groups-edit"
|
||||
disabled={action.builtin || !canUpdateActions}
|
||||
disabled={!canUpdateActions}
|
||||
title={
|
||||
action.builtin
|
||||
? 'Built-in actions cannot be edited'
|
||||
: canUpdateActions
|
||||
? undefined
|
||||
canUpdateActions
|
||||
? action.builtin
|
||||
? 'Edit widget settings'
|
||||
: undefined
|
||||
: 'Requires actions.update'
|
||||
}
|
||||
onClick={() => startEdit(action)}
|
||||
@@ -1482,7 +1531,9 @@ function ActionsConfigTab() {
|
||||
>
|
||||
<h2 id="action-editor-title">
|
||||
{isEditing
|
||||
? `Edit ${editorKind === 'shell' ? 'shell command' : 'script'}`
|
||||
? editingIsBuiltin
|
||||
? `Widget settings · ${draftName}`
|
||||
: `Edit ${editorKind === 'shell' ? 'shell command' : 'script'}`
|
||||
: `New ${editorKind === 'shell' ? 'shell command' : 'script'}`}
|
||||
</h2>
|
||||
|
||||
@@ -1492,12 +1543,20 @@ function ActionsConfigTab() {
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
{editingIsBuiltin ? (
|
||||
<p className="config-hint">
|
||||
Built-in actions keep a fixed command body. You can still enable
|
||||
or change the Overview widget.
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
<div className="config-field">
|
||||
<label htmlFor="action-name">Name</label>
|
||||
<input
|
||||
id="action-name"
|
||||
className="config-input"
|
||||
value={draftName}
|
||||
disabled={fieldsLocked}
|
||||
onChange={(event) => setDraftName(event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
@@ -1508,6 +1567,7 @@ function ActionsConfigTab() {
|
||||
id="action-description"
|
||||
className="config-input"
|
||||
value={draftDescription}
|
||||
disabled={fieldsLocked}
|
||||
onChange={(event) => setDraftDescription(event.target.value)}
|
||||
/>
|
||||
</div>
|
||||
@@ -1521,6 +1581,7 @@ function ActionsConfigTab() {
|
||||
id="action-body"
|
||||
className="config-input"
|
||||
value={draftBody}
|
||||
disabled={fieldsLocked}
|
||||
placeholder="echo {{node.ip}}"
|
||||
onChange={(event) => setDraftBody(event.target.value)}
|
||||
/>
|
||||
@@ -1529,21 +1590,25 @@ function ActionsConfigTab() {
|
||||
id="action-body"
|
||||
className="config-input actions-script-input"
|
||||
value={draftBody}
|
||||
disabled={fieldsLocked}
|
||||
rows={8}
|
||||
placeholder={"#!/bin/sh\necho {{node.host}}"}
|
||||
onChange={(event) => setDraftBody(event.target.value)}
|
||||
/>
|
||||
)}
|
||||
{!fieldsLocked ? (
|
||||
<p className="config-hint">
|
||||
Do not include a leading <code>sudo</code>; check Requires Sudo
|
||||
instead so ClusterCanvas runs with <code>sudo -n</code>.
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<label className="checkbox-row">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={draftRequiresSudo}
|
||||
disabled={fieldsLocked}
|
||||
onChange={(event) =>
|
||||
setDraftRequiresSudo(event.target.checked)
|
||||
}
|
||||
@@ -1551,6 +1616,41 @@ function ActionsConfigTab() {
|
||||
Requires Sudo
|
||||
</label>
|
||||
|
||||
<label className="checkbox-row">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={draftShowWidget}
|
||||
onChange={(event) => {
|
||||
const checked = event.target.checked
|
||||
setDraftShowWidget(checked)
|
||||
if (checked && !draftWidgetType) {
|
||||
setDraftWidgetType('raw')
|
||||
}
|
||||
}}
|
||||
/>
|
||||
Show Widget
|
||||
</label>
|
||||
{draftShowWidget ? (
|
||||
<div className="config-field">
|
||||
<label htmlFor="action-widget-type">Widget type</label>
|
||||
<select
|
||||
id="action-widget-type"
|
||||
className="config-input"
|
||||
value={draftWidgetType}
|
||||
onChange={(event) =>
|
||||
setDraftWidgetType(event.target.value as ActionWidgetType)
|
||||
}
|
||||
>
|
||||
<option value="memory">Memory (free -m ring)</option>
|
||||
<option value="disk">Disk (df -h ring)</option>
|
||||
<option value="raw">Raw text</option>
|
||||
</select>
|
||||
<p className="config-hint">
|
||||
Parsed from the last run of this action on each node Overview.
|
||||
</p>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="config-field">
|
||||
<label>Environment variables</label>
|
||||
<p className="config-hint">
|
||||
@@ -1565,6 +1665,7 @@ function ActionsConfigTab() {
|
||||
aria-label={`ENV name ${index + 1}`}
|
||||
placeholder="NAME"
|
||||
value={row.name}
|
||||
disabled={fieldsLocked}
|
||||
onChange={(event) =>
|
||||
updateEnvRow(index, 'name', event.target.value)
|
||||
}
|
||||
@@ -1574,6 +1675,7 @@ function ActionsConfigTab() {
|
||||
aria-label={`ENV value ${index + 1}`}
|
||||
placeholder="value or {{node.ip}}"
|
||||
value={row.value}
|
||||
disabled={fieldsLocked}
|
||||
onChange={(event) =>
|
||||
updateEnvRow(index, 'value', event.target.value)
|
||||
}
|
||||
@@ -1582,6 +1684,7 @@ function ActionsConfigTab() {
|
||||
type="button"
|
||||
className="groups-delete"
|
||||
aria-label={`Remove ENV row ${index + 1}`}
|
||||
disabled={fieldsLocked}
|
||||
onClick={() => removeEnvRow(index)}
|
||||
>
|
||||
Remove
|
||||
@@ -1592,6 +1695,7 @@ function ActionsConfigTab() {
|
||||
<button
|
||||
type="button"
|
||||
className="groups-new"
|
||||
disabled={fieldsLocked}
|
||||
onClick={() => addEnvRow()}
|
||||
>
|
||||
Add ENV variable
|
||||
@@ -2433,11 +2537,13 @@ function ResourceOverviewTab({
|
||||
canRead,
|
||||
canExec,
|
||||
canDelete,
|
||||
onNodesChanged,
|
||||
}: {
|
||||
section: ResourceSectionId
|
||||
canRead: boolean
|
||||
canExec: boolean
|
||||
canDelete: boolean
|
||||
onNodesChanged: () => void
|
||||
}) {
|
||||
const [nodes, setNodes] = useState<ReadonlyArray<Node>>([])
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
@@ -2532,6 +2638,7 @@ function ResourceOverviewTab({
|
||||
)
|
||||
} finally {
|
||||
setCheckingNodeId(null)
|
||||
onNodesChanged()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2546,6 +2653,7 @@ function ResourceOverviewTab({
|
||||
),
|
||||
)
|
||||
setPendingDeleteNodeId(null)
|
||||
onNodesChanged()
|
||||
}
|
||||
|
||||
if (!canRead) {
|
||||
@@ -2996,12 +3104,14 @@ function ResourceSectionPanel({
|
||||
onResourceTabChange,
|
||||
nodeId,
|
||||
nodeDetailTab,
|
||||
onNodesChanged,
|
||||
}: {
|
||||
section: ResourceSectionId
|
||||
activeResourceTab: ResourceTabId
|
||||
onResourceTabChange: (tab: ResourceTabId) => void
|
||||
nodeId: string | null
|
||||
nodeDetailTab: NodeDetailTabId
|
||||
onNodesChanged: () => void
|
||||
}) {
|
||||
const [me, setMe] = useState<MeResponse | null>(null)
|
||||
const [overviewKey, setOverviewKey] = useState(0)
|
||||
@@ -3040,6 +3150,7 @@ function ResourceSectionPanel({
|
||||
section={section}
|
||||
nodeId={nodeId}
|
||||
activeTab={nodeDetailTab}
|
||||
onNodesChanged={onNodesChanged}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -3086,6 +3197,7 @@ function ResourceSectionPanel({
|
||||
canRead={canRead}
|
||||
canExec={canExec}
|
||||
canDelete={canDelete}
|
||||
onNodesChanged={onNodesChanged}
|
||||
/>
|
||||
) : null}
|
||||
{activeResourceTab === 'security' ? (
|
||||
@@ -3848,6 +3960,7 @@ function App() {
|
||||
useState<NodeSectionCounts | null>(null)
|
||||
const [nodeHealthErrorCounts, setNodeHealthErrorCounts] =
|
||||
useState<NodeSectionCounts | null>(null)
|
||||
const [sidebarCountsRefreshToken, setSidebarCountsRefreshToken] = useState(0)
|
||||
const [authFailuresToday, setAuthFailuresToday] = useState<number | null>(
|
||||
null,
|
||||
)
|
||||
@@ -3969,7 +4082,7 @@ function App() {
|
||||
isCancelled = true
|
||||
window.clearInterval(pollIntervalId)
|
||||
}
|
||||
}, [bootState.kind, canReadLogs, activeSection])
|
||||
}, [bootState.kind, canReadLogs, activeSection, sidebarCountsRefreshToken])
|
||||
|
||||
useEffect(() => {
|
||||
if (bootState.kind !== 'ready') {
|
||||
@@ -4272,7 +4385,12 @@ function App() {
|
||||
onResourceTabChange={goToResourceTab}
|
||||
nodeId={activeNodeId}
|
||||
nodeDetailTab={activeNodeDetailTab}
|
||||
onNodesChanged={() => {
|
||||
setSidebarCountsRefreshToken((token) => token + 1)
|
||||
}}
|
||||
/>
|
||||
) : activeSection === 'overview' ? (
|
||||
<OverviewPanel />
|
||||
) : (
|
||||
<p>Coming soon</p>
|
||||
)}
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
type ActionLogFile,
|
||||
type ActionLogFileInfo,
|
||||
type ActionGroupRunRecord,
|
||||
type ActionEnvVar,
|
||||
type MeResponse,
|
||||
type Node,
|
||||
type NodeActionGroup,
|
||||
@@ -35,6 +36,7 @@ import {
|
||||
collectSudoersLines,
|
||||
itemRequiresSudo,
|
||||
} from './actionSudoers'
|
||||
import { NodeActionWidgetsPanel } from './ActionWidgets'
|
||||
import { SECTION_TITLES } from './documentTitle'
|
||||
import {
|
||||
type NodeDetailTabId,
|
||||
@@ -132,6 +134,7 @@ function normalizeItemForCompare(item: NodeActionItem): unknown {
|
||||
requires_sudo: Boolean(item.requires_sudo),
|
||||
set_variable: item.set_variable || '',
|
||||
run_if: item.run_if || '',
|
||||
widget_label: item.widget_label || '',
|
||||
}
|
||||
}
|
||||
|
||||
@@ -257,10 +260,12 @@ export function NodeDetailPanel({
|
||||
section,
|
||||
nodeId,
|
||||
activeTab,
|
||||
onNodesChanged,
|
||||
}: {
|
||||
section: ResourceSectionId
|
||||
nodeId: string
|
||||
activeTab: NodeDetailTabId
|
||||
onNodesChanged?: () => void
|
||||
}) {
|
||||
const [me, setMe] = useState<MeResponse | null>(null)
|
||||
const [node, setNode] = useState<Node | null>(null)
|
||||
@@ -378,6 +383,7 @@ export function NodeDetailPanel({
|
||||
canExec={canExecNodes}
|
||||
canUpdate={canUpdateNodes}
|
||||
onNodeUpdated={setNode}
|
||||
onNodesChanged={onNodesChanged}
|
||||
/>
|
||||
) : null}
|
||||
{activeTab === 'actions' ? (
|
||||
@@ -444,6 +450,7 @@ function formValuesToIntervalSeconds(
|
||||
|
||||
function NodeDetailOverviewTab({ node }: { node: Node }) {
|
||||
return (
|
||||
<div className="node-detail-overview-wrap">
|
||||
<dl className="node-detail-overview">
|
||||
<div>
|
||||
<dt>Name</dt>
|
||||
@@ -477,6 +484,12 @@ function NodeDetailOverviewTab({ node }: { node: Node }) {
|
||||
<dd>{new Date(node.created_at).toLocaleString()}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
|
||||
<section className="node-action-widgets-section" aria-label="Action widgets">
|
||||
<h2 className="node-action-widgets-heading">Action widgets</h2>
|
||||
<NodeActionWidgetsPanel nodeId={node.id} />
|
||||
</section>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -485,11 +498,13 @@ function NodeHealthcheckTab({
|
||||
canExec,
|
||||
canUpdate,
|
||||
onNodeUpdated,
|
||||
onNodesChanged,
|
||||
}: {
|
||||
node: Node
|
||||
canExec: boolean
|
||||
canUpdate: boolean
|
||||
onNodeUpdated: (node: Node) => void
|
||||
onNodesChanged?: () => void
|
||||
}) {
|
||||
const [intervalDraft, setIntervalDraft] = useState(() =>
|
||||
intervalToFormValues(node.health_check_interval_seconds ?? 0),
|
||||
@@ -498,6 +513,10 @@ function NodeHealthcheckTab({
|
||||
const [intervalError, setIntervalError] = useState<string | null>(null)
|
||||
const [isChecking, setIsChecking] = useState(false)
|
||||
const [checkError, setCheckError] = useState<string | null>(null)
|
||||
const [pendingHealthPoll, setPendingHealthPoll] = useState<{
|
||||
nodeId: string
|
||||
previousCheckedAt: string | null
|
||||
} | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
setIntervalDraft(
|
||||
@@ -506,6 +525,56 @@ function NodeHealthcheckTab({
|
||||
setIntervalError(null)
|
||||
}, [node.id, node.health_check_interval_seconds])
|
||||
|
||||
useEffect(() => {
|
||||
setPendingHealthPoll(null)
|
||||
}, [node.id])
|
||||
|
||||
useEffect(() => {
|
||||
if (pendingHealthPoll === null) {
|
||||
return
|
||||
}
|
||||
const { nodeId, previousCheckedAt } = pendingHealthPoll
|
||||
let isCancelled = false
|
||||
|
||||
async function pollForHealthResult() {
|
||||
const maxAttempts = 20
|
||||
for (let attempt = 0; attempt < maxAttempts; attempt += 1) {
|
||||
if (isCancelled) {
|
||||
return
|
||||
}
|
||||
await new Promise((resolve) => {
|
||||
window.setTimeout(resolve, 300)
|
||||
})
|
||||
if (isCancelled) {
|
||||
return
|
||||
}
|
||||
try {
|
||||
const refreshed = await fetchNode(nodeId)
|
||||
const nextCheckedAt = refreshed.node.health_last_checked_at ?? null
|
||||
if (nextCheckedAt !== previousCheckedAt) {
|
||||
if (!isCancelled) {
|
||||
onNodeUpdated(refreshed.node)
|
||||
onNodesChanged?.()
|
||||
setPendingHealthPoll(null)
|
||||
}
|
||||
return
|
||||
}
|
||||
} catch {
|
||||
// Ignore transient poll errors; interval save already succeeded.
|
||||
}
|
||||
}
|
||||
if (!isCancelled) {
|
||||
setPendingHealthPoll(null)
|
||||
}
|
||||
}
|
||||
|
||||
void pollForHealthResult()
|
||||
|
||||
return () => {
|
||||
isCancelled = true
|
||||
}
|
||||
}, [pendingHealthPoll, onNodeUpdated, onNodesChanged])
|
||||
|
||||
async function handleSaveInterval() {
|
||||
const seconds = formValuesToIntervalSeconds(
|
||||
intervalDraft.value,
|
||||
@@ -514,6 +583,7 @@ function NodeHealthcheckTab({
|
||||
setIsSavingInterval(true)
|
||||
setIntervalError(null)
|
||||
try {
|
||||
const previousCheckedAt = node.health_last_checked_at ?? null
|
||||
const response = await patchNode(node.id, {
|
||||
health_check_interval_seconds: seconds,
|
||||
})
|
||||
@@ -521,6 +591,12 @@ function NodeHealthcheckTab({
|
||||
setIntervalDraft(
|
||||
intervalToFormValues(response.node.health_check_interval_seconds ?? 0),
|
||||
)
|
||||
if (seconds > 0) {
|
||||
setPendingHealthPoll({
|
||||
nodeId: node.id,
|
||||
previousCheckedAt,
|
||||
})
|
||||
}
|
||||
} catch (err) {
|
||||
setIntervalError(
|
||||
err instanceof Error ? err.message : 'Failed to save interval',
|
||||
@@ -536,6 +612,7 @@ function NodeHealthcheckTab({
|
||||
try {
|
||||
const response = await runNodeHealthCheck(node.id)
|
||||
onNodeUpdated(response.node)
|
||||
onNodesChanged?.()
|
||||
} catch (err) {
|
||||
setCheckError(
|
||||
err instanceof Error ? err.message : 'Health check failed',
|
||||
@@ -971,6 +1048,8 @@ function ActionGroupEditor({
|
||||
const [editRequiresSudo, setEditRequiresSudo] = useState(false)
|
||||
const [editSetVariable, setEditSetVariable] = useState('')
|
||||
const [editRunIf, setEditRunIf] = useState('')
|
||||
const [editWidgetLabel, setEditWidgetLabel] = useState('')
|
||||
const [editEnv, setEditEnv] = useState<ActionEnvVar[]>([])
|
||||
const [resolvedPaths, setResolvedPaths] = useState<Record<string, string>>({})
|
||||
const [resolveMissing, setResolveMissing] = useState<string[]>([])
|
||||
const [resolveBusy, setResolveBusy] = useState(false)
|
||||
@@ -1154,8 +1233,10 @@ function ActionGroupEditor({
|
||||
name: draftItem.name,
|
||||
body: draftItem.body,
|
||||
requires_sudo: Boolean(draftItem.requires_sudo),
|
||||
env: draftItem.env || [],
|
||||
set_variable: draftItem.set_variable || '',
|
||||
run_if: draftItem.run_if || '',
|
||||
widget_label: draftItem.widget_label || '',
|
||||
},
|
||||
)
|
||||
latest = patched.group
|
||||
@@ -1174,12 +1255,16 @@ function ActionGroupEditor({
|
||||
name: draftItem.name,
|
||||
body: draftItem.body,
|
||||
requires_sudo: Boolean(draftItem.requires_sudo),
|
||||
env: draftItem.env || [],
|
||||
set_variable: draftItem.set_variable || '',
|
||||
run_if: draftItem.run_if || '',
|
||||
widget_label: draftItem.widget_label || '',
|
||||
}
|
||||
: {
|
||||
env: draftItem.env || [],
|
||||
set_variable: draftItem.set_variable || '',
|
||||
run_if: draftItem.run_if || '',
|
||||
widget_label: draftItem.widget_label || '',
|
||||
}
|
||||
const patched = await patchActionGroupItem(
|
||||
nodeId,
|
||||
@@ -1201,8 +1286,10 @@ function ActionGroupEditor({
|
||||
? await createActionGroupItem(nodeId, group.id, {
|
||||
source: 'library',
|
||||
library_action_id: draftItem.library_action_id,
|
||||
env: draftItem.env || [],
|
||||
set_variable: draftItem.set_variable,
|
||||
run_if: draftItem.run_if,
|
||||
widget_label: draftItem.widget_label,
|
||||
})
|
||||
: await createActionGroupItem(nodeId, group.id, {
|
||||
source: 'local',
|
||||
@@ -1214,6 +1301,7 @@ function ActionGroupEditor({
|
||||
requires_sudo: Boolean(draftItem.requires_sudo),
|
||||
set_variable: draftItem.set_variable,
|
||||
run_if: draftItem.run_if,
|
||||
widget_label: draftItem.widget_label,
|
||||
})
|
||||
latest = created.group
|
||||
const createdItem = latest.items.find(
|
||||
@@ -1223,15 +1311,20 @@ function ActionGroupEditor({
|
||||
idMap.set(draftItem.id, createdItem.id)
|
||||
if (
|
||||
draftItem.source === 'library' &&
|
||||
(draftItem.set_variable || draftItem.run_if)
|
||||
(draftItem.set_variable ||
|
||||
draftItem.run_if ||
|
||||
draftItem.widget_label ||
|
||||
(draftItem.env && draftItem.env.length > 0))
|
||||
) {
|
||||
const patched = await patchActionGroupItem(
|
||||
nodeId,
|
||||
group.id,
|
||||
createdItem.id,
|
||||
{
|
||||
env: draftItem.env || [],
|
||||
set_variable: draftItem.set_variable || '',
|
||||
run_if: draftItem.run_if || '',
|
||||
widget_label: draftItem.widget_label || '',
|
||||
},
|
||||
)
|
||||
latest = patched.group
|
||||
@@ -1286,12 +1379,19 @@ function ActionGroupEditor({
|
||||
if (!libraryPick) {
|
||||
return
|
||||
}
|
||||
const libraryAction = libraryById.get(libraryPick)
|
||||
setItemsDraft((previous) => [
|
||||
...previous,
|
||||
{
|
||||
id: newDraftItemId(),
|
||||
source: 'library',
|
||||
library_action_id: libraryPick,
|
||||
env: libraryAction
|
||||
? libraryAction.env.map((envVar) => ({ ...envVar }))
|
||||
: [],
|
||||
widget_label: libraryAction?.show_widget
|
||||
? libraryAction.name
|
||||
: undefined,
|
||||
},
|
||||
])
|
||||
setLibraryPick('')
|
||||
@@ -1391,6 +1491,7 @@ function ActionGroupEditor({
|
||||
requires_sudo: libraryAction.requires_sudo || undefined,
|
||||
set_variable: entry.set_variable,
|
||||
run_if: entry.run_if,
|
||||
widget_label: entry.widget_label,
|
||||
}
|
||||
: entry,
|
||||
),
|
||||
@@ -1398,6 +1499,12 @@ function ActionGroupEditor({
|
||||
}
|
||||
|
||||
function handleApplyItemEdit(item: NodeActionItem) {
|
||||
const normalizedEnv = editEnv
|
||||
.map((row) => ({
|
||||
name: row.name.trim(),
|
||||
value: row.value,
|
||||
}))
|
||||
.filter((row) => row.name !== '' || row.value.trim() !== '')
|
||||
setItemsDraft((previous) =>
|
||||
previous.map((entry) => {
|
||||
if (entry.id !== item.id) {
|
||||
@@ -1408,21 +1515,33 @@ function ActionGroupEditor({
|
||||
...entry,
|
||||
name: editName,
|
||||
body: editBody,
|
||||
env: normalizedEnv,
|
||||
requires_sudo: editRequiresSudo || undefined,
|
||||
set_variable: editSetVariable.trim() || undefined,
|
||||
run_if: editRunIf.trim() || undefined,
|
||||
widget_label: editWidgetLabel.trim() || undefined,
|
||||
}
|
||||
}
|
||||
return {
|
||||
...entry,
|
||||
env: normalizedEnv,
|
||||
set_variable: editSetVariable.trim() || undefined,
|
||||
run_if: editRunIf.trim() || undefined,
|
||||
widget_label: editWidgetLabel.trim() || undefined,
|
||||
}
|
||||
}),
|
||||
)
|
||||
setEditingItemId(null)
|
||||
}
|
||||
|
||||
function libraryShowsWidget(item: NodeActionItem): boolean {
|
||||
if (item.source !== 'library') {
|
||||
return false
|
||||
}
|
||||
const libraryAction = libraryById.get(item.library_action_id || '')
|
||||
return Boolean(libraryAction?.show_widget)
|
||||
}
|
||||
|
||||
async function handleRunGroup() {
|
||||
if (isDirty) {
|
||||
setError('Save or undo changes before running this group.')
|
||||
@@ -1723,6 +1842,24 @@ function ActionGroupEditor({
|
||||
setEditRequiresSudo(Boolean(item.requires_sudo))
|
||||
setEditSetVariable(item.set_variable || '')
|
||||
setEditRunIf(item.run_if || '')
|
||||
setEditWidgetLabel(item.widget_label || '')
|
||||
const libraryAction =
|
||||
item.source === 'library'
|
||||
? libraryById.get(item.library_action_id || '')
|
||||
: undefined
|
||||
const baseEnv =
|
||||
item.env && item.env.length > 0
|
||||
? item.env.map((envVar) => ({ ...envVar }))
|
||||
: libraryAction
|
||||
? libraryAction.env.map((envVar) => ({
|
||||
...envVar,
|
||||
}))
|
||||
: []
|
||||
setEditEnv(
|
||||
baseEnv.length > 0
|
||||
? baseEnv
|
||||
: [{ name: '', value: '' }],
|
||||
)
|
||||
}}
|
||||
>
|
||||
Edit
|
||||
@@ -1743,6 +1880,7 @@ function ActionGroupEditor({
|
||||
</div>
|
||||
{(item.set_variable ||
|
||||
item.run_if ||
|
||||
item.widget_label ||
|
||||
itemRequiresSudo(item, libraryById)) &&
|
||||
!(isEditing && editingItemId === item.id) ? (
|
||||
<p className="config-hint node-action-item-vars">
|
||||
@@ -1756,7 +1894,11 @@ function ActionGroupEditor({
|
||||
)
|
||||
) : null}
|
||||
{itemRequiresSudo(item, libraryById) &&
|
||||
(item.set_variable || item.run_if)
|
||||
(item.set_variable || item.run_if || item.widget_label)
|
||||
? ' · '
|
||||
: null}
|
||||
{item.widget_label ? `Widget: ${item.widget_label}` : null}
|
||||
{item.widget_label && (item.set_variable || item.run_if)
|
||||
? ' · '
|
||||
: null}
|
||||
{item.set_variable ? `Set $${item.set_variable}` : null}
|
||||
@@ -1797,13 +1939,104 @@ function ActionGroupEditor({
|
||||
</>
|
||||
) : (
|
||||
<p className="config-hint">
|
||||
Library action body is read-only. Clone to customize the
|
||||
command, or edit variables below.
|
||||
Library action body is read-only. Override ENV (e.g. MOUNT)
|
||||
or widget label below, or clone to customize the command.
|
||||
{itemRequiresSudo(item, libraryById)
|
||||
? ' This library action requires sudo.'
|
||||
: ''}
|
||||
</p>
|
||||
)}
|
||||
{libraryShowsWidget(item) ? (
|
||||
<label>
|
||||
Widget label
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Overview title"
|
||||
value={editWidgetLabel}
|
||||
onChange={(event) =>
|
||||
setEditWidgetLabel(event.target.value)
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
) : null}
|
||||
{libraryShowsWidget(item) ? (
|
||||
<div className="config-field">
|
||||
<span>ENV overrides</span>
|
||||
<p className="config-hint">
|
||||
For disk widgets, set MOUNT to the path (e.g. / or
|
||||
/data).
|
||||
</p>
|
||||
<div className="actions-env-rows">
|
||||
{editEnv.map((row, index) => (
|
||||
<div
|
||||
key={`item-env-${index}`}
|
||||
className="actions-env-row"
|
||||
>
|
||||
<input
|
||||
className="config-input"
|
||||
aria-label={`ENV name ${index + 1}`}
|
||||
placeholder="NAME"
|
||||
value={row.name}
|
||||
onChange={(event) => {
|
||||
const value = event.target.value
|
||||
setEditEnv((previous) =>
|
||||
previous.map((entry, rowIndex) =>
|
||||
rowIndex === index
|
||||
? { ...entry, name: value }
|
||||
: entry,
|
||||
),
|
||||
)
|
||||
}}
|
||||
/>
|
||||
<input
|
||||
className="config-input"
|
||||
aria-label={`ENV value ${index + 1}`}
|
||||
placeholder="value"
|
||||
value={row.value}
|
||||
onChange={(event) => {
|
||||
const value = event.target.value
|
||||
setEditEnv((previous) =>
|
||||
previous.map((entry, rowIndex) =>
|
||||
rowIndex === index
|
||||
? { ...entry, value }
|
||||
: entry,
|
||||
),
|
||||
)
|
||||
}}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="groups-delete"
|
||||
onClick={() => {
|
||||
setEditEnv((previous) => {
|
||||
if (previous.length <= 1) {
|
||||
return [{ name: '', value: '' }]
|
||||
}
|
||||
return previous.filter(
|
||||
(_, rowIndex) => rowIndex !== index,
|
||||
)
|
||||
})
|
||||
}}
|
||||
>
|
||||
Remove
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="groups-new"
|
||||
onClick={() =>
|
||||
setEditEnv((previous) => [
|
||||
...previous,
|
||||
{ name: '', value: '' },
|
||||
])
|
||||
}
|
||||
>
|
||||
Add ENV variable
|
||||
</button>
|
||||
</div>
|
||||
) : null}
|
||||
<label>
|
||||
Set variable
|
||||
<input
|
||||
|
||||
@@ -0,0 +1,288 @@
|
||||
import { render, screen } from '@testing-library/react'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import {
|
||||
formatDiskLine,
|
||||
formatMemoryAmount,
|
||||
formatMemoryLine,
|
||||
freePercentFromUsePercent,
|
||||
OverviewPanel,
|
||||
} from './OverviewPanel'
|
||||
import { navigateTo } from './navigation'
|
||||
|
||||
vi.mock('./navigation', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('./navigation')>()
|
||||
return {
|
||||
...actual,
|
||||
navigateTo: vi.fn(),
|
||||
}
|
||||
})
|
||||
|
||||
describe('formatMemoryAmount', () => {
|
||||
it('uses MiB, GiB, and TiB thresholds', () => {
|
||||
expect(formatMemoryAmount(512)).toBe('512 MiB')
|
||||
expect(formatMemoryAmount(1024)).toBe('1.0 GiB')
|
||||
expect(formatMemoryAmount(1024 * 1024)).toBe('1.0 TiB')
|
||||
})
|
||||
})
|
||||
|
||||
describe('formatMemoryLine', () => {
|
||||
it('formats free / total', () => {
|
||||
expect(
|
||||
formatMemoryLine({
|
||||
total: 2048,
|
||||
used: 1000,
|
||||
free: 512,
|
||||
shared: 0,
|
||||
buff_cache: 536,
|
||||
available: 800,
|
||||
}),
|
||||
).toBe('512 MiB / 2.0 GiB')
|
||||
})
|
||||
})
|
||||
|
||||
describe('freePercentFromUsePercent', () => {
|
||||
it('derives free percent from use percent', () => {
|
||||
expect(freePercentFromUsePercent('24%')).toBe(76)
|
||||
expect(freePercentFromUsePercent('24')).toBe(76)
|
||||
expect(freePercentFromUsePercent('bad')).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('formatDiskLine', () => {
|
||||
it('formats used / size with free percent', () => {
|
||||
expect(
|
||||
formatDiskLine({
|
||||
filesystem: '/dev/sda1',
|
||||
size: '50G',
|
||||
used: '12G',
|
||||
avail: '38G',
|
||||
use_percent: '24%',
|
||||
mounted_on: '/',
|
||||
}),
|
||||
).toBe('12G / 50G (76% free)')
|
||||
})
|
||||
})
|
||||
|
||||
describe('OverviewPanel', () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it('groups nodes by kind and shows metrics when widgets exist', async () => {
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn().mockImplementation((input: RequestInfo | URL) => {
|
||||
const url = typeof input === 'string' ? input : input.toString()
|
||||
|
||||
if (url.includes('/api/v1/action-widgets')) {
|
||||
return Promise.resolve({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
widgets: [
|
||||
{
|
||||
node_id: 'c1',
|
||||
item_id: 'm1',
|
||||
group_id: 'g1',
|
||||
library_action_id: 'mem',
|
||||
widget_type: 'memory',
|
||||
title: 'Memory',
|
||||
updated_at: '2026-01-01T00:00:00.000Z',
|
||||
exit_code: 0,
|
||||
raw_stdout: '',
|
||||
memory: {
|
||||
total: 1024,
|
||||
used: 400,
|
||||
free: 512,
|
||||
shared: 0,
|
||||
buff_cache: 112,
|
||||
available: 600,
|
||||
},
|
||||
},
|
||||
{
|
||||
node_id: 'c1',
|
||||
item_id: 'd1',
|
||||
group_id: 'g1',
|
||||
library_action_id: 'disk',
|
||||
widget_type: 'disk',
|
||||
title: 'Disk',
|
||||
updated_at: '2026-01-01T00:00:00.000Z',
|
||||
exit_code: 0,
|
||||
raw_stdout: '',
|
||||
disk: {
|
||||
filesystem: '/dev/sda1',
|
||||
size: '50G',
|
||||
used: '12G',
|
||||
avail: '38G',
|
||||
use_percent: '24%',
|
||||
mounted_on: '/',
|
||||
},
|
||||
},
|
||||
],
|
||||
}),
|
||||
})
|
||||
}
|
||||
|
||||
if (url.includes('/api/v1/nodes')) {
|
||||
return Promise.resolve({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
nodes: [
|
||||
{
|
||||
id: 'c1',
|
||||
kind: 'container',
|
||||
name: 'alpha',
|
||||
host_ip: '10.0.0.1',
|
||||
username: 'root',
|
||||
group_name: 'Administrators',
|
||||
public_key: '',
|
||||
key_algo: 'ed25519',
|
||||
created_at: '2026-01-01T00:00:00.000Z',
|
||||
health_ok: true,
|
||||
},
|
||||
{
|
||||
id: 'd1',
|
||||
kind: 'docker',
|
||||
name: 'dock',
|
||||
host_ip: '10.0.0.2',
|
||||
username: 'root',
|
||||
group_name: 'Administrators',
|
||||
public_key: '',
|
||||
key_algo: 'ed25519',
|
||||
created_at: '2026-01-01T00:00:00.000Z',
|
||||
},
|
||||
{
|
||||
id: 'v1',
|
||||
kind: 'vm',
|
||||
name: 'vm-one',
|
||||
host_ip: '10.0.0.3',
|
||||
username: 'root',
|
||||
group_name: 'Administrators',
|
||||
public_key: '',
|
||||
key_algo: 'ed25519',
|
||||
created_at: '2026-01-01T00:00:00.000Z',
|
||||
},
|
||||
],
|
||||
}),
|
||||
})
|
||||
}
|
||||
|
||||
return Promise.resolve({
|
||||
ok: true,
|
||||
json: async () => ({}),
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
render(<OverviewPanel />)
|
||||
|
||||
expect(await screen.findByRole('heading', { name: 'Containers' })).toBeInTheDocument()
|
||||
expect(screen.getByRole('heading', { name: 'Docker' })).toBeInTheDocument()
|
||||
expect(
|
||||
screen.getByRole('heading', { name: 'Virtual Machines' }),
|
||||
).toBeInTheDocument()
|
||||
|
||||
expect(screen.getByRole('button', { name: /alpha/ })).toBeInTheDocument()
|
||||
expect(screen.getByText('512 MiB / 1.0 GiB')).toBeInTheDocument()
|
||||
expect(screen.getByText('12G / 50G (76% free)')).toBeInTheDocument()
|
||||
|
||||
expect(screen.getByRole('button', { name: /dock/ })).toBeInTheDocument()
|
||||
expect(screen.getByRole('button', { name: /vm-one/ })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('omits metric lines when widgets are missing', async () => {
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn().mockImplementation((input: RequestInfo | URL) => {
|
||||
const url = typeof input === 'string' ? input : input.toString()
|
||||
|
||||
if (url.includes('/api/v1/action-widgets')) {
|
||||
return Promise.resolve({
|
||||
ok: true,
|
||||
json: async () => ({ widgets: [] }),
|
||||
})
|
||||
}
|
||||
|
||||
if (url.includes('/api/v1/nodes')) {
|
||||
return Promise.resolve({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
nodes: [
|
||||
{
|
||||
id: 'c1',
|
||||
kind: 'container',
|
||||
name: 'bare',
|
||||
host_ip: '10.0.0.1',
|
||||
username: 'root',
|
||||
group_name: 'Administrators',
|
||||
public_key: '',
|
||||
key_algo: 'ed25519',
|
||||
created_at: '2026-01-01T00:00:00.000Z',
|
||||
},
|
||||
],
|
||||
}),
|
||||
})
|
||||
}
|
||||
|
||||
return Promise.resolve({
|
||||
ok: true,
|
||||
json: async () => ({}),
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
render(<OverviewPanel />)
|
||||
|
||||
expect(await screen.findByRole('button', { name: /bare/ })).toBeInTheDocument()
|
||||
expect(screen.queryByText('RAM')).not.toBeInTheDocument()
|
||||
expect(screen.queryByText('Disk')).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('navigates to node detail on card click', async () => {
|
||||
const user = userEvent.setup()
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn().mockImplementation((input: RequestInfo | URL) => {
|
||||
const url = typeof input === 'string' ? input : input.toString()
|
||||
|
||||
if (url.includes('/api/v1/action-widgets')) {
|
||||
return Promise.resolve({
|
||||
ok: true,
|
||||
json: async () => ({ widgets: [] }),
|
||||
})
|
||||
}
|
||||
|
||||
if (url.includes('/api/v1/nodes')) {
|
||||
return Promise.resolve({
|
||||
ok: true,
|
||||
json: async () => ({
|
||||
nodes: [
|
||||
{
|
||||
id: 'c1',
|
||||
kind: 'container',
|
||||
name: 'alpha',
|
||||
host_ip: '10.0.0.1',
|
||||
username: 'root',
|
||||
group_name: 'Administrators',
|
||||
public_key: '',
|
||||
key_algo: 'ed25519',
|
||||
created_at: '2026-01-01T00:00:00.000Z',
|
||||
},
|
||||
],
|
||||
}),
|
||||
})
|
||||
}
|
||||
|
||||
return Promise.resolve({
|
||||
ok: true,
|
||||
json: async () => ({}),
|
||||
})
|
||||
}),
|
||||
)
|
||||
|
||||
render(<OverviewPanel />)
|
||||
|
||||
await user.click(await screen.findByRole('button', { name: /alpha/ }))
|
||||
expect(navigateTo).toHaveBeenCalledWith('/containers/c1')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,259 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
import {
|
||||
type ActionWidgetSnapshot,
|
||||
type DiskWidgetData,
|
||||
type MemoryWidgetData,
|
||||
type Node,
|
||||
type NodeKind,
|
||||
fetchAllActionWidgets,
|
||||
fetchNodes,
|
||||
} from './api/client'
|
||||
import { SECTION_TITLES } from './documentTitle'
|
||||
import {
|
||||
navigateTo,
|
||||
pathFor,
|
||||
type ResourceSectionId,
|
||||
} from './navigation'
|
||||
|
||||
const KIND_SECTIONS: ReadonlyArray<{
|
||||
kind: NodeKind
|
||||
section: ResourceSectionId
|
||||
}> = [
|
||||
{ kind: 'container', section: 'containers' },
|
||||
{ kind: 'docker', section: 'docker' },
|
||||
{ kind: 'vm', section: 'vms' },
|
||||
]
|
||||
|
||||
/** Format a MiB quantity with auto MiB / GiB / TiB units. */
|
||||
export function formatMemoryAmount(mib: number): string {
|
||||
const absolute = Math.abs(mib)
|
||||
if (absolute >= 1024 * 1024) {
|
||||
return `${(mib / (1024 * 1024)).toFixed(1)} TiB`
|
||||
}
|
||||
if (absolute >= 1024) {
|
||||
return `${(mib / 1024).toFixed(1)} GiB`
|
||||
}
|
||||
return `${Math.round(mib)} MiB`
|
||||
}
|
||||
|
||||
/** Format free / total RAM from a memory widget snapshot. */
|
||||
export function formatMemoryLine(memory: MemoryWidgetData): string {
|
||||
return `${formatMemoryAmount(memory.free)} / ${formatMemoryAmount(memory.total)}`
|
||||
}
|
||||
|
||||
/** Parse df Use% into a free percent integer, or null if unparsable. */
|
||||
export function freePercentFromUsePercent(usePercent: string): number | null {
|
||||
const match = usePercent.trim().match(/^(\d+(?:\.\d+)?)\s*%?$/)
|
||||
if (!match) {
|
||||
return null
|
||||
}
|
||||
const used = Number(match[1])
|
||||
if (!Number.isFinite(used)) {
|
||||
return null
|
||||
}
|
||||
return Math.max(0, Math.min(100, Math.round(100 - used)))
|
||||
}
|
||||
|
||||
/** Format used / total (free%) from a disk widget snapshot. */
|
||||
export function formatDiskLine(disk: DiskWidgetData): string {
|
||||
const freePercent = freePercentFromUsePercent(disk.use_percent)
|
||||
const base = `${disk.used} / ${disk.size}`
|
||||
if (freePercent === null) {
|
||||
return base
|
||||
}
|
||||
return `${base} (${freePercent}% free)`
|
||||
}
|
||||
|
||||
function pickMemoryWidget(
|
||||
widgets: ReadonlyArray<ActionWidgetSnapshot>,
|
||||
): MemoryWidgetData | null {
|
||||
for (const widget of widgets) {
|
||||
if (widget.widget_type === 'memory' && widget.memory) {
|
||||
return widget.memory
|
||||
}
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function pickDiskWidget(
|
||||
widgets: ReadonlyArray<ActionWidgetSnapshot>,
|
||||
): DiskWidgetData | null {
|
||||
const diskWidgets = widgets.filter(
|
||||
(widget) => widget.widget_type === 'disk' && widget.disk,
|
||||
)
|
||||
if (diskWidgets.length === 0) {
|
||||
return null
|
||||
}
|
||||
const root = diskWidgets.find((widget) => widget.disk?.mounted_on === '/')
|
||||
return (root ?? diskWidgets[0]).disk ?? null
|
||||
}
|
||||
|
||||
function healthDotClass(healthOk: boolean | null | undefined): string {
|
||||
if (healthOk === true) {
|
||||
return 'node-health-dot node-health-dot-ok'
|
||||
}
|
||||
if (healthOk === false) {
|
||||
return 'node-health-dot node-health-dot-error'
|
||||
}
|
||||
return 'node-health-dot node-health-dot-unknown'
|
||||
}
|
||||
|
||||
function healthDotLabel(healthOk: boolean | null | undefined): string {
|
||||
if (healthOk === true) {
|
||||
return 'Healthy'
|
||||
}
|
||||
if (healthOk === false) {
|
||||
return 'Unhealthy'
|
||||
}
|
||||
return 'Health unknown'
|
||||
}
|
||||
|
||||
function groupWidgetsByNode(
|
||||
widgets: ReadonlyArray<ActionWidgetSnapshot>,
|
||||
): Map<string, ActionWidgetSnapshot[]> {
|
||||
const byNode = new Map<string, ActionWidgetSnapshot[]>()
|
||||
for (const widget of widgets) {
|
||||
const existing = byNode.get(widget.node_id)
|
||||
if (existing) {
|
||||
existing.push(widget)
|
||||
} else {
|
||||
byNode.set(widget.node_id, [widget])
|
||||
}
|
||||
}
|
||||
return byNode
|
||||
}
|
||||
|
||||
function OverviewNodeCard({
|
||||
node,
|
||||
widgets,
|
||||
section,
|
||||
}: {
|
||||
node: Node
|
||||
widgets: ReadonlyArray<ActionWidgetSnapshot>
|
||||
section: ResourceSectionId
|
||||
}) {
|
||||
const memory = pickMemoryWidget(widgets)
|
||||
const disk = pickDiskWidget(widgets)
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className="overview-node-card"
|
||||
onClick={() => {
|
||||
navigateTo(pathFor(section, 'overview', 'overview', node.id, 'overview'))
|
||||
}}
|
||||
>
|
||||
<div className="overview-node-card-heading">
|
||||
<span
|
||||
className={healthDotClass(node.health_ok)}
|
||||
title={healthDotLabel(node.health_ok)}
|
||||
aria-label={healthDotLabel(node.health_ok)}
|
||||
role="img"
|
||||
/>
|
||||
<strong className="overview-node-card-title">{node.name}</strong>
|
||||
</div>
|
||||
{memory ? (
|
||||
<p className="overview-node-metric">
|
||||
<span className="overview-node-metric-label">RAM</span>
|
||||
{formatMemoryLine(memory)}
|
||||
</p>
|
||||
) : null}
|
||||
{disk ? (
|
||||
<p className="overview-node-metric">
|
||||
<span className="overview-node-metric-label">Disk</span>
|
||||
{formatDiskLine(disk)}
|
||||
</p>
|
||||
) : null}
|
||||
</button>
|
||||
)
|
||||
}
|
||||
|
||||
export function OverviewPanel() {
|
||||
const [nodes, setNodes] = useState<ReadonlyArray<Node> | null>(null)
|
||||
const [widgetsByNode, setWidgetsByNode] = useState<
|
||||
Map<string, ActionWidgetSnapshot[]>
|
||||
>(() => new Map())
|
||||
const [errorMessage, setErrorMessage] = useState<string | null>(null)
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
|
||||
useEffect(() => {
|
||||
let isCancelled = false
|
||||
|
||||
async function loadOverview() {
|
||||
setIsLoading(true)
|
||||
setErrorMessage(null)
|
||||
try {
|
||||
const [nodesResponse, widgetsResponse] = await Promise.all([
|
||||
fetchNodes(),
|
||||
fetchAllActionWidgets(),
|
||||
])
|
||||
if (isCancelled) {
|
||||
return
|
||||
}
|
||||
setNodes(nodesResponse.nodes)
|
||||
setWidgetsByNode(groupWidgetsByNode(widgetsResponse.widgets ?? []))
|
||||
} catch (error) {
|
||||
if (!isCancelled) {
|
||||
setErrorMessage(
|
||||
error instanceof Error ? error.message : 'Failed to load overview',
|
||||
)
|
||||
setNodes([])
|
||||
setWidgetsByNode(new Map())
|
||||
}
|
||||
} finally {
|
||||
if (!isCancelled) {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void loadOverview()
|
||||
|
||||
return () => {
|
||||
isCancelled = true
|
||||
}
|
||||
}, [])
|
||||
|
||||
if (isLoading) {
|
||||
return <p className="config-hint">Loading overview…</p>
|
||||
}
|
||||
|
||||
if (errorMessage) {
|
||||
return <p className="form-error" role="alert">{errorMessage}</p>
|
||||
}
|
||||
|
||||
const allNodes = nodes ?? []
|
||||
if (allNodes.length === 0) {
|
||||
return <p className="config-hint">No nodes yet.</p>
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="overview-panel">
|
||||
{KIND_SECTIONS.map(({ kind, section }) => {
|
||||
const sectionNodes = allNodes.filter((node) => node.kind === kind)
|
||||
if (sectionNodes.length === 0) {
|
||||
return null
|
||||
}
|
||||
return (
|
||||
<section
|
||||
key={kind}
|
||||
className="overview-kind-section"
|
||||
aria-label={SECTION_TITLES[section]}
|
||||
>
|
||||
<h3 className="overview-kind-heading">{SECTION_TITLES[section]}</h3>
|
||||
<div className="overview-node-grid">
|
||||
{sectionNodes.map((node) => (
|
||||
<OverviewNodeCard
|
||||
key={node.id}
|
||||
node={node}
|
||||
widgets={widgetsByNode.get(node.id) ?? []}
|
||||
section={section}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -611,6 +611,21 @@ describe('nodes API', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('fetches all action widgets', async () => {
|
||||
const { fetchAllActionWidgets } = await import('./client')
|
||||
const fetchMock = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({ widgets: [] }),
|
||||
})
|
||||
|
||||
await fetchAllActionWidgets(fetchMock)
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
'http://localhost:8080/api/v1/action-widgets',
|
||||
expect.objectContaining({ credentials: 'include' }),
|
||||
)
|
||||
})
|
||||
|
||||
it('sends idle-exempt header when requested', async () => {
|
||||
const { fetchNodes, SESSION_IDLE_EXEMPT_HEADER } = await import('./client')
|
||||
const fetchMock = vi.fn().mockResolvedValue({
|
||||
|
||||
@@ -904,6 +904,8 @@ export async function fetchAuthLogs(
|
||||
|
||||
export type ActionKind = 'shell' | 'script'
|
||||
|
||||
export type ActionWidgetType = 'memory' | 'disk' | 'raw'
|
||||
|
||||
export type ActionEnvVar = {
|
||||
name: string
|
||||
value: string
|
||||
@@ -917,6 +919,8 @@ export type Action = {
|
||||
body: string
|
||||
env: ReadonlyArray<ActionEnvVar>
|
||||
requires_sudo: boolean
|
||||
show_widget: boolean
|
||||
widget_type?: ActionWidgetType | ''
|
||||
builtin: boolean
|
||||
created_at: string
|
||||
updated_at: string
|
||||
@@ -933,6 +937,8 @@ export type CreateActionRequest = {
|
||||
body: string
|
||||
env: ReadonlyArray<ActionEnvVar>
|
||||
requires_sudo?: boolean
|
||||
show_widget?: boolean
|
||||
widget_type?: ActionWidgetType | ''
|
||||
}
|
||||
|
||||
export type PatchActionRequest = {
|
||||
@@ -941,6 +947,8 @@ export type PatchActionRequest = {
|
||||
body?: string
|
||||
env?: ReadonlyArray<ActionEnvVar>
|
||||
requires_sudo?: boolean
|
||||
show_widget?: boolean
|
||||
widget_type?: ActionWidgetType | ''
|
||||
}
|
||||
|
||||
export async function fetchActions(
|
||||
@@ -1033,6 +1041,7 @@ export type NodeActionItem = {
|
||||
requires_sudo?: boolean
|
||||
set_variable?: string
|
||||
run_if?: string
|
||||
widget_label?: string
|
||||
}
|
||||
|
||||
export type NodeActionGroup = {
|
||||
@@ -1133,6 +1142,7 @@ export type CreateActionGroupItemRequest = {
|
||||
requires_sudo?: boolean
|
||||
set_variable?: string
|
||||
run_if?: string
|
||||
widget_label?: string
|
||||
}
|
||||
|
||||
export type PatchActionGroupItemRequest = {
|
||||
@@ -1144,6 +1154,7 @@ export type PatchActionGroupItemRequest = {
|
||||
requires_sudo?: boolean
|
||||
set_variable?: string
|
||||
run_if?: string
|
||||
widget_label?: string
|
||||
}
|
||||
|
||||
export async function fetchActionGroups(
|
||||
@@ -1359,3 +1370,68 @@ export async function fetchActionLogFile(
|
||||
}
|
||||
return (await response.json()) as ActionLogFileResponse
|
||||
}
|
||||
|
||||
export type MemoryWidgetData = {
|
||||
total: number
|
||||
used: number
|
||||
free: number
|
||||
shared: number
|
||||
buff_cache: number
|
||||
available: number
|
||||
}
|
||||
|
||||
export type DiskWidgetData = {
|
||||
filesystem: string
|
||||
size: string
|
||||
used: string
|
||||
avail: string
|
||||
use_percent: string
|
||||
mounted_on: string
|
||||
size_mib?: number
|
||||
used_mib?: number
|
||||
avail_mib?: number
|
||||
}
|
||||
|
||||
export type ActionWidgetSnapshot = {
|
||||
node_id: string
|
||||
item_id: string
|
||||
group_id: string
|
||||
library_action_id: string
|
||||
widget_type: ActionWidgetType | ''
|
||||
title: string
|
||||
updated_at: string
|
||||
exit_code: number
|
||||
error?: string
|
||||
raw_stdout: string
|
||||
memory?: MemoryWidgetData
|
||||
disk?: DiskWidgetData
|
||||
}
|
||||
|
||||
export type ActionWidgetsResponse = {
|
||||
widgets: ReadonlyArray<ActionWidgetSnapshot>
|
||||
}
|
||||
|
||||
export async function fetchNodeActionWidgets(
|
||||
nodeId: string,
|
||||
fetchImpl: typeof fetch = fetch,
|
||||
): Promise<ActionWidgetsResponse> {
|
||||
const response = await apiFetch(
|
||||
`/api/v1/nodes/${encodeURIComponent(nodeId)}/action-widgets`,
|
||||
{},
|
||||
fetchImpl,
|
||||
)
|
||||
if (!response.ok) {
|
||||
throw new Error(await readErrorMessage(response))
|
||||
}
|
||||
return (await response.json()) as ActionWidgetsResponse
|
||||
}
|
||||
|
||||
export async function fetchAllActionWidgets(
|
||||
fetchImpl: typeof fetch = fetch,
|
||||
): Promise<ActionWidgetsResponse> {
|
||||
const response = await apiFetch('/api/v1/action-widgets', {}, fetchImpl)
|
||||
if (!response.ok) {
|
||||
throw new Error(await readErrorMessage(response))
|
||||
}
|
||||
return (await response.json()) as ActionWidgetsResponse
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user