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