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
+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"`