Add Show Widget for library actions with Overview charts from last run output.
This commit is contained in:
@@ -7,6 +7,7 @@ import (
|
||||
"time"
|
||||
|
||||
"codeberg.org/SquidSE/ClusterCanvas/service/internal/auth"
|
||||
"codeberg.org/SquidSE/ClusterCanvas/service/internal/runner/widgetparse"
|
||||
"codeberg.org/SquidSE/ClusterCanvas/service/internal/settings"
|
||||
)
|
||||
|
||||
@@ -126,10 +127,7 @@ func ResolveItem(item settings.NodeActionItem, library []settings.Action) (Resol
|
||||
}
|
||||
for _, action := range library {
|
||||
if action.ID == item.LibraryActionID {
|
||||
env := action.Env
|
||||
if env == nil {
|
||||
env = []settings.ActionEnvVar{}
|
||||
}
|
||||
env := mergeActionEnv(action.Env, item.Env)
|
||||
return ResolvedAction{
|
||||
Name: action.Name,
|
||||
Description: action.Description,
|
||||
@@ -170,6 +168,36 @@ func ResolveItem(item settings.NodeActionItem, library []settings.Action) (Resol
|
||||
}
|
||||
}
|
||||
|
||||
// mergeActionEnv returns library env with item overrides (item wins on name clash).
|
||||
func mergeActionEnv(
|
||||
libraryEnv []settings.ActionEnvVar,
|
||||
itemEnv []settings.ActionEnvVar,
|
||||
) []settings.ActionEnvVar {
|
||||
merged := make([]settings.ActionEnvVar, 0, len(libraryEnv)+len(itemEnv))
|
||||
indexByName := map[string]int{}
|
||||
for _, envVar := range libraryEnv {
|
||||
name := strings.TrimSpace(envVar.Name)
|
||||
if name == "" {
|
||||
continue
|
||||
}
|
||||
indexByName[name] = len(merged)
|
||||
merged = append(merged, settings.ActionEnvVar{Name: name, Value: envVar.Value})
|
||||
}
|
||||
for _, envVar := range itemEnv {
|
||||
name := strings.TrimSpace(envVar.Name)
|
||||
if name == "" {
|
||||
continue
|
||||
}
|
||||
if index, exists := indexByName[name]; exists {
|
||||
merged[index].Value = envVar.Value
|
||||
continue
|
||||
}
|
||||
indexByName[name] = len(merged)
|
||||
merged = append(merged, settings.ActionEnvVar{Name: name, Value: envVar.Value})
|
||||
}
|
||||
return merged
|
||||
}
|
||||
|
||||
// WrapShellWithSudo prepends "sudo -n " when requiresSudo is true.
|
||||
func WrapShellWithSudo(command string, requiresSudo bool) string {
|
||||
if !requiresSudo {
|
||||
@@ -390,6 +418,17 @@ func (executor *Executor) runItems(
|
||||
itemResult.VariableValue = captured
|
||||
}
|
||||
results = append(results, itemResult)
|
||||
|
||||
if item.Source == settings.ActionItemSourceLibrary {
|
||||
_ = persistWidgetSnapshot(
|
||||
executor.ConfigDir,
|
||||
group,
|
||||
item,
|
||||
libraryStore.Actions,
|
||||
itemResult,
|
||||
envMap,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
finishedAt := time.Now().UTC()
|
||||
@@ -447,3 +486,68 @@ func (executor *Executor) loadNodeCredentials(nodeID string) (settings.Node, str
|
||||
}
|
||||
return settings.Node{}, "", "", fmt.Errorf("node private key not found")
|
||||
}
|
||||
|
||||
func persistWidgetSnapshot(
|
||||
configDir string,
|
||||
group settings.NodeActionGroup,
|
||||
item settings.NodeActionItem,
|
||||
library []settings.Action,
|
||||
itemResult settings.ActionItemRunResult,
|
||||
envMap map[string]string,
|
||||
) error {
|
||||
var libraryAction *settings.Action
|
||||
for index := range library {
|
||||
if library[index].ID == item.LibraryActionID {
|
||||
libraryAction = &library[index]
|
||||
break
|
||||
}
|
||||
}
|
||||
if libraryAction == nil || !libraryAction.ShowWidget {
|
||||
return nil
|
||||
}
|
||||
widgetType := libraryAction.WidgetType
|
||||
if widgetType == settings.ActionWidgetTypeNone {
|
||||
widgetType = settings.ActionWidgetTypeRaw
|
||||
}
|
||||
|
||||
title := strings.TrimSpace(item.WidgetLabel)
|
||||
if title == "" {
|
||||
title = libraryAction.Name
|
||||
if mount := strings.TrimSpace(envMap["MOUNT"]); mount != "" && widgetType == settings.ActionWidgetTypeDisk {
|
||||
title = fmt.Sprintf("%s (%s)", libraryAction.Name, mount)
|
||||
}
|
||||
}
|
||||
|
||||
snapshot := settings.ActionWidgetSnapshot{
|
||||
NodeID: group.NodeID,
|
||||
ItemID: item.ID,
|
||||
GroupID: group.ID,
|
||||
LibraryActionID: item.LibraryActionID,
|
||||
WidgetType: widgetType,
|
||||
Title: title,
|
||||
UpdatedAt: itemResult.FinishedAt,
|
||||
ExitCode: itemResult.ExitCode,
|
||||
Error: itemResult.Error,
|
||||
RawStdout: itemResult.Stdout,
|
||||
}
|
||||
|
||||
if itemResult.Skipped {
|
||||
snapshot.Error = itemResult.SkipReason
|
||||
return settings.UpsertActionWidgetSnapshot(configDir, snapshot)
|
||||
}
|
||||
|
||||
mountHint := strings.TrimSpace(envMap["MOUNT"])
|
||||
if itemResult.ExitCode == 0 && itemResult.Error == "" {
|
||||
memory, disk, parseErr := widgetparse.ParseStdout(widgetType, itemResult.Stdout, mountHint)
|
||||
if parseErr != nil {
|
||||
snapshot.Error = parseErr.Error()
|
||||
} else {
|
||||
snapshot.Memory = memory
|
||||
snapshot.Disk = disk
|
||||
}
|
||||
} else if snapshot.Error == "" && itemResult.ExitCode != 0 {
|
||||
snapshot.Error = fmt.Sprintf("exit code %d", itemResult.ExitCode)
|
||||
}
|
||||
|
||||
return settings.UpsertActionWidgetSnapshot(configDir, snapshot)
|
||||
}
|
||||
|
||||
@@ -15,6 +15,16 @@ func TestResolveItemLibraryAndLocal(t *testing.T) {
|
||||
Body: "uptime",
|
||||
Env: []settings.ActionEnvVar{},
|
||||
},
|
||||
{
|
||||
ID: settings.BuiltinActionDiskUsageID,
|
||||
Name: "Disk usage",
|
||||
Kind: settings.ActionKindShell,
|
||||
Body: "df -h {{env.MOUNT}}",
|
||||
Env: []settings.ActionEnvVar{
|
||||
{Name: "MOUNT", Value: "/"},
|
||||
{Name: "EXTRA", Value: "lib"},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
resolved, err := ResolveItem(settings.NodeActionItem{
|
||||
@@ -29,6 +39,32 @@ func TestResolveItemLibraryAndLocal(t *testing.T) {
|
||||
t.Fatalf("resolved = %#v", resolved)
|
||||
}
|
||||
|
||||
merged, err := ResolveItem(settings.NodeActionItem{
|
||||
ID: "i-disk",
|
||||
Source: settings.ActionItemSourceLibrary,
|
||||
LibraryActionID: settings.BuiltinActionDiskUsageID,
|
||||
Env: []settings.ActionEnvVar{
|
||||
{Name: "MOUNT", Value: "/data"},
|
||||
{Name: "ITEM_ONLY", Value: "yes"},
|
||||
},
|
||||
}, library)
|
||||
if err != nil {
|
||||
t.Fatalf("ResolveItem merge: %v", err)
|
||||
}
|
||||
envByName := map[string]string{}
|
||||
for _, envVar := range merged.Env {
|
||||
envByName[envVar.Name] = envVar.Value
|
||||
}
|
||||
if envByName["MOUNT"] != "/data" {
|
||||
t.Fatalf("MOUNT override = %#v", merged.Env)
|
||||
}
|
||||
if envByName["EXTRA"] != "lib" {
|
||||
t.Fatalf("library EXTRA lost: %#v", merged.Env)
|
||||
}
|
||||
if envByName["ITEM_ONLY"] != "yes" {
|
||||
t.Fatalf("item env missing: %#v", merged.Env)
|
||||
}
|
||||
|
||||
local, err := ResolveItem(settings.NodeActionItem{
|
||||
ID: "i2",
|
||||
Source: settings.ActionItemSourceLocal,
|
||||
|
||||
@@ -0,0 +1,201 @@
|
||||
package widgetparse
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"codeberg.org/SquidSE/ClusterCanvas/service/internal/settings"
|
||||
)
|
||||
|
||||
var (
|
||||
whitespaceSplit = regexp.MustCompile(`\s+`)
|
||||
sizeUnitPattern = regexp.MustCompile(`(?i)^([0-9]*\.?[0-9]+)([KMGTP]?i?B?)$`)
|
||||
)
|
||||
|
||||
// ParseStdout parses action stdout into typed widget data for the given widget type.
|
||||
// mountHint selects a df row when widgetType is disk (e.g. "/" or "/data").
|
||||
func ParseStdout(
|
||||
widgetType settings.ActionWidgetType,
|
||||
stdout string,
|
||||
mountHint string,
|
||||
) (memory *settings.MemoryWidgetData, disk *settings.DiskWidgetData, parseErr error) {
|
||||
switch widgetType {
|
||||
case settings.ActionWidgetTypeMemory:
|
||||
data, err := ParseFreeM(stdout)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
return &data, nil, nil
|
||||
case settings.ActionWidgetTypeDisk:
|
||||
data, err := ParseDfH(stdout, mountHint)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
return nil, &data, nil
|
||||
case settings.ActionWidgetTypeRaw:
|
||||
return nil, nil, nil
|
||||
default:
|
||||
return nil, nil, fmt.Errorf("unsupported widget type %q", widgetType)
|
||||
}
|
||||
}
|
||||
|
||||
// ParseFreeM parses `free -m` output and returns the Mem: line figures.
|
||||
func ParseFreeM(stdout string) (settings.MemoryWidgetData, error) {
|
||||
lines := strings.Split(stdout, "\n")
|
||||
for _, line := range lines {
|
||||
trimmed := strings.TrimSpace(line)
|
||||
if trimmed == "" {
|
||||
continue
|
||||
}
|
||||
fields := whitespaceSplit.Split(trimmed, -1)
|
||||
if len(fields) < 4 {
|
||||
continue
|
||||
}
|
||||
if !strings.EqualFold(strings.TrimSuffix(fields[0], ":"), "Mem") {
|
||||
continue
|
||||
}
|
||||
total, err := strconv.ParseInt(fields[1], 10, 64)
|
||||
if err != nil {
|
||||
return settings.MemoryWidgetData{}, fmt.Errorf("parse free total: %w", err)
|
||||
}
|
||||
used, err := strconv.ParseInt(fields[2], 10, 64)
|
||||
if err != nil {
|
||||
return settings.MemoryWidgetData{}, fmt.Errorf("parse free used: %w", err)
|
||||
}
|
||||
free, err := strconv.ParseInt(fields[3], 10, 64)
|
||||
if err != nil {
|
||||
return settings.MemoryWidgetData{}, fmt.Errorf("parse free free: %w", err)
|
||||
}
|
||||
data := settings.MemoryWidgetData{
|
||||
Total: total,
|
||||
Used: used,
|
||||
Free: free,
|
||||
}
|
||||
if len(fields) > 4 {
|
||||
shared, err := strconv.ParseInt(fields[4], 10, 64)
|
||||
if err != nil {
|
||||
return settings.MemoryWidgetData{}, fmt.Errorf("parse free shared: %w", err)
|
||||
}
|
||||
data.Shared = shared
|
||||
}
|
||||
if len(fields) > 5 {
|
||||
buffCache, err := strconv.ParseInt(fields[5], 10, 64)
|
||||
if err != nil {
|
||||
return settings.MemoryWidgetData{}, fmt.Errorf("parse free buff/cache: %w", err)
|
||||
}
|
||||
data.BuffCache = buffCache
|
||||
}
|
||||
if len(fields) > 6 {
|
||||
available, err := strconv.ParseInt(fields[6], 10, 64)
|
||||
if err != nil {
|
||||
return settings.MemoryWidgetData{}, fmt.Errorf("parse free available: %w", err)
|
||||
}
|
||||
data.Available = available
|
||||
}
|
||||
return data, nil
|
||||
}
|
||||
return settings.MemoryWidgetData{}, fmt.Errorf("no Mem line found in free output")
|
||||
}
|
||||
|
||||
// ParseDfH parses `df -h` output. When mountHint is non-empty, prefers a row whose
|
||||
// Mounted on column matches that path; otherwise uses the first data row.
|
||||
func ParseDfH(stdout string, mountHint string) (settings.DiskWidgetData, error) {
|
||||
lines := strings.Split(stdout, "\n")
|
||||
var dataRows []settings.DiskWidgetData
|
||||
for _, line := range lines {
|
||||
trimmed := strings.TrimSpace(line)
|
||||
if trimmed == "" {
|
||||
continue
|
||||
}
|
||||
fields := whitespaceSplit.Split(trimmed, -1)
|
||||
if len(fields) < 6 {
|
||||
continue
|
||||
}
|
||||
if strings.EqualFold(fields[0], "Filesystem") {
|
||||
continue
|
||||
}
|
||||
// df wraps long filesystem names; prefer last 5 columns as Size Used Avail Use% Mounted.
|
||||
mountedOn := fields[len(fields)-1]
|
||||
usePercent := fields[len(fields)-2]
|
||||
avail := fields[len(fields)-3]
|
||||
used := fields[len(fields)-4]
|
||||
size := fields[len(fields)-5]
|
||||
filesystem := strings.Join(fields[:len(fields)-5], " ")
|
||||
if filesystem == "" {
|
||||
continue
|
||||
}
|
||||
row := settings.DiskWidgetData{
|
||||
Filesystem: filesystem,
|
||||
Size: size,
|
||||
Used: used,
|
||||
Avail: avail,
|
||||
UsePercent: usePercent,
|
||||
MountedOn: mountedOn,
|
||||
SizeMiB: parseSizeToMiB(size),
|
||||
UsedMiB: parseSizeToMiB(used),
|
||||
AvailMiB: parseSizeToMiB(avail),
|
||||
}
|
||||
dataRows = append(dataRows, row)
|
||||
}
|
||||
if len(dataRows) == 0 {
|
||||
return settings.DiskWidgetData{}, fmt.Errorf("no df data rows found")
|
||||
}
|
||||
|
||||
hint := strings.TrimSpace(mountHint)
|
||||
if hint != "" {
|
||||
for _, row := range dataRows {
|
||||
if row.MountedOn == hint {
|
||||
return row, nil
|
||||
}
|
||||
}
|
||||
// Also accept trailing-slash normalized match.
|
||||
hintTrim := strings.TrimSuffix(hint, "/")
|
||||
if hintTrim == "" {
|
||||
hintTrim = "/"
|
||||
}
|
||||
for _, row := range dataRows {
|
||||
mounted := strings.TrimSuffix(row.MountedOn, "/")
|
||||
if mounted == "" {
|
||||
mounted = "/"
|
||||
}
|
||||
if mounted == hintTrim {
|
||||
return row, nil
|
||||
}
|
||||
}
|
||||
}
|
||||
return dataRows[0], nil
|
||||
}
|
||||
|
||||
func parseSizeToMiB(raw string) int64 {
|
||||
trimmed := strings.TrimSpace(raw)
|
||||
if trimmed == "" || trimmed == "-" {
|
||||
return 0
|
||||
}
|
||||
matches := sizeUnitPattern.FindStringSubmatch(trimmed)
|
||||
if matches == nil {
|
||||
return 0
|
||||
}
|
||||
value, err := strconv.ParseFloat(matches[1], 64)
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
unit := strings.ToUpper(matches[2])
|
||||
switch unit {
|
||||
case "", "B":
|
||||
return int64(value / (1024 * 1024))
|
||||
case "K", "KB", "KIB":
|
||||
return int64(value / 1024)
|
||||
case "M", "MB", "MIB":
|
||||
return int64(value)
|
||||
case "G", "GB", "GIB":
|
||||
return int64(value * 1024)
|
||||
case "T", "TB", "TIB":
|
||||
return int64(value * 1024 * 1024)
|
||||
case "P", "PB", "PIB":
|
||||
return int64(value * 1024 * 1024 * 1024)
|
||||
default:
|
||||
return 0
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
package widgetparse
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"codeberg.org/SquidSE/ClusterCanvas/service/internal/settings"
|
||||
)
|
||||
|
||||
func TestParseFreeM(t *testing.T) {
|
||||
stdout := ` total used free shared buff/cache available
|
||||
Mem: 16014 4521 2341 412 9151 11012
|
||||
Swap: 8191 0 8191
|
||||
`
|
||||
data, err := ParseFreeM(stdout)
|
||||
if err != nil {
|
||||
t.Fatalf("ParseFreeM: %v", err)
|
||||
}
|
||||
if data.Total != 16014 || data.Used != 4521 || data.Free != 2341 {
|
||||
t.Fatalf("core fields = %+v", data)
|
||||
}
|
||||
if data.Shared != 412 || data.BuffCache != 9151 || data.Available != 11012 {
|
||||
t.Fatalf("extra fields = %+v", data)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseDfHPrefersMount(t *testing.T) {
|
||||
stdout := `Filesystem Size Used Avail Use% Mounted on
|
||||
/dev/sda1 100G 40G 55G 42% /
|
||||
/dev/sdb1 200G 10G 180G 6% /data
|
||||
`
|
||||
data, err := ParseDfH(stdout, "/data")
|
||||
if err != nil {
|
||||
t.Fatalf("ParseDfH: %v", err)
|
||||
}
|
||||
if data.MountedOn != "/data" || data.Size != "200G" || data.Used != "10G" {
|
||||
t.Fatalf("row = %+v", data)
|
||||
}
|
||||
if data.SizeMiB != 200*1024 || data.UsedMiB != 10*1024 {
|
||||
t.Fatalf("mib = %+v", data)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseDfHFirstRow(t *testing.T) {
|
||||
stdout := `Filesystem Size Used Avail Use% Mounted on
|
||||
tmpfs 1.6G 0 1.6G 0% /run
|
||||
/dev/sda1 100G 40G 55G 42% /
|
||||
`
|
||||
data, err := ParseDfH(stdout, "")
|
||||
if err != nil {
|
||||
t.Fatalf("ParseDfH: %v", err)
|
||||
}
|
||||
if data.MountedOn != "/run" {
|
||||
t.Fatalf("expected first row, got %+v", data)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseStdoutMemory(t *testing.T) {
|
||||
stdout := "Mem: 100 40 20 1 39 50\n"
|
||||
memory, disk, err := ParseStdout(settings.ActionWidgetTypeMemory, stdout, "")
|
||||
if err != nil {
|
||||
t.Fatalf("ParseStdout: %v", err)
|
||||
}
|
||||
if memory == nil || disk != nil || memory.Total != 100 {
|
||||
t.Fatalf("memory=%v disk=%v", memory, disk)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user