Add Show Widget for library actions with Overview charts from last run output.
This commit is contained in:
@@ -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,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),
|
||||
})
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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,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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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"`
|
||||
@@ -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"`
|
||||
|
||||
Reference in New Issue
Block a user