Add Show Widget for library actions with Overview charts from last run output.

This commit is contained in:
2026-07-20 20:48:06 +02:00
parent e047499fed
commit d188ca41b8
20 changed files with 1918 additions and 128 deletions
+33 -2
View File
@@ -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),
})
}
+85 -31
View File
@@ -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 {
+34 -7
View File
@@ -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,
+1
View File
@@ -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)