From d188ca41b823d64fe6567acabac259abe976db76 Mon Sep 17 00:00:00 2001 From: squid Date: Mon, 20 Jul 2026 20:48:06 +0200 Subject: [PATCH] Add Show Widget for library actions with Overview charts from last run output. --- .../internal/api/action_groups_handlers.go | 35 +- .../internal/api/action_widgets_handlers.go | 26 ++ service/internal/api/actions_handlers.go | 116 ++++-- service/internal/api/actions_handlers_test.go | 41 ++- service/internal/api/router.go | 1 + service/internal/runner/executor.go | 112 +++++- service/internal/runner/executor_test.go | 36 ++ service/internal/runner/widgetparse/parse.go | 201 +++++++++++ .../internal/runner/widgetparse/parse_test.go | 66 ++++ service/internal/settings/action_widgets.go | 125 +++++++ .../internal/settings/action_widgets_test.go | 135 +++++++ service/internal/settings/actions.go | 139 ++++++- service/internal/settings/actions_test.go | 10 +- service/internal/settings/path.go | 1 + service/internal/settings/types.go | 78 +++- webui/src/ActionWidgets.tsx | 341 ++++++++++++++++++ webui/src/App.css | 139 +++++++ webui/src/App.tsx | 138 ++++++- webui/src/NodeDetailPanel.tsx | 240 ++++++++++-- webui/src/api/client.ts | 66 ++++ 20 files changed, 1918 insertions(+), 128 deletions(-) create mode 100644 service/internal/api/action_widgets_handlers.go create mode 100644 service/internal/runner/widgetparse/parse.go create mode 100644 service/internal/runner/widgetparse/parse_test.go create mode 100644 service/internal/settings/action_widgets.go create mode 100644 service/internal/settings/action_widgets_test.go create mode 100644 webui/src/ActionWidgets.tsx diff --git a/service/internal/api/action_groups_handlers.go b/service/internal/api/action_groups_handlers.go index 1acecac..12a4129 100644 --- a/service/internal/api/action_groups_handlers.go +++ b/service/internal/api/action_groups_handlers.go @@ -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") diff --git a/service/internal/api/action_widgets_handlers.go b/service/internal/api/action_widgets_handlers.go new file mode 100644 index 0000000..a47b1df --- /dev/null +++ b/service/internal/api/action_widgets_handlers.go @@ -0,0 +1,26 @@ +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), + }) +} diff --git a/service/internal/api/actions_handlers.go b/service/internal/api/actions_handlers.go index e6b4ff7..709d8e6 100644 --- a/service/internal/api/actions_handlers.go +++ b/service/internal/api/actions_handlers.go @@ -17,31 +17,38 @@ type actionsResponse struct { } type createActionRequest struct { - Name string `json:"name"` - Description string `json:"description"` - Kind settings.ActionKind `json:"kind"` - Body string `json:"body"` - Env []settings.ActionEnvVar `json:"env"` - RequiresSudo bool `json:"requires_sudo"` + Name string `json:"name"` + Description string `json:"description"` + Kind settings.ActionKind `json:"kind"` + 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 { - Name *string `json:"name"` - Description *string `json:"description"` - Body *string `json:"body"` - Env *[]settings.ActionEnvVar `json:"env"` - RequiresSudo *bool `json:"requires_sudo"` + Name *string `json:"name"` + Description *string `json:"description"` + 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_]*$`) var ( - errActionNameRequired = errors.New("action name is required") - errActionBodyRequired = errors.New("action body is required") - 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") - errActionIDRequired = errors.New("action id is required") + errActionNameRequired = errors.New("action name is required") + errActionBodyRequired = errors.New("action body is required") + 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") ) func actionsGetHandler(configDir string) http.HandlerFunc { @@ -120,26 +127,46 @@ func actionsPatchHandler(configDir string) http.HandlerFunc { writeJSON(writer, http.StatusNotFound, apiErrorResponse{Error: errActionNotFound.Error()}) return } - if store.Actions[index].Builtin { - writeJSON(writer, http.StatusBadRequest, apiErrorResponse{Error: errActionBuiltinRO.Error()}) - return - } updated := store.Actions[index] - if payload.Name != nil { - updated.Name = strings.TrimSpace(*payload.Name) + 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 + } } - if payload.Description != nil { - updated.Description = strings.TrimSpace(*payload.Description) + + if !isBuiltin { + if payload.Name != nil { + updated.Name = strings.TrimSpace(*payload.Name) + } + if payload.Description != nil { + updated.Description = strings.TrimSpace(*payload.Description) + } + if payload.Body != nil { + updated.Body = strings.TrimSpace(*payload.Body) + } + if payload.Env != nil { + updated.Env = *payload.Env + } + if payload.RequiresSudo != nil { + updated.RequiresSudo = *payload.RequiresSudo + } } - if payload.Body != nil { - updated.Body = strings.TrimSpace(*payload.Body) + if payload.ShowWidget != nil { + updated.ShowWidget = *payload.ShowWidget } - if payload.Env != nil { - updated.Env = *payload.Env + if payload.WidgetType != nil { + updated.WidgetType = *payload.WidgetType } - if payload.RequiresSudo != nil { - updated.RequiresSudo = *payload.RequiresSudo + if !updated.ShowWidget { + updated.WidgetType = settings.ActionWidgetTypeNone } updated.UpdatedAt = time.Now().UTC() @@ -147,6 +174,10 @@ func actionsPatchHandler(configDir string) http.HandlerFunc { 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{} } @@ -212,6 +243,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 +265,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 +297,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 { diff --git a/service/internal/api/actions_handlers_test.go b/service/internal/api/actions_handlers_test.go index 3794e22..017574d 100644 --- a/service/internal/api/actions_handlers_test.go +++ b/service/internal/api/actions_handlers_test.go @@ -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, diff --git a/service/internal/api/router.go b/service/internal/api/router.go index c11d9a8..89e7394 100644 --- a/service/internal/api/router.go +++ b/service/internal/api/router.go @@ -83,6 +83,7 @@ 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/node-logs", app.nodeLogsListHandler) mux.HandleFunc("GET /api/v1/auth-logs", app.authLogsListHandler) diff --git a/service/internal/runner/executor.go b/service/internal/runner/executor.go index b5011b8..d6657ab 100644 --- a/service/internal/runner/executor.go +++ b/service/internal/runner/executor.go @@ -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) +} diff --git a/service/internal/runner/executor_test.go b/service/internal/runner/executor_test.go index 84308e8..d0bd409 100644 --- a/service/internal/runner/executor_test.go +++ b/service/internal/runner/executor_test.go @@ -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, diff --git a/service/internal/runner/widgetparse/parse.go b/service/internal/runner/widgetparse/parse.go new file mode 100644 index 0000000..94f9f7c --- /dev/null +++ b/service/internal/runner/widgetparse/parse.go @@ -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 + } +} diff --git a/service/internal/runner/widgetparse/parse_test.go b/service/internal/runner/widgetparse/parse_test.go new file mode 100644 index 0000000..77022cf --- /dev/null +++ b/service/internal/runner/widgetparse/parse_test.go @@ -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) + } +} diff --git a/service/internal/settings/action_widgets.go b/service/internal/settings/action_widgets.go new file mode 100644 index 0000000..d4f034c --- /dev/null +++ b/service/internal/settings/action_widgets.go @@ -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) +} diff --git a/service/internal/settings/action_widgets_test.go b/service/internal/settings/action_widgets_test.go new file mode 100644 index 0000000..6a5cb7a --- /dev/null +++ b/service/internal/settings/action_widgets_test.go @@ -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) + } +} diff --git a/service/internal/settings/actions.go b/service/internal/settings/actions.go index f3fe84c..1ddcdaa 100644 --- a/service/internal/settings/actions.go +++ b/service/internal/settings/actions.go @@ -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,11 +39,15 @@ func DefaultBuiltinActions() []Action { Name: "Disk usage", Description: "Show filesystem disk space usage on the target.", Kind: ActionKindShell, - Body: "df -h", - Env: []ActionEnvVar{}, - Builtin: true, - CreatedAt: now, - UpdatedAt: now, + Body: "df -h {{env.MOUNT}}", + Env: []ActionEnvVar{ + {Name: "MOUNT", Value: "/"}, + }, + ShowWidget: true, + WidgetType: ActionWidgetTypeDisk, + Builtin: true, + CreatedAt: now, + UpdatedAt: now, }, { ID: BuiltinActionSysInfoID, @@ -52,10 +59,25 @@ if [ -f /etc/os-release ]; then . /etc/os-release echo "OS: ${NAME:-unknown} ${VERSION:-}" fi`, - Env: []ActionEnvVar{}, - Builtin: true, - CreatedAt: now, - UpdatedAt: now, + 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) } diff --git a/service/internal/settings/actions_test.go b/service/internal/settings/actions_test.go index c5d5f16..e2ed221 100644 --- a/service/internal/settings/actions_test.go +++ b/service/internal/settings/actions_test.go @@ -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) } diff --git a/service/internal/settings/path.go b/service/internal/settings/path.go index 0922b01..cf27b80 100644 --- a/service/internal/settings/path.go +++ b/service/internal/settings/path.go @@ -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" ) diff --git a/service/internal/settings/types.go b/service/internal/settings/types.go index 8b0e696..2a6710c 100644 --- a/service/internal/settings/types.go +++ b/service/internal/settings/types.go @@ -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"` @@ -136,16 +146,18 @@ type ActionEnvVar struct { // Action is a named shell command or script that can run on managed nodes. type Action struct { - ID string `json:"id"` - Name string `json:"name"` - Description string `json:"description"` - Kind ActionKind `json:"kind"` - Body string `json:"body"` - Env []ActionEnvVar `json:"env"` - RequiresSudo bool `json:"requires_sudo"` - Builtin bool `json:"builtin"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` + ID string `json:"id"` + Name string `json:"name"` + Description string `json:"description"` + Kind ActionKind `json:"kind"` + 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"` } // ActionStore is the plain JSON payload in actions.json. @@ -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"` diff --git a/webui/src/ActionWidgets.tsx b/webui/src/ActionWidgets.tsx new file mode 100644 index 0000000..c1e5ac6 --- /dev/null +++ b/webui/src/ActionWidgets.tsx @@ -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 + 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 ( + + ) + } + + return ( + `${segment.label} ${segment.value}`).join(', ')} + > + + {segments.map((segment) => { + const value = Math.max(0, segment.value) + const length = (value / total) * circumference + const dashOffset = -offset + offset += length + return ( + + ) + })} + + + ) +} + +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 ( +
+
+

{title}

+ {updatedAt ? ( + + ) : null} +
+ {error ?

{error}

: null} + {!data && !error ? ( +

Run this action to populate the widget.

+ ) : null} + {data ? ( +
+ +
+

Total {formatMiB(total)}

+
    +
  • + + Used {formatMiB(used)} +
  • +
  • + + Free {formatMiB(free)} +
  • +
  • + + Buff/cache {formatMiB(buffCache)} +
  • +
  • Shared {formatMiB(data.shared)}
  • +
  • Available {formatMiB(data.available)}
  • +
+
+
+ ) : null} +
+ ) +} + +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 ( +
+
+

{title}

+ {updatedAt ? ( + + ) : null} +
+ {error ?

{error}

: null} + {!data && !error ? ( +

Run this action to populate the widget.

+ ) : null} + {data ? ( +
+ +
+

+ Size {sizeLabel} + {mount ? ` · ${mount}` : ''} +

+
    +
  • + + Used {data.used} ({data.use_percent}) +
  • +
  • + + Avail {data.avail} +
  • +
  • {data.filesystem}
  • +
+
+
+ ) : null} +
+ ) +} + +function RawWidget({ + title, + stdout, + updatedAt, + error, +}: { + title: string + stdout: string + updatedAt?: string + error?: string +}) { + return ( +
+
+

{title}

+ {updatedAt ? ( + + ) : null} +
+ {error ?

{error}

: null} + {!stdout && !error ? ( +

Run this action to populate the widget.

+ ) : null} + {stdout ?
{stdout}
: null} +
+ ) +} + +function ActionWidgetCard({ widget }: { widget: ActionWidgetSnapshot }) { + if (widget.widget_type === 'memory') { + return ( + + ) + } + if (widget.widget_type === 'disk') { + return ( + + ) + } + return ( + + ) +} + +export function NodeActionWidgetsPanel({ nodeId }: { nodeId: string }) { + const [widgets, setWidgets] = useState([]) + const [isLoading, setIsLoading] = useState(true) + const [loadError, setLoadError] = useState(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

Loading widgets…

+ } + if (loadError) { + return ( +

+ {loadError} +

+ ) + } + if (widgets.length === 0) { + return ( +

+ No action widgets yet. Enable Show Widget on a library action, add it to + an action group, and run it. +

+ ) + } + + return ( +
+ {widgets.map((widget) => ( + + ))} +
+ ) +} diff --git a/webui/src/App.css b/webui/src/App.css index d891413..5e696dd 100644 --- a/webui/src/App.css +++ b/webui/src/App.css @@ -1927,3 +1927,142 @@ 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; +} + diff --git a/webui/src/App.tsx b/webui/src/App.tsx index 5e9e04d..c77af48 100644 --- a/webui/src/App.tsx +++ b/webui/src/App.tsx @@ -34,6 +34,7 @@ import { type Action, type ActionEnvVar, type ActionKind, + type ActionWidgetType, type AuthAuditEvent, type Group, type GroupScopeKind, @@ -1121,13 +1122,18 @@ function ActionsConfigTab() { const [draftBody, setDraftBody] = useState('') const [draftEnv, setDraftEnv] = useState([emptyEnvRow()]) const [draftRequiresSudo, setDraftRequiresSudo] = useState(false) + const [draftShowWidget, setDraftShowWidget] = useState(false) + const [draftWidgetType, setDraftWidgetType] = + useState('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 +1179,9 @@ function ActionsConfigTab() { setDraftBody('') setDraftEnv([emptyEnvRow()]) setDraftRequiresSudo(false) + setDraftShowWidget(false) + setDraftWidgetType('raw') + setEditingIsBuiltin(false) setSubmitError(null) } @@ -1185,21 +1194,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 +1225,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 +1274,25 @@ function ActionsConfigTab() { setIsSaving(true) try { const env = normalizedEnv() + const widgetType = draftShowWidget ? draftWidgetType : ('' as const) const response = isEditing - ? await patchAction(editingActionId!, { - name: draftName, - description: draftDescription, - body: draftBody, - env, - requires_sudo: draftRequiresSudo, - }) + ? 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 +1300,8 @@ function ActionsConfigTab() { body: draftBody, env, requires_sudo: draftRequiresSudo, + show_widget: draftShowWidget, + widget_type: widgetType, }) setActions([...response.actions]) clearEditor() @@ -1355,6 +1392,14 @@ function ActionsConfigTab() { sudo ) : null} + {action.show_widget ? ( + + widget + {action.widget_type + ? `: ${action.widget_type}` + : ''} + + ) : null}
{action.description || 'No description'} @@ -1371,13 +1416,13 @@ function ActionsConfigTab() {
@@ -1521,6 +1577,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 +1586,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)} /> )} -

