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)
+108 -4
View File
@@ -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)
}
+36
View File
@@ -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)
}
}
+125
View File
@@ -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)
}
}
+126 -13
View File
@@ -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)
}
+5 -5
View File
@@ -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)
}
+1
View File
@@ -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"
)
+68 -10
View File
@@ -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"`
+341
View File
@@ -0,0 +1,341 @@
import { useEffect, useState } from 'react'
import {
type ActionWidgetSnapshot,
type DiskWidgetData,
type MemoryWidgetData,
fetchNodeActionWidgets,
} from './api/client'
type RingSegment = {
value: number
color: string
label: string
}
function formatMiB(value: number): string {
if (value >= 1024) {
return `${(value / 1024).toFixed(1)} GiB`
}
return `${value} MiB`
}
function RingChart({
segments,
size = 120,
strokeWidth = 16,
}: {
segments: ReadonlyArray<RingSegment>
size?: number
strokeWidth?: number
}) {
const total = segments.reduce((sum, segment) => sum + Math.max(0, segment.value), 0)
const radius = (size - strokeWidth) / 2
const circumference = 2 * Math.PI * radius
let offset = 0
if (total <= 0) {
return (
<svg
className="action-widget-ring"
width={size}
height={size}
viewBox={`0 0 ${size} ${size}`}
aria-hidden="true"
>
<circle
cx={size / 2}
cy={size / 2}
r={radius}
fill="none"
stroke="var(--ctp-surface1)"
strokeWidth={strokeWidth}
/>
</svg>
)
}
return (
<svg
className="action-widget-ring"
width={size}
height={size}
viewBox={`0 0 ${size} ${size}`}
role="img"
aria-label={segments.map((segment) => `${segment.label} ${segment.value}`).join(', ')}
>
<g transform={`rotate(-90 ${size / 2} ${size / 2})`}>
{segments.map((segment) => {
const value = Math.max(0, segment.value)
const length = (value / total) * circumference
const dashOffset = -offset
offset += length
return (
<circle
key={segment.label}
cx={size / 2}
cy={size / 2}
r={radius}
fill="none"
stroke={segment.color}
strokeWidth={strokeWidth}
strokeDasharray={`${length} ${circumference - length}`}
strokeDashoffset={dashOffset}
strokeLinecap="butt"
/>
)
})}
</g>
</svg>
)
}
function MemoryRingWidget({
title,
data,
updatedAt,
error,
}: {
title: string
data?: MemoryWidgetData
updatedAt?: string
error?: string
}) {
const used = data?.used ?? 0
const free = data?.free ?? 0
const buffCache = data?.buff_cache ?? 0
const total = data?.total ?? used + free + buffCache
return (
<article className="action-widget action-widget-memory">
<header className="action-widget-header">
<h3>{title}</h3>
{updatedAt ? (
<time dateTime={updatedAt}>{new Date(updatedAt).toLocaleString()}</time>
) : null}
</header>
{error ? <p className="action-widget-error">{error}</p> : null}
{!data && !error ? (
<p className="config-hint">Run this action to populate the widget.</p>
) : null}
{data ? (
<div className="action-widget-body">
<RingChart
segments={[
{ value: used, color: 'var(--ctp-red)', label: 'Used' },
{ value: free, color: 'var(--ctp-green)', label: 'Free' },
{
value: buffCache,
color: 'var(--ctp-yellow)',
label: 'Buff/cache',
},
]}
/>
<div className="action-widget-stats">
<p className="action-widget-total">Total {formatMiB(total)}</p>
<ul className="action-widget-legend">
<li>
<span className="action-widget-swatch action-widget-swatch-used" />
Used {formatMiB(used)}
</li>
<li>
<span className="action-widget-swatch action-widget-swatch-free" />
Free {formatMiB(free)}
</li>
<li>
<span className="action-widget-swatch action-widget-swatch-cache" />
Buff/cache {formatMiB(buffCache)}
</li>
<li>Shared {formatMiB(data.shared)}</li>
<li>Available {formatMiB(data.available)}</li>
</ul>
</div>
</div>
) : null}
</article>
)
}
function DiskRingWidget({
title,
data,
updatedAt,
error,
}: {
title: string
data?: DiskWidgetData
updatedAt?: string
error?: string
}) {
const usedMiB = data?.used_mib ?? 0
const availMiB = data?.avail_mib ?? 0
const sizeLabel = data?.size || '—'
const mount = data?.mounted_on || ''
return (
<article className="action-widget action-widget-disk">
<header className="action-widget-header">
<h3>{title}</h3>
{updatedAt ? (
<time dateTime={updatedAt}>{new Date(updatedAt).toLocaleString()}</time>
) : null}
</header>
{error ? <p className="action-widget-error">{error}</p> : null}
{!data && !error ? (
<p className="config-hint">Run this action to populate the widget.</p>
) : null}
{data ? (
<div className="action-widget-body">
<RingChart
segments={[
{ value: usedMiB || 1, color: 'var(--ctp-yellow)', label: 'Used' },
{
value: availMiB || 1,
color: 'var(--ctp-blue)',
label: 'Available',
},
]}
/>
<div className="action-widget-stats">
<p className="action-widget-total">
Size {sizeLabel}
{mount ? ` · ${mount}` : ''}
</p>
<ul className="action-widget-legend">
<li>
<span className="action-widget-swatch action-widget-swatch-disk-used" />
Used {data.used} ({data.use_percent})
</li>
<li>
<span className="action-widget-swatch action-widget-swatch-disk-avail" />
Avail {data.avail}
</li>
<li className="action-widget-fs">{data.filesystem}</li>
</ul>
</div>
</div>
) : null}
</article>
)
}
function RawWidget({
title,
stdout,
updatedAt,
error,
}: {
title: string
stdout: string
updatedAt?: string
error?: string
}) {
return (
<article className="action-widget action-widget-raw">
<header className="action-widget-header">
<h3>{title}</h3>
{updatedAt ? (
<time dateTime={updatedAt}>{new Date(updatedAt).toLocaleString()}</time>
) : null}
</header>
{error ? <p className="action-widget-error">{error}</p> : null}
{!stdout && !error ? (
<p className="config-hint">Run this action to populate the widget.</p>
) : null}
{stdout ? <pre className="action-widget-pre">{stdout}</pre> : null}
</article>
)
}
function ActionWidgetCard({ widget }: { widget: ActionWidgetSnapshot }) {
if (widget.widget_type === 'memory') {
return (
<MemoryRingWidget
title={widget.title || 'Memory'}
data={widget.memory}
updatedAt={widget.updated_at}
error={widget.error}
/>
)
}
if (widget.widget_type === 'disk') {
return (
<DiskRingWidget
title={widget.title || 'Disk'}
data={widget.disk}
updatedAt={widget.updated_at}
error={widget.error}
/>
)
}
return (
<RawWidget
title={widget.title || 'Output'}
stdout={widget.raw_stdout || ''}
updatedAt={widget.updated_at}
error={widget.error}
/>
)
}
export function NodeActionWidgetsPanel({ nodeId }: { nodeId: string }) {
const [widgets, setWidgets] = useState<ActionWidgetSnapshot[]>([])
const [isLoading, setIsLoading] = useState(true)
const [loadError, setLoadError] = useState<string | null>(null)
useEffect(() => {
let isCancelled = false
async function loadWidgets() {
setIsLoading(true)
setLoadError(null)
try {
const response = await fetchNodeActionWidgets(nodeId)
if (!isCancelled) {
setWidgets([...response.widgets])
}
} catch (err) {
if (!isCancelled) {
setLoadError(
err instanceof Error ? err.message : 'Failed to load widgets',
)
}
} finally {
if (!isCancelled) {
setIsLoading(false)
}
}
}
void loadWidgets()
return () => {
isCancelled = true
}
}, [nodeId])
if (isLoading) {
return <p className="config-hint">Loading widgets</p>
}
if (loadError) {
return (
<p className="config-empty groups-error" role="alert">
{loadError}
</p>
)
}
if (widgets.length === 0) {
return (
<p className="config-hint">
No action widgets yet. Enable Show Widget on a library action, add it to
an action group, and run it.
</p>
)
}
return (
<div className="action-widgets-grid">
{widgets.map((widget) => (
<ActionWidgetCard key={`${widget.node_id}-${widget.item_id}`} widget={widget} />
))}
</div>
)
}
+139
View File
@@ -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;
}
+119 -19
View File
@@ -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<ActionEnvVar[]>([emptyEnvRow()])
const [draftRequiresSudo, setDraftRequiresSudo] = useState(false)
const [draftShowWidget, setDraftShowWidget] = useState(false)
const [draftWidgetType, setDraftWidgetType] =
useState<ActionWidgetType>('raw')
const [isSaving, setIsSaving] = useState(false)
const [editingIsBuiltin, setEditingIsBuiltin] = useState(false)
const isEditing = editingActionId !== null
const showEditor = editorKind !== null
const canCreateActions = Boolean(me?.permissions?.includes('actions.create'))
const canUpdateActions = Boolean(me?.permissions?.includes('actions.update'))
const canDeleteActions = Boolean(me?.permissions?.includes('actions.delete'))
const fieldsLocked = editingIsBuiltin
useEffect(() => {
let isCancelled = false
@@ -1173,6 +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
</span>
) : null}
{action.show_widget ? (
<span className="user-badge">
widget
{action.widget_type
? `: ${action.widget_type}`
: ''}
</span>
) : null}
</div>
<div className="groups-li-meta">
{action.description || 'No description'}
@@ -1371,13 +1416,13 @@ function ActionsConfigTab() {
<button
type="button"
className="groups-edit"
disabled={action.builtin || !canUpdateActions}
disabled={!canUpdateActions}
title={
action.builtin
? 'Built-in actions cannot be edited'
: canUpdateActions
? undefined
: 'Requires actions.update'
canUpdateActions
? action.builtin
? 'Edit widget settings'
: undefined
: 'Requires actions.update'
}
onClick={() => startEdit(action)}
>
@@ -1482,7 +1527,9 @@ function ActionsConfigTab() {
>
<h2 id="action-editor-title">
{isEditing
? `Edit ${editorKind === 'shell' ? 'shell command' : 'script'}`
? editingIsBuiltin
? `Widget settings · ${draftName}`
: `Edit ${editorKind === 'shell' ? 'shell command' : 'script'}`
: `New ${editorKind === 'shell' ? 'shell command' : 'script'}`}
</h2>
@@ -1492,12 +1539,20 @@ function ActionsConfigTab() {
</p>
) : null}
{editingIsBuiltin ? (
<p className="config-hint">
Built-in actions keep a fixed command body. You can still enable
or change the Overview widget.
</p>
) : null}
<div className="config-field">
<label htmlFor="action-name">Name</label>
<input
id="action-name"
className="config-input"
value={draftName}
disabled={fieldsLocked}
onChange={(event) => setDraftName(event.target.value)}
/>
</div>
@@ -1508,6 +1563,7 @@ function ActionsConfigTab() {
id="action-description"
className="config-input"
value={draftDescription}
disabled={fieldsLocked}
onChange={(event) => setDraftDescription(event.target.value)}
/>
</div>
@@ -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)}
/>
)}
<p className="config-hint">
Do not include a leading <code>sudo</code>; check Requires Sudo
instead so ClusterCanvas runs with <code>sudo -n</code>.
</p>
{!fieldsLocked ? (
<p className="config-hint">
Do not include a leading <code>sudo</code>; check Requires Sudo
instead so ClusterCanvas runs with <code>sudo -n</code>.
</p>
) : null}
</div>
<label className="checkbox-row">
<input
type="checkbox"
checked={draftRequiresSudo}
disabled={fieldsLocked}
onChange={(event) =>
setDraftRequiresSudo(event.target.checked)
}
@@ -1551,6 +1612,41 @@ function ActionsConfigTab() {
Requires Sudo
</label>
<label className="checkbox-row">
<input
type="checkbox"
checked={draftShowWidget}
onChange={(event) => {
const checked = event.target.checked
setDraftShowWidget(checked)
if (checked && !draftWidgetType) {
setDraftWidgetType('raw')
}
}}
/>
Show Widget
</label>
{draftShowWidget ? (
<div className="config-field">
<label htmlFor="action-widget-type">Widget type</label>
<select
id="action-widget-type"
className="config-input"
value={draftWidgetType}
onChange={(event) =>
setDraftWidgetType(event.target.value as ActionWidgetType)
}
>
<option value="memory">Memory (free -m ring)</option>
<option value="disk">Disk (df -h ring)</option>
<option value="raw">Raw text</option>
</select>
<p className="config-hint">
Parsed from the last run of this action on each node Overview.
</p>
</div>
) : null}
<div className="config-field">
<label>Environment variables</label>
<p className="config-hint">
@@ -1565,6 +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() {
<button
type="button"
className="groups-new"
disabled={fieldsLocked}
onClick={() => addEnvRow()}
>
Add ENV variable
+203 -37
View File
@@ -5,6 +5,7 @@ import {
type ActionLogFile,
type ActionLogFileInfo,
type ActionGroupRunRecord,
type ActionEnvVar,
type MeResponse,
type Node,
type NodeActionGroup,
@@ -35,6 +36,7 @@ import {
collectSudoersLines,
itemRequiresSudo,
} from './actionSudoers'
import { NodeActionWidgetsPanel } from './ActionWidgets'
import { SECTION_TITLES } from './documentTitle'
import {
type NodeDetailTabId,
@@ -132,6 +134,7 @@ function normalizeItemForCompare(item: NodeActionItem): unknown {
requires_sudo: Boolean(item.requires_sudo),
set_variable: item.set_variable || '',
run_if: item.run_if || '',
widget_label: item.widget_label || '',
}
}
@@ -447,39 +450,46 @@ function formValuesToIntervalSeconds(
function NodeDetailOverviewTab({ node }: { node: Node }) {
return (
<dl className="node-detail-overview">
<div>
<dt>Name</dt>
<dd>
<span
className={healthDotClass(node.health_ok)}
title={healthDotLabel(node.health_ok)}
aria-hidden="true"
/>{' '}
{node.name}
</dd>
</div>
<div>
<dt>Host IP</dt>
<dd>{node.host_ip}</dd>
</div>
<div>
<dt>Username</dt>
<dd>{node.username}</dd>
</div>
<div>
<dt>Group</dt>
<dd>{node.group_name}</dd>
</div>
<div>
<dt>Key algorithm</dt>
<dd>{node.key_algo}</dd>
</div>
<div>
<dt>Created</dt>
<dd>{new Date(node.created_at).toLocaleString()}</dd>
</div>
</dl>
<div className="node-detail-overview-wrap">
<dl className="node-detail-overview">
<div>
<dt>Name</dt>
<dd>
<span
className={healthDotClass(node.health_ok)}
title={healthDotLabel(node.health_ok)}
aria-hidden="true"
/>{' '}
{node.name}
</dd>
</div>
<div>
<dt>Host IP</dt>
<dd>{node.host_ip}</dd>
</div>
<div>
<dt>Username</dt>
<dd>{node.username}</dd>
</div>
<div>
<dt>Group</dt>
<dd>{node.group_name}</dd>
</div>
<div>
<dt>Key algorithm</dt>
<dd>{node.key_algo}</dd>
</div>
<div>
<dt>Created</dt>
<dd>{new Date(node.created_at).toLocaleString()}</dd>
</div>
</dl>
<section className="node-action-widgets-section" aria-label="Action widgets">
<h2 className="node-action-widgets-heading">Action widgets</h2>
<NodeActionWidgetsPanel nodeId={node.id} />
</section>
</div>
)
}
@@ -1038,6 +1048,8 @@ function ActionGroupEditor({
const [editRequiresSudo, setEditRequiresSudo] = useState(false)
const [editSetVariable, setEditSetVariable] = useState('')
const [editRunIf, setEditRunIf] = useState('')
const [editWidgetLabel, setEditWidgetLabel] = useState('')
const [editEnv, setEditEnv] = useState<ActionEnvVar[]>([])
const [resolvedPaths, setResolvedPaths] = useState<Record<string, string>>({})
const [resolveMissing, setResolveMissing] = useState<string[]>([])
const [resolveBusy, setResolveBusy] = useState(false)
@@ -1221,8 +1233,10 @@ function ActionGroupEditor({
name: draftItem.name,
body: draftItem.body,
requires_sudo: Boolean(draftItem.requires_sudo),
env: draftItem.env || [],
set_variable: draftItem.set_variable || '',
run_if: draftItem.run_if || '',
widget_label: draftItem.widget_label || '',
},
)
latest = patched.group
@@ -1241,12 +1255,16 @@ function ActionGroupEditor({
name: draftItem.name,
body: draftItem.body,
requires_sudo: Boolean(draftItem.requires_sudo),
env: draftItem.env || [],
set_variable: draftItem.set_variable || '',
run_if: draftItem.run_if || '',
widget_label: draftItem.widget_label || '',
}
: {
env: draftItem.env || [],
set_variable: draftItem.set_variable || '',
run_if: draftItem.run_if || '',
widget_label: draftItem.widget_label || '',
}
const patched = await patchActionGroupItem(
nodeId,
@@ -1268,8 +1286,10 @@ function ActionGroupEditor({
? await createActionGroupItem(nodeId, group.id, {
source: 'library',
library_action_id: draftItem.library_action_id,
env: draftItem.env || [],
set_variable: draftItem.set_variable,
run_if: draftItem.run_if,
widget_label: draftItem.widget_label,
})
: await createActionGroupItem(nodeId, group.id, {
source: 'local',
@@ -1281,6 +1301,7 @@ function ActionGroupEditor({
requires_sudo: Boolean(draftItem.requires_sudo),
set_variable: draftItem.set_variable,
run_if: draftItem.run_if,
widget_label: draftItem.widget_label,
})
latest = created.group
const createdItem = latest.items.find(
@@ -1290,15 +1311,20 @@ function ActionGroupEditor({
idMap.set(draftItem.id, createdItem.id)
if (
draftItem.source === 'library' &&
(draftItem.set_variable || draftItem.run_if)
(draftItem.set_variable ||
draftItem.run_if ||
draftItem.widget_label ||
(draftItem.env && draftItem.env.length > 0))
) {
const patched = await patchActionGroupItem(
nodeId,
group.id,
createdItem.id,
{
env: draftItem.env || [],
set_variable: draftItem.set_variable || '',
run_if: draftItem.run_if || '',
widget_label: draftItem.widget_label || '',
},
)
latest = patched.group
@@ -1353,12 +1379,19 @@ function ActionGroupEditor({
if (!libraryPick) {
return
}
const libraryAction = libraryById.get(libraryPick)
setItemsDraft((previous) => [
...previous,
{
id: newDraftItemId(),
source: 'library',
library_action_id: libraryPick,
env: libraryAction
? libraryAction.env.map((envVar) => ({ ...envVar }))
: [],
widget_label: libraryAction?.show_widget
? libraryAction.name
: undefined,
},
])
setLibraryPick('')
@@ -1458,6 +1491,7 @@ function ActionGroupEditor({
requires_sudo: libraryAction.requires_sudo || undefined,
set_variable: entry.set_variable,
run_if: entry.run_if,
widget_label: entry.widget_label,
}
: entry,
),
@@ -1465,6 +1499,12 @@ function ActionGroupEditor({
}
function handleApplyItemEdit(item: NodeActionItem) {
const normalizedEnv = editEnv
.map((row) => ({
name: row.name.trim(),
value: row.value,
}))
.filter((row) => row.name !== '' || row.value.trim() !== '')
setItemsDraft((previous) =>
previous.map((entry) => {
if (entry.id !== item.id) {
@@ -1475,21 +1515,33 @@ function ActionGroupEditor({
...entry,
name: editName,
body: editBody,
env: normalizedEnv,
requires_sudo: editRequiresSudo || undefined,
set_variable: editSetVariable.trim() || undefined,
run_if: editRunIf.trim() || undefined,
widget_label: editWidgetLabel.trim() || undefined,
}
}
return {
...entry,
env: normalizedEnv,
set_variable: editSetVariable.trim() || undefined,
run_if: editRunIf.trim() || undefined,
widget_label: editWidgetLabel.trim() || undefined,
}
}),
)
setEditingItemId(null)
}
function libraryShowsWidget(item: NodeActionItem): boolean {
if (item.source !== 'library') {
return false
}
const libraryAction = libraryById.get(item.library_action_id || '')
return Boolean(libraryAction?.show_widget)
}
async function handleRunGroup() {
if (isDirty) {
setError('Save or undo changes before running this group.')
@@ -1790,6 +1842,24 @@ function ActionGroupEditor({
setEditRequiresSudo(Boolean(item.requires_sudo))
setEditSetVariable(item.set_variable || '')
setEditRunIf(item.run_if || '')
setEditWidgetLabel(item.widget_label || '')
const libraryAction =
item.source === 'library'
? libraryById.get(item.library_action_id || '')
: undefined
const baseEnv =
item.env && item.env.length > 0
? item.env.map((envVar) => ({ ...envVar }))
: libraryAction
? libraryAction.env.map((envVar) => ({
...envVar,
}))
: []
setEditEnv(
baseEnv.length > 0
? baseEnv
: [{ name: '', value: '' }],
)
}}
>
Edit
@@ -1810,6 +1880,7 @@ function ActionGroupEditor({
</div>
{(item.set_variable ||
item.run_if ||
item.widget_label ||
itemRequiresSudo(item, libraryById)) &&
!(isEditing && editingItemId === item.id) ? (
<p className="config-hint node-action-item-vars">
@@ -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({
</>
) : (
<p className="config-hint">
Library action body is read-only. Clone to customize the
command, or edit variables below.
Library action body is read-only. Override ENV (e.g. MOUNT)
or widget label below, or clone to customize the command.
{itemRequiresSudo(item, libraryById)
? ' This library action requires sudo.'
: ''}
</p>
)}
{libraryShowsWidget(item) ? (
<label>
Widget label
<input
type="text"
placeholder="Overview title"
value={editWidgetLabel}
onChange={(event) =>
setEditWidgetLabel(event.target.value)
}
/>
</label>
) : null}
{libraryShowsWidget(item) ? (
<div className="config-field">
<span>ENV overrides</span>
<p className="config-hint">
For disk widgets, set MOUNT to the path (e.g. / or
/data).
</p>
<div className="actions-env-rows">
{editEnv.map((row, index) => (
<div
key={`item-env-${index}`}
className="actions-env-row"
>
<input
className="config-input"
aria-label={`ENV name ${index + 1}`}
placeholder="NAME"
value={row.name}
onChange={(event) => {
const value = event.target.value
setEditEnv((previous) =>
previous.map((entry, rowIndex) =>
rowIndex === index
? { ...entry, name: value }
: entry,
),
)
}}
/>
<input
className="config-input"
aria-label={`ENV value ${index + 1}`}
placeholder="value"
value={row.value}
onChange={(event) => {
const value = event.target.value
setEditEnv((previous) =>
previous.map((entry, rowIndex) =>
rowIndex === index
? { ...entry, value }
: entry,
),
)
}}
/>
<button
type="button"
className="groups-delete"
onClick={() => {
setEditEnv((previous) => {
if (previous.length <= 1) {
return [{ name: '', value: '' }]
}
return previous.filter(
(_, rowIndex) => rowIndex !== index,
)
})
}}
>
Remove
</button>
</div>
))}
</div>
<button
type="button"
className="groups-new"
onClick={() =>
setEditEnv((previous) => [
...previous,
{ name: '', value: '' },
])
}
>
Add ENV variable
</button>
</div>
) : null}
<label>
Set variable
<input
+66
View File
@@ -904,6 +904,8 @@ export async function fetchAuthLogs(
export type ActionKind = 'shell' | 'script'
export type ActionWidgetType = 'memory' | 'disk' | 'raw'
export type ActionEnvVar = {
name: string
value: string
@@ -917,6 +919,8 @@ export type Action = {
body: string
env: ReadonlyArray<ActionEnvVar>
requires_sudo: boolean
show_widget: boolean
widget_type?: ActionWidgetType | ''
builtin: boolean
created_at: string
updated_at: string
@@ -933,6 +937,8 @@ export type CreateActionRequest = {
body: string
env: ReadonlyArray<ActionEnvVar>
requires_sudo?: boolean
show_widget?: boolean
widget_type?: ActionWidgetType | ''
}
export type PatchActionRequest = {
@@ -941,6 +947,8 @@ export type PatchActionRequest = {
body?: string
env?: ReadonlyArray<ActionEnvVar>
requires_sudo?: boolean
show_widget?: boolean
widget_type?: ActionWidgetType | ''
}
export async function fetchActions(
@@ -1033,6 +1041,7 @@ export type NodeActionItem = {
requires_sudo?: boolean
set_variable?: string
run_if?: string
widget_label?: string
}
export type NodeActionGroup = {
@@ -1133,6 +1142,7 @@ export type CreateActionGroupItemRequest = {
requires_sudo?: boolean
set_variable?: string
run_if?: string
widget_label?: string
}
export type PatchActionGroupItemRequest = {
@@ -1144,6 +1154,7 @@ export type PatchActionGroupItemRequest = {
requires_sudo?: boolean
set_variable?: string
run_if?: string
widget_label?: string
}
export async function fetchActionGroups(
@@ -1359,3 +1370,58 @@ export async function fetchActionLogFile(
}
return (await response.json()) as ActionLogFileResponse
}
export type MemoryWidgetData = {
total: number
used: number
free: number
shared: number
buff_cache: number
available: number
}
export type DiskWidgetData = {
filesystem: string
size: string
used: string
avail: string
use_percent: string
mounted_on: string
size_mib?: number
used_mib?: number
avail_mib?: number
}
export type ActionWidgetSnapshot = {
node_id: string
item_id: string
group_id: string
library_action_id: string
widget_type: ActionWidgetType | ''
title: string
updated_at: string
exit_code: number
error?: string
raw_stdout: string
memory?: MemoryWidgetData
disk?: DiskWidgetData
}
export type ActionWidgetsResponse = {
widgets: ReadonlyArray<ActionWidgetSnapshot>
}
export async function fetchNodeActionWidgets(
nodeId: string,
fetchImpl: typeof fetch = fetch,
): Promise<ActionWidgetsResponse> {
const response = await apiFetch(
`/api/v1/nodes/${encodeURIComponent(nodeId)}/action-widgets`,
{},
fetchImpl,
)
if (!response.ok) {
throw new Error(await readErrorMessage(response))
}
return (await response.json()) as ActionWidgetsResponse
}