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
@@ -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)
}
}