- Do not include a leading sudo; check Requires Sudo - instead so ClusterCanvas runs with sudo -n. -

+ {!fieldsLocked ? ( +

+ Do not include a leading sudo; check Requires Sudo + instead so ClusterCanvas runs with sudo -n. +

+ ) : null} + + {draftShowWidget ? ( +
+ + +

+ Parsed from the last run of this action on each node Overview. +

+
+ ) : null} +

@@ -1565,6 +1661,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 +1671,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 +1680,7 @@ function ActionsConfigTab() { type="button" className="groups-delete" aria-label={`Remove ENV row ${index + 1}`} + disabled={fieldsLocked} onClick={() => removeEnvRow(index)} > Remove @@ -1592,6 +1691,7 @@ function ActionsConfigTab() {

{(item.set_variable || item.run_if || + item.widget_label || itemRequiresSudo(item, libraryById)) && !(isEditing && editingItemId === item.id) ? (

@@ -1823,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} @@ -1864,13 +1939,104 @@ function ActionGroupEditor({ ) : (

- 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.' : ''}

)} + {libraryShowsWidget(item) ? ( + + ) : null} + {libraryShowsWidget(item) ? ( +
+ ENV overrides +

+ For disk widgets, set MOUNT to the path (e.g. / or + /data). +

+
+ {editEnv.map((row, index) => ( +
+ { + const value = event.target.value + setEditEnv((previous) => + previous.map((entry, rowIndex) => + rowIndex === index + ? { ...entry, name: value } + : entry, + ), + ) + }} + /> + { + const value = event.target.value + setEditEnv((previous) => + previous.map((entry, rowIndex) => + rowIndex === index + ? { ...entry, value } + : entry, + ), + ) + }} + /> + +
+ ))} +
+ +
+ ) : null}