diff --git a/.commitlintrc.js b/.commitlintrc.js index 84dcb12..0025947 100644 --- a/.commitlintrc.js +++ b/.commitlintrc.js @@ -1,3 +1,23 @@ module.exports = { extends: ['@commitlint/config-conventional'], + rules: { + 'body-max-line-length': [0], + 'type-enum': [2, 'always', [ + 'build', + 'chore', + 'ci', + 'docs', + 'wiki', + 'feat', + 'fix', + 'perf', + 'refactor', + 'revert', + 'style', + 'test', + 'vibe' + 'plan' + 'vision', + ]], + }, }; diff --git a/README.md b/README.md index 859e304..4016519 100644 --- a/README.md +++ b/README.md @@ -48,9 +48,20 @@ export PSSTD_GOSSIP=":7947" # peer sync listen address, de export PSSTD_SEEDS="10.0.1.20:7946,10.0.1.21:7946" # explicit peer sync addresses export PSSTD_DB="./data" # local state directory export PSSTD_WEB="true" # set false for sync-only nodes +export PSSTD_NODE_NAME="rack-a-01" # optional stable node identity override +export PSSTD_NODE_TTL="15s" # how long this node's heartbeat stays online ./psstd ``` +By default, psstd uses the OS hostname as the node identity. Set +`PSSTD_NODE_NAME` for cloned hosts, containers, or multiple test instances that +would otherwise publish the same hostname. The override must be non-empty and +must not contain whitespace. + +Each node publishes its own heartbeat TTL with `PSSTD_NODE_TTL`. Shorter values +make stale/offline indication react faster; longer values are better for slow or +lossy networks. The default is `15s`, and values must be at least `2s`. + ## Discovery | Environment | How nodes find each other | diff --git a/discovery.go b/discovery.go index c2730e5..a3b6209 100644 --- a/discovery.go +++ b/discovery.go @@ -63,7 +63,6 @@ func discoverPeers() []string { continue } peer := fmt.Sprintf("%s:%d", addr.String(), entry.Port) - log.Printf("mDNS: discovered peer %s (%s)", entry.Name, peer) peers = append(peers, peer) } return peers diff --git a/gossip.go b/gossip.go index 1bbb0f4..6fce1ea 100644 --- a/gossip.go +++ b/gossip.go @@ -5,7 +5,6 @@ import ( "errors" "log" "sync" - "time" "github.com/cockroachdb/pebble/v2" "github.com/hashicorp/memberlist" @@ -14,14 +13,15 @@ import ( var appVersion = "dev" type NodeStats struct { - Name string `json:"name"` - Version string `json:"version,omitempty"` - WebURL string `json:"web,omitempty"` - CPU []float64 `json:"cpu"` - MemUsed uint64 `json:"mu"` - MemTotal uint64 `json:"mt"` - Load [3]float64 `json:"ld"` - UpdatedAt int64 `json:"ts"` // unix nano, LWW key + Name string `json:"name"` + Version string `json:"version,omitempty"` + WebURL string `json:"web,omitempty"` + TTLSeconds int `json:"ttl,omitempty"` + CPU []float64 `json:"cpu"` + MemUsed uint64 `json:"mu"` + MemTotal uint64 `json:"mt"` + Load [3]float64 `json:"ld"` + UpdatedAt int64 `json:"ts"` // unix nano, LWW key } var errStaleVersion = errors.New("stale version") @@ -107,7 +107,7 @@ func purgeOfflineDifferentVersion(db *pebble.DB, version string) error { } func nodeRecordOffline(s NodeStats) bool { - return s.UpdatedAt == 0 || time.Since(time.Unix(0, s.UpdatedAt)) > 15*time.Second + return nodeHealth(s).State == healthOffline } // ── Delegate ────────────────────────────────────────────────────────────────── diff --git a/main.go b/main.go index 6c7ae25..0576ef5 100644 --- a/main.go +++ b/main.go @@ -2,6 +2,7 @@ package main import ( "encoding/json" + "flag" "fmt" "log" "net" @@ -15,19 +16,34 @@ import ( "github.com/hashicorp/memberlist" ) +type cliOptions struct { + List bool +} + const ( - envDB = "PSSTD_DB" - envHTTP = "PSSTD_HTTP" - envGossip = "PSSTD_GOSSIP" - envSeeds = "PSSTD_SEEDS" - envHTTPAd = "PSSTD_ADVERTISE_HTTP" - envWeb = "PSSTD_WEB" // "true" to enable HTTP, default true - gossipPort = 7946 - httpPort = 8080 + envDB = "PSSTD_DB" + envHTTP = "PSSTD_HTTP" + envGossip = "PSSTD_GOSSIP" + envSeeds = "PSSTD_SEEDS" + envHTTPAd = "PSSTD_ADVERTISE_HTTP" + envWeb = "PSSTD_WEB" // "true" to enable HTTP, default true + envNodeName = "PSSTD_NODE_NAME" + envNodeTTL = "PSSTD_NODE_TTL" + gossipPort = 7946 + httpPort = 8080 ) func main() { + opts := parseCLI(os.Args[1:]) hostname, _ := os.Hostname() + nodeName, err := nodeNameFromEnv(hostname) + if err != nil { + log.Fatalf("node name: %v", err) + } + nodeTTL, err := nodeTTLFromEnv() + if err != nil { + log.Fatalf("node ttl: %v", err) + } dbPath := envOr(envDB, "./data") httpAddr := envOr(envHTTP, fmt.Sprintf(":%d", httpPort)) @@ -47,7 +63,7 @@ func main() { if err != nil { if pebbleLockHeld(err) { log.Printf("psstd already appears to own %s; starting terminal mirror instead", dbPath) - runTerminalMirror(hostname, gossipAddr, seeds) + runTerminalMirror(nodeName, gossipAddr, seeds, opts.List) return } log.Fatalf("pebble open: %v", err) @@ -65,7 +81,7 @@ func main() { delegate := newKVDelegate(db, appVersion) cfg := memberlist.DefaultLANConfig() - cfg.Name = hostname + cfg.Name = nodeName cfg.BindAddr, cfg.BindPort = splitHostPort(gossipAddr) cfg.Delegate = delegate cfg.Events = newEventDelegate(db, appVersion) @@ -79,7 +95,7 @@ func main() { } db = nil log.Printf("psstd already appears to be listening on %s; starting terminal mirror instead", gossipAddr) - runTerminalMirror(hostname, gossipAddr, seeds) + runTerminalMirror(nodeName, gossipAddr, seeds, opts.List) return } log.Fatalf("memberlist create: %v", err) @@ -88,39 +104,87 @@ func main() { // ── Discovery ──────────────────────────────────────────────────────────── // 1. Register ourselves via mDNS so peers can find us on LAN - stopMDNS := registerMDNS(hostname, cfg.BindPort) + stopMDNS := registerMDNS(nodeName, cfg.BindPort) defer stopMDNS() // 2. Scan for existing peers (mDNS + any explicit seeds) discovered := discoverPeers() allSeeds := append(seeds, discovered...) + joinedPeers := 0 + joinErr := error(nil) if len(allSeeds) > 0 { - if n, err := list.Join(allSeeds); err != nil { - log.Printf("join warning (joined %d): %v", n, err) - } else { - log.Printf("joined cluster, %d peer(s)", n) - } - } else { - log.Println("no peers found — running solo, will be discovered by others") + joinedPeers, joinErr = list.Join(allSeeds) } + logStartupConfig(startupConfig{ + NodeName: nodeName, + DBPath: dbPath, + HTTPAddr: httpAddr, + WebURL: webURL, + GossipAddr: gossipAddr, + WebEnabled: webEnabled, + Version: appVersion, + NodeTTL: nodeTTL, + SeedCount: len(seeds), + MDNSCount: len(discovered), + JoinedPeers: joinedPeers, + JoinErr: joinErr, + }) // ── Stats heartbeat ───────────────────────────────────────────────────── - go statsLoop(hostname, webURL, appVersion, db, delegate) + go statsLoop(nodeName, webURL, appVersion, nodeTTL, db, delegate) // ── HTTP ───────────────────────────────────────────────────────────────── if webEnabled { mux := http.NewServeMux() - mux.HandleFunc("/", makeHandler(db, hostname)) - log.Printf("psstd version=%s node=%s http=%s advertise=%s gossip=%s web=true", appVersion, hostname, httpAddr, webURL, gossipAddr) + mux.HandleFunc("/", makeHandler(db, nodeName)) if err := http.ListenAndServe(httpAddr, mux); err != nil { log.Fatalf("http: %v", err) } } else { - log.Printf("psstd version=%s node=%s gossip=%s web=false", appVersion, hostname, gossipAddr) select {} // block forever } } +func parseCLI(args []string) cliOptions { + fs := flag.NewFlagSet("psstd", flag.ExitOnError) + fs.SetOutput(os.Stderr) + var opts cliOptions + fs.BoolVar(&opts.List, "l", false, "render terminal mirror as a vertical node list") + fs.BoolVar(&opts.List, "list", false, "render terminal mirror as a vertical node list") + _ = fs.Parse(args) + return opts +} + +type startupConfig struct { + NodeName string + DBPath string + HTTPAddr string + WebURL string + GossipAddr string + WebEnabled bool + Version string + NodeTTL time.Duration + SeedCount int + MDNSCount int + JoinedPeers int + JoinErr error +} + +func logStartupConfig(cfg startupConfig) { + log.Print(startupSummary(cfg)) +} + +func startupSummary(cfg startupConfig) string { + join := "solo" + if cfg.JoinErr != nil { + join = fmt.Sprintf("warning joined=%d error=%q", cfg.JoinedPeers, cfg.JoinErr) + } else if cfg.JoinedPeers > 0 { + join = fmt.Sprintf("joined=%d", cfg.JoinedPeers) + } + return fmt.Sprintf("psstd startup: version=%s node=%s db=%s web=%t http=%s advertise=%s gossip=%s ttl=%s seeds=%d mdns=%d join=%s", + cfg.Version, cfg.NodeName, cfg.DBPath, cfg.WebEnabled, cfg.HTTPAddr, cfg.WebURL, cfg.GossipAddr, cfg.NodeTTL, cfg.SeedCount, cfg.MDNSCount, join) +} + func pebbleLockHeld(err error) bool { msg := strings.ToLower(err.Error()) return strings.Contains(msg, "lock") && @@ -137,7 +201,7 @@ func addressInUse(err error) bool { strings.Contains(msg, "bind: only one usage of each socket address") } -func runTerminalMirror(hostname, gossipAddr string, seeds []string) { +func runTerminalMirror(hostname, gossipAddr string, seeds []string, listMode bool) { tmpDir, err := os.MkdirTemp("", "psstd-view-*") if err != nil { log.Fatalf("terminal mirror temp db: %v", err) @@ -172,7 +236,7 @@ func runTerminalMirror(hostname, gossipAddr string, seeds []string) { log.Printf("terminal mirror joined cluster, %d peer(s)", n) } - terminalRenderLoop(db) + terminalRenderLoop(db, listMode) } func terminalMirrorSeeds(gossipAddr string, seeds []string) []string { @@ -188,11 +252,11 @@ func terminalMirrorSeeds(gossipAddr string, seeds []string) []string { // ── Stats loop ─────────────────────────────────────────────────────────────── -func statsLoop(hostname, webURL, version string, db *pebble.DB, d *kvDelegate) { +func statsLoop(hostname, webURL, version string, ttl time.Duration, db *pebble.DB, d *kvDelegate) { ticker := time.NewTicker(2 * time.Second) defer ticker.Stop() for range ticker.C { - stats, err := collectStats(hostname, webURL, version) + stats, err := collectStats(hostname, webURL, version, ttl) if err != nil { log.Printf("stats error: %v", err) continue @@ -255,6 +319,38 @@ func envOr(key, def string) string { return def } +func nodeNameFromEnv(hostname string) (string, error) { + override, ok := os.LookupEnv(envNodeName) + if !ok || override == "" { + if strings.TrimSpace(hostname) == "" { + return "", fmt.Errorf("hostname is empty; set %s", envNodeName) + } + return hostname, nil + } + if strings.TrimSpace(override) != override || override == "" { + return "", fmt.Errorf("%s must not be empty or have leading/trailing whitespace", envNodeName) + } + if strings.ContainsAny(override, " \t\r\n") { + return "", fmt.Errorf("%s must not contain whitespace", envNodeName) + } + return override, nil +} + +func nodeTTLFromEnv() (time.Duration, error) { + value, ok := os.LookupEnv(envNodeTTL) + if !ok || value == "" { + return defaultNodeTTL, nil + } + ttl, err := time.ParseDuration(value) + if err != nil { + return 0, fmt.Errorf("%s must be a duration such as 15s or 1m: %w", envNodeTTL, err) + } + if ttl < 2*time.Second { + return 0, fmt.Errorf("%s must be at least 2s", envNodeTTL) + } + return ttl, nil +} + func splitCSV(s string) []string { var out []string for _, p := range strings.Split(s, ",") { diff --git a/main_test.go b/main_test.go new file mode 100644 index 0000000..f37d62b --- /dev/null +++ b/main_test.go @@ -0,0 +1,133 @@ +package main + +import ( + "errors" + "os" + "strings" + "testing" + "time" +) + +func withoutNodeNameEnv(t *testing.T) { + t.Helper() + old, hadOld := os.LookupEnv(envNodeName) + if err := os.Unsetenv(envNodeName); err != nil { + t.Fatalf("unset %s: %v", envNodeName, err) + } + t.Cleanup(func() { + if hadOld { + _ = os.Setenv(envNodeName, old) + } else { + _ = os.Unsetenv(envNodeName) + } + }) +} + +func TestNodeNameFromEnvUsesHostnameByDefault(t *testing.T) { + withoutNodeNameEnv(t) + + got, err := nodeNameFromEnv("host-a") + if err != nil { + t.Fatalf("nodeNameFromEnv: %v", err) + } + if got != "host-a" { + t.Fatalf("name = %q, want host-a", got) + } +} + +func TestNodeNameFromEnvUsesOverride(t *testing.T) { + t.Setenv(envNodeName, "test-node") + + got, err := nodeNameFromEnv("host-a") + if err != nil { + t.Fatalf("nodeNameFromEnv: %v", err) + } + if got != "test-node" { + t.Fatalf("name = %q, want test-node", got) + } +} + +func TestNodeNameFromEnvRejectsWhitespaceOverride(t *testing.T) { + for _, value := range []string{" test-node", "test-node ", "test node", "\t"} { + t.Run(value, func(t *testing.T) { + t.Setenv(envNodeName, value) + if got, err := nodeNameFromEnv("host-a"); err == nil { + t.Fatalf("name = %q, want error", got) + } + }) + } +} + +func TestNodeTTLFromEnvDefaultAndOverride(t *testing.T) { + t.Setenv(envNodeTTL, "") + got, err := nodeTTLFromEnv() + if err != nil { + t.Fatalf("default ttl: %v", err) + } + if got != defaultNodeTTL { + t.Fatalf("default ttl = %s, want %s", got, defaultNodeTTL) + } + + t.Setenv(envNodeTTL, "45s") + got, err = nodeTTLFromEnv() + if err != nil { + t.Fatalf("override ttl: %v", err) + } + if got != 45*time.Second { + t.Fatalf("override ttl = %s, want 45s", got) + } +} + +func TestNodeTTLFromEnvRejectsInvalidValues(t *testing.T) { + for _, value := range []string{"soon", "1s"} { + t.Run(value, func(t *testing.T) { + t.Setenv(envNodeTTL, value) + if got, err := nodeTTLFromEnv(); err == nil { + t.Fatalf("ttl = %s, want error", got) + } + }) + } +} + +func TestStartupSummaryIncludesJoinOutcome(t *testing.T) { + base := startupConfig{ + Version: "v1", + NodeName: "node-a", + DBPath: "./data", + HTTPAddr: ":8080", + WebURL: "http://node-a:8080", + GossipAddr: ":7946", + WebEnabled: true, + NodeTTL: 15 * time.Second, + SeedCount: 2, + MDNSCount: 1, + } + + joined := base + joined.JoinedPeers = 3 + if got := startupSummary(joined); !strings.Contains(got, "join=joined=3") { + t.Fatalf("joined summary missing outcome: %s", got) + } + + solo := base + if got := startupSummary(solo); !strings.Contains(got, "join=solo") { + t.Fatalf("solo summary missing outcome: %s", got) + } + + warn := base + warn.JoinedPeers = 1 + warn.JoinErr = errors.New("partial join") + if got := startupSummary(warn); !strings.Contains(got, `join=warning joined=1 error="partial join"`) { + t.Fatalf("warning summary missing outcome: %s", got) + } +} + +func TestParseCLIListFlag(t *testing.T) { + for _, args := range [][]string{{"-l"}, {"--list"}} { + t.Run(strings.Join(args, " "), func(t *testing.T) { + if opts := parseCLI(args); !opts.List { + t.Fatalf("List = false, want true") + } + }) + } +} diff --git a/render.go b/render.go index baf5d1b..f6061f5 100644 --- a/render.go +++ b/render.go @@ -21,6 +21,8 @@ import ( "github.com/cockroachdb/pebble/v2" ) +const defaultNodeTTL = 15 * time.Second + //go:embed templates/dashboard.html var templateFS embed.FS @@ -34,7 +36,7 @@ func init() { // ── Stats collection ────────────────────────────────────────────────────────── -func collectStats(hostname, webURL, version string) (NodeStats, error) { +func collectStats(hostname, webURL, version string, ttl time.Duration) (NodeStats, error) { cpuPcts, err := cpu.Percent(200*time.Millisecond, true) if err != nil { return NodeStats{}, err @@ -48,14 +50,15 @@ func collectStats(hostname, webURL, version string) (NodeStats, error) { return NodeStats{}, err } return NodeStats{ - Name: hostname, - Version: version, - WebURL: webURL, - CPU: cpuPcts, - MemUsed: vmStat.Used, - MemTotal: vmStat.Total, - Load: [3]float64{loadStat.Load1, loadStat.Load5, loadStat.Load15}, - UpdatedAt: time.Now().UnixNano(), + Name: hostname, + Version: version, + WebURL: webURL, + TTLSeconds: int(ttl / time.Second), + CPU: cpuPcts, + MemUsed: vmStat.Used, + MemTotal: vmStat.Total, + Load: [3]float64{loadStat.Load1, loadStat.Load5, loadStat.Load15}, + UpdatedAt: time.Now().UnixNano(), }, nil } @@ -91,6 +94,46 @@ var ( } ) +type healthState string + +const ( + healthFresh healthState = "fresh" + healthStale healthState = "stale" + healthOffline healthState = "offline" +) + +type nodeHealthInfo struct { + State healthState + Age time.Duration + TTL time.Duration +} + +func nodeTTL(s NodeStats) time.Duration { + if s.TTLSeconds > 0 { + return time.Duration(s.TTLSeconds) * time.Second + } + return defaultNodeTTL +} + +func nodeHealth(s NodeStats) nodeHealthInfo { + ttl := nodeTTL(s) + if s.UpdatedAt == 0 { + return nodeHealthInfo{State: healthOffline, TTL: ttl} + } + age := time.Since(time.Unix(0, s.UpdatedAt)) + if age > ttl { + return nodeHealthInfo{State: healthOffline, Age: age, TTL: ttl} + } + if age > ttl/2 { + return nodeHealthInfo{State: healthStale, Age: age, TTL: ttl} + } + return nodeHealthInfo{State: healthFresh, Age: age, TTL: ttl} +} + +func nodeOnline(s NodeStats) bool { + return nodeHealth(s).State != healthOffline +} + func pctBar(pct float64, width int, segments []barSegment) string { filled := int(math.Round(pct / 100.0 * float64(width))) if filled > width { @@ -120,20 +163,26 @@ func segmentStyle(pct float64, segments []barSegment) lipgloss.Style { func renderANSI(s NodeStats) string { var sb strings.Builder - age := time.Since(time.Unix(0, s.UpdatedAt)) - offline := s.UpdatedAt == 0 || age > 15*time.Second + health := nodeHealth(s) status := styleGreen.Render("●") - if offline { + if health.State == healthStale { + status = styleYellow.Render("●") + } + if health.State == healthOffline { status = styleRed.Render("●") } sb.WriteString(fmt.Sprintf("%s %s\n", status, s.Name)) - if offline { + if health.State == healthOffline { sb.WriteString(styleDim.Render(" offline")) sb.WriteByte('\n') return sb.String() } - sb.WriteString(fmt.Sprintf(" updated %.0fs ago\n", age.Seconds())) + if health.State == healthStale { + sb.WriteString(fmt.Sprintf(" stale %.0fs ago\n", health.Age.Seconds())) + } else { + sb.WriteString(fmt.Sprintf(" updated %.0fs ago\n", health.Age.Seconds())) + } sb.WriteString(styleDim.Render(strings.Repeat("─", barWidth+14))) sb.WriteByte('\n') @@ -211,8 +260,14 @@ func avgCPU(s NodeStats) float64 { return sum / float64(len(s.CPU)) } -func nodeOnline(s NodeStats) bool { - return s.UpdatedAt != 0 && time.Since(time.Unix(0, s.UpdatedAt)) <= 15*time.Second +func maxCPU(s NodeStats) float64 { + max := 0.0 + for _, v := range s.CPU { + if v > max { + max = v + } + } + return max } func computeRefreshIntervalMs(nodes []NodeStats) int { @@ -265,6 +320,51 @@ func findBestNodeHint(nodes []NodeStats) string { return fmt.Sprintf("lowest-load node: %s (%.0f%% cpu, %.2f load)", best.Name, avgCPU(*best), best.Load[0]) } +type clusterSummary struct { + Fresh int + Stale int + Offline int + Hottest string + HotCPU float64 + HotLoad float64 + HasHot bool +} + +func summarizeCluster(nodes []NodeStats) clusterSummary { + var summary clusterSummary + hotScore := -1.0 + for _, s := range nodes { + switch nodeHealth(s).State { + case healthFresh: + summary.Fresh++ + case healthStale: + summary.Stale++ + continue + default: + summary.Offline++ + continue + } + cpu := avgCPU(s) + score := cpu + s.Load[0]*10 + if score > hotScore { + hotScore = score + summary.Hottest = s.Name + summary.HotCPU = cpu + summary.HotLoad = s.Load[0] + summary.HasHot = true + } + } + return summary +} + +func (s clusterSummary) TerminalHeader() string { + hot := "hottest: none" + if s.HasHot { + hot = fmt.Sprintf("hottest: %s %.0f%% cpu %.2f load", s.Hottest, s.HotCPU, s.HotLoad) + } + return fmt.Sprintf("online %d - stale %d - offline %d - %s", s.Fresh, s.Stale, s.Offline, hot) +} + func nodeScore(s NodeStats) float64 { return avgCPU(s) + s.Load[0]*10 } @@ -360,11 +460,20 @@ func displayQuery(r *http.Request, winW, winH int) url.Values { // ── HTTP handler ────────────────────────────────────────────────────────────── type cellData struct { - Name string - URL string - HTML template.HTML - Offline bool - Link bool + Name string + URL string + HTML template.HTML + State healthState + CPUAvg float64 + CPUMax float64 + MemPct float64 + MemUsed uint64 + MemTotal uint64 + Load1 float64 + Load5 float64 + Load15 float64 + Age float64 + Link bool } type pageData struct { @@ -374,6 +483,7 @@ type pageData struct { RefreshLabel string RefreshURL string BestHint string + Summary clusterSummary } func makeHandler(db *pebble.DB, selfName string) http.HandlerFunc { @@ -392,18 +502,31 @@ func makeHandler(db *pebble.DB, selfName string) http.HandlerFunc { cells := make([]cellData, 0, len(nodes)) for _, s := range nodes { htmlBytes := ansihtml.ConvertToHTML([]byte(renderANSI(s))) - offline := s.UpdatedAt == 0 || time.Since(time.Unix(0, s.UpdatedAt)) > 15*time.Second + health := nodeHealth(s) nodeURL := "" if s.WebURL != "" { nodeURL = pageURL(s.WebURL, displayQuery(r, winW, winH)) } + memPct := 0.0 + if s.MemTotal > 0 { + memPct = float64(s.MemUsed) / float64(s.MemTotal) * 100 + } cells = append(cells, cellData{ - Name: s.Name, - URL: nodeURL, - HTML: template.HTML(htmlBytes), - Offline: offline, - Link: nodeURL != "", + Name: s.Name, + URL: nodeURL, + HTML: template.HTML(htmlBytes), + State: health.State, + CPUAvg: avgCPU(s), + CPUMax: maxCPU(s), + MemPct: memPct, + MemUsed: s.MemUsed, + MemTotal: s.MemTotal, + Load1: s.Load[0], + Load5: s.Load[1], + Load15: s.Load[2], + Age: health.Age.Seconds(), + Link: nodeURL != "", }) } @@ -423,6 +546,7 @@ func makeHandler(db *pebble.DB, selfName string) http.HandlerFunc { RefreshLabel: fmt.Sprintf("%.1fs", float64(refreshMs)/1000), RefreshURL: refreshURL, BestHint: bestHint, + Summary: summarizeCluster(nodes), }); err != nil { http.Error(w, "template error", 500) return diff --git a/render_test.go b/render_test.go index 51330f1..8393716 100644 --- a/render_test.go +++ b/render_test.go @@ -53,3 +53,115 @@ func TestDashboardDoesNotLinkNodesWithoutWebURL(t *testing.T) { t.Fatalf("web node was not rendered as a link:\n%s", body) } } + +func TestNodeHealthDistinguishesFreshStaleOffline(t *testing.T) { + now := time.Now() + cases := []struct { + name string + node NodeStats + want healthState + }{ + { + name: "fresh", + node: NodeStats{UpdatedAt: now.Add(-2 * time.Second).UnixNano(), TTLSeconds: 10}, + want: healthFresh, + }, + { + name: "stale", + node: NodeStats{UpdatedAt: now.Add(-7 * time.Second).UnixNano(), TTLSeconds: 10}, + want: healthStale, + }, + { + name: "offline", + node: NodeStats{UpdatedAt: now.Add(-11 * time.Second).UnixNano(), TTLSeconds: 10}, + want: healthOffline, + }, + { + name: "zero timestamp offline", + node: NodeStats{}, + want: healthOffline, + }, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if got := nodeHealth(tc.node).State; got != tc.want { + t.Fatalf("state = %s, want %s", got, tc.want) + } + }) + } +} + +func TestSummarizeClusterCountsStatesAndHottestOnlineNode(t *testing.T) { + now := time.Now() + summary := summarizeCluster([]NodeStats{ + {Name: "fresh-hot", UpdatedAt: now.UnixNano(), TTLSeconds: 10, CPU: []float64{20}, Load: [3]float64{0.5}}, + {Name: "stale-hot", UpdatedAt: now.Add(-7 * time.Second).UnixNano(), TTLSeconds: 10, CPU: []float64{70}, Load: [3]float64{1.2}}, + {Name: "offline", UpdatedAt: 0, TTLSeconds: 10, CPU: []float64{99}, Load: [3]float64{9}}, + }) + + if summary.Fresh != 1 || summary.Stale != 1 || summary.Offline != 1 { + t.Fatalf("counts = fresh %d stale %d offline %d, want 1/1/1", summary.Fresh, summary.Stale, summary.Offline) + } + if !summary.HasHot || summary.Hottest != "fresh-hot" { + t.Fatalf("hottest = %q has=%t, want fresh-hot", summary.Hottest, summary.HasHot) + } +} + +func TestDashboardRendersClusterSummaryAndStateData(t *testing.T) { + db := openTestDB(t) + now := time.Now() + nodes := []NodeStats{ + {Name: "fresh", Version: appVersion, UpdatedAt: now.UnixNano(), TTLSeconds: 10, CPU: []float64{20}, MemTotal: 100, MemUsed: 30}, + {Name: "stale", Version: appVersion, UpdatedAt: now.Add(-7 * time.Second).UnixNano(), TTLSeconds: 10, CPU: []float64{80}, MemTotal: 100, MemUsed: 40}, + {Name: "offline", Version: appVersion, UpdatedAt: 0, TTLSeconds: 10, CPU: []float64{90}, MemTotal: 100, MemUsed: 50}, + } + for _, node := range nodes { + if err := dbSet(db, node); err != nil { + t.Fatalf("set %s: %v", node.Name, err) + } + } + + req := httptest.NewRequest("GET", "/?theme=dark&palette=monochrome", nil) + rr := httptest.NewRecorder() + makeHandler(db, "fresh").ServeHTTP(rr, req) + + body := rr.Body.String() + for _, want := range []string{ + `online 1`, + `stale 1`, + `offline 1`, + `data-state="stale"`, + `id="sort-select"`, + `id="hide-offline"`, + `value="cpuAvg"`, + `value="cpuMax"`, + `value="memPct"`, + `value="memUsed"`, + `value="memTotal"`, + `value="load1"`, + `value="load5"`, + `value="load15"`, + `data-cpu-avg="20.000"`, + `data-cpu-max="20.000"`, + `data-mem-pct="30.000"`, + `data-mem-used="30"`, + `data-mem-total="100"`, + `data-load1="0.000"`, + `data-load5="0.000"`, + `data-load15="0.000"`, + } { + if !strings.Contains(body, want) { + t.Fatalf("dashboard missing %q:\n%s", want, body) + } + } +} + +func TestCollectStatsUsesConfiguredTTL(t *testing.T) { + stats, err := collectStats("node-a", "http://node-a:8080", "v1", 42*time.Second) + if err != nil { + t.Fatalf("collectStats: %v", err) + } + if stats.TTLSeconds != 42 { + t.Fatalf("ttl seconds = %d, want 42", stats.TTLSeconds) + } +} diff --git "a/tasks/0_planning/\360\237\222\241-\360\237\224\224-\360\237\233\251\357\270\217-selectable_metrics_and_sensors.md" "b/tasks/0_planning/\360\237\222\241-\360\237\224\224-\360\237\233\251\357\270\217-selectable_metrics_and_sensors.md" new file mode 100644 index 0000000..1f4e59a --- /dev/null +++ "b/tasks/0_planning/\360\237\222\241-\360\237\224\224-\360\237\233\251\357\270\217-selectable_metrics_and_sensors.md" @@ -0,0 +1,41 @@ +--- +id: task-20260518-selectable-metrics-sensors +title: Plan selectable metrics and host sensors +status: 0_planning +type: feature +priority: normal +effort: trip +creator: codex +owner: "" +created: 2026-05-18 +--- + +## Problem + +psstd currently renders a fixed set of metrics for every node. Real hosts may +have useful extra sensors, such as temperature, fan speed, power, battery, UPS, +HID, or I2C-attached devices, and not every node will support the same readings. + +Operators should eventually be able to choose which metrics appear in the +dashboard and terminal view without making unsupported node sensors look broken. + +## Planning questions + +- Which metrics are core and always shown? +- Which metrics are optional per-node capabilities? +- Should metric selection be a browser preference, node-local config, cluster-wide config, or a mix? +- How should the UI represent a selected metric that only some nodes can report? +- Which operating-system sensor APIs are reliable enough on Linux, macOS, Windows, and containers? +- Which Go libraries or native command integrations should be considered for sensor discovery and reading? +- How often should slow or expensive sensor reads run compared with CPU/memory/load heartbeats? +- How should sensor values be normalized, named, unit-tagged, and versioned in the gossip payload? + +## Done when + +- Existing CPU, memory, load, age, and health metrics have an explicit display-selection model +- Optional metrics can be hidden or shown without adding server API surface unless the design justifies it +- Sensor capability discovery is defined separately from sensor reading +- Unsupported metrics render as unavailable, not stale/offline +- Sensor payloads include stable names, units, values, and collection timestamps +- The first implementation scope is small enough for one MR +- Follow-up tickets exist for OS-specific sensor support after research diff --git "a/tasks/0_planning/\360\237\233\240\357\270\217-\360\237\224\224-\360\237\217\225\357\270\217-cli_mode_flags.md" "b/tasks/0_planning/\360\237\233\240\357\270\217-\360\237\224\224-\360\237\217\225\357\270\217-cli_mode_flags.md" new file mode 100644 index 0000000..c3480d5 --- /dev/null +++ "b/tasks/0_planning/\360\237\233\240\357\270\217-\360\237\224\224-\360\237\217\225\357\270\217-cli_mode_flags.md" @@ -0,0 +1,30 @@ +--- +id: task-20260518-cli-mode-flags +title: Design explicit CLI mode flags +status: 0_planning +type: maintenance +priority: normal +effort: night +creator: codex +owner: "" +created: 2026-05-18 +--- + +## Problem + +The requested `-t`, `-v`, `-tv`, and `-r` modes change psstd startup semantics: +today the primary process owns the local database, participates in gossip, serves +HTTP by default, and only falls back to terminal mirror mode when another local +instance already owns the store or gossip port. + +Making terminal mirror mode the default needs a clearer mode model so service +deployments, web serving, read-only cluster observation, and log streaming remain +predictable. + +## Done when + +- CLI modes are explicitly named and documented +- Default behavior for interactive shells and services is decided +- `-t`, `-v`, `-tv`, and `-r` have non-overlapping behavior +- Read-only observer mode cannot accidentally publish a duplicate node +- Tests cover flag parsing and mode selection diff --git "a/tasks/3_done/\360\237\222\241-\360\237\224\224-\360\237\215\260-node_health_ttl_configuration.md" "b/tasks/3_done/\360\237\222\241-\360\237\224\224-\360\237\215\260-node_health_ttl_configuration.md" new file mode 100644 index 0000000..c0de1df --- /dev/null +++ "b/tasks/3_done/\360\237\222\241-\360\237\224\224-\360\237\215\260-node_health_ttl_configuration.md" @@ -0,0 +1,34 @@ +--- +id: task-20260518-node-health-ttl-config +title: Make node health TTL configurable +status: 3_done +type: feature +priority: normal +effort: cake +creator: codex +owner: "" +created: 2026-05-18 +forked_from: task-20260517-health +--- + +## Problem + +Fresh, stale, and offline rendering now uses a TTL published by the origin node, +but the origin currently only publishes psstd's built-in default. Operators +still cannot tune how long a node wants its last heartbeat to be considered +fresh or stale. + +## Done when + +- A node-local configuration value controls the TTL published in that node's heartbeat +- Empty configuration preserves the current default +- Invalid TTL values fail clearly or fall back with an explicit warning +- README documents when and how to tune the TTL +- Tests cover default, configured, and invalid TTL behavior + +## Result + +- Added `PSSTD_NODE_TTL` duration parsing with a `15s` default and clear invalid-value failures +- Published the configured TTL in each node heartbeat +- Documented TTL tuning in README +- Added unit coverage for default, configured, invalid, and heartbeat-published TTL behavior diff --git "a/tasks/1_todo/\360\237\222\241-\360\237\224\224-\360\237\215\260-node_name_override.md" "b/tasks/3_done/\360\237\222\241-\360\237\224\224-\360\237\215\260-node_name_override.md" similarity index 71% rename from "tasks/1_todo/\360\237\222\241-\360\237\224\224-\360\237\215\260-node_name_override.md" rename to "tasks/3_done/\360\237\222\241-\360\237\224\224-\360\237\215\260-node_name_override.md" index 0004d39..0984e0d 100644 --- "a/tasks/1_todo/\360\237\222\241-\360\237\224\224-\360\237\215\260-node_name_override.md" +++ "b/tasks/3_done/\360\237\222\241-\360\237\224\224-\360\237\215\260-node_name_override.md" @@ -1,7 +1,7 @@ --- id: task-20260517-node-name-override title: Add optional node name override -status: 1_todo +status: 3_done type: feature priority: normal effort: cake @@ -21,3 +21,9 @@ psstd uses the OS hostname as the node identity. That is good for zero-config in - Node name is validated enough to avoid empty names and confusing whitespace - README documents when to use the override - Tests cover default hostname and override behavior where practical + +## Result + +- Implemented `PSSTD_NODE_NAME` for memberlist identity, mDNS registration, stats heartbeat, and terminal mirror fallback naming +- Added validation and README coverage +- Added focused unit tests for default, override, and whitespace rejection diff --git "a/tasks/1_todo/\360\237\222\241-\360\237\224\224-\360\237\233\235-cluster_summary_band.md" "b/tasks/3_done/\360\237\222\241-\360\237\224\224-\360\237\233\235-cluster_summary_band.md" similarity index 75% rename from "tasks/1_todo/\360\237\222\241-\360\237\224\224-\360\237\233\235-cluster_summary_band.md" rename to "tasks/3_done/\360\237\222\241-\360\237\224\224-\360\237\233\235-cluster_summary_band.md" index 2c90022..3fa6385 100644 --- "a/tasks/1_todo/\360\237\222\241-\360\237\224\224-\360\237\233\235-cluster_summary_band.md" +++ "b/tasks/3_done/\360\237\222\241-\360\237\224\224-\360\237\233\235-cluster_summary_band.md" @@ -1,7 +1,7 @@ --- id: task-20260517-cluster-summary-band title: Add a compact cluster summary band -status: 1_todo +status: 3_done type: feature priority: normal effort: walk @@ -20,3 +20,9 @@ The dashboard is strongest as an at-a-glance cluster htop. A small summary can a - Summary identifies the hottest online node by CPU or load - Terminal mirror includes the same counts in its header - The summary uses existing node snapshots only and adds no API surface + +## Result + +- Added a compact web summary with online, stale, offline, and hottest-node details +- Added the same summary to the terminal mirror header +- Summary is calculated from existing node snapshots diff --git "a/tasks/3_done/\360\237\222\241-\360\237\224\224-\360\237\233\235-dashboard_metric_sort_completeness.md" "b/tasks/3_done/\360\237\222\241-\360\237\224\224-\360\237\233\235-dashboard_metric_sort_completeness.md" new file mode 100644 index 0000000..fe15244 --- /dev/null +++ "b/tasks/3_done/\360\237\222\241-\360\237\224\224-\360\237\233\235-dashboard_metric_sort_completeness.md" @@ -0,0 +1,33 @@ +--- +id: task-20260518-dashboard-metric-sort-completeness +title: Complete dashboard metric sorting +status: 3_done +type: feature +priority: normal +effort: walk +creator: codex +owner: "" +created: 2026-05-18 +forked_from: task-20260517-client-sort +--- + +## Problem + +The dashboard now sorts by name, average CPU, memory percentage, load1, and age. +The original ticket said the dashboard should sort by any populated metrics, but +the implementation does not yet cover the full node snapshot shape or explicitly +define which metrics are intentionally sortable. + +## Done when + +- The sortable metric set is explicitly defined in code and UI +- Populated node metrics are either sortable or intentionally excluded in the ticket result +- Load sorting covers the load values users expect, not only load1 unless that is the intended scope +- CPU sorting handles the desired aggregate, such as average and/or max core +- Tests cover rendered sort data for each supported metric + +## Result + +- Sort controls now cover name, CPU average, CPU max, memory percent, memory used, memory total, load 1m, load 5m, load 15m, and age +- Dashboard cards render sortable data attributes for each supported metric +- Tests cover the rendered sort options and metric data diff --git "a/tasks/1_todo/\360\237\222\241-\360\237\224\224-\360\237\233\235-dashboard_sort_filter.md" "b/tasks/3_done/\360\237\222\241-\360\237\224\224-\360\237\233\235-dashboard_sort_filter.md" similarity index 58% rename from "tasks/1_todo/\360\237\222\241-\360\237\224\224-\360\237\233\235-dashboard_sort_filter.md" rename to "tasks/3_done/\360\237\222\241-\360\237\224\224-\360\237\233\235-dashboard_sort_filter.md" index 9bcdd0a..dfd0bb7 100644 --- "a/tasks/1_todo/\360\237\222\241-\360\237\224\224-\360\237\233\235-dashboard_sort_filter.md" +++ "b/tasks/3_done/\360\237\222\241-\360\237\224\224-\360\237\233\235-dashboard_sort_filter.md" @@ -1,7 +1,7 @@ --- id: task-20260517-client-sort title: Add dashboard sort and filter controls -status: 0_planning +status: 3_done type: feature priority: normal effort: walk @@ -21,4 +21,12 @@ Large clusters benefit from sorting by CPU% or memory to spot hot nodes quickly. - Optional filter can hide offline or stale nodes - Preferences are stored in the browser, not the server +## Result +- Added client-side sort controls for name, CPU, memory, load, and age +- Added browser-persisted stale/offline filters +- Kept the implementation in the dashboard template without adding server API surface + +## Forked follow-up + +- `task-20260518-dashboard-metric-sort-completeness`: finish or explicitly define the full set of populated metrics that should be sortable diff --git "a/tasks/1_todo/\360\237\222\241-\360\237\224\224-\360\237\233\235-stale_node_indication.md" "b/tasks/3_done/\360\237\222\241-\360\237\224\224-\360\237\233\235-stale_node_indication.md" similarity index 60% rename from "tasks/1_todo/\360\237\222\241-\360\237\224\224-\360\237\233\235-stale_node_indication.md" rename to "tasks/3_done/\360\237\222\241-\360\237\224\224-\360\237\233\235-stale_node_indication.md" index 72762cd..3012f80 100644 --- "a/tasks/1_todo/\360\237\222\241-\360\237\224\224-\360\237\233\235-stale_node_indication.md" +++ "b/tasks/3_done/\360\237\222\241-\360\237\224\224-\360\237\233\235-stale_node_indication.md" @@ -1,7 +1,7 @@ --- id: task-20260517-health title: Distinguish stale nodes from offline nodes -status: 1_todo +status: 3_done type: feature priority: normal effort: walk @@ -20,3 +20,13 @@ Offline nodes and recently stale nodes can look too similar. Operators should qu - The staleness threshold is controlled by the origin node (each node controls the 'time' it'd like to be known for) - Terminal and web views use the same state calculation - Existing offline purge/version behavior still works + +## Result + +- Added node heartbeat TTL metadata and shared fresh/stale/offline state calculation +- Terminal and web rendering now use the shared health state +- Offline purge/version behavior still flows through the same `nodeRecordOffline` helper + +## Forked follow-up + +- `task-20260518-node-health-ttl-config`: expose and validate the origin node's TTL as configuration instead of only publishing the built-in default diff --git "a/tasks/3_done/\360\237\233\240\357\270\217-\360\237\224\224-\360\237\215\260-startup_join_outcome_summary.md" "b/tasks/3_done/\360\237\233\240\357\270\217-\360\237\224\224-\360\237\215\260-startup_join_outcome_summary.md" new file mode 100644 index 0000000..b11929a --- /dev/null +++ "b/tasks/3_done/\360\237\233\240\357\270\217-\360\237\224\224-\360\237\215\260-startup_join_outcome_summary.md" @@ -0,0 +1,33 @@ +--- +id: task-20260518-startup-join-outcome-summary +title: Include join outcome in startup summary +status: 3_done +type: maintenance +priority: normal +effort: cake +creator: codex +owner: "" +created: 2026-05-18 +forked_from: task-20260517-cli +--- + +## Problem + +Startup logging now prints a concise configuration summary and quiets repeated +mDNS discovery lines, but the actual join outcome is still reported separately. +The original operator-facing problem asks for the startup summary to make it +obvious whether psstd joined peers or is running solo. + +## Done when + +- The startup summary includes joined peer count or solo status +- Join warnings still include enough detail for troubleshooting +- Seed count, mDNS discovery count, and join result are not repeated noisily +- Tests cover startup summary formatting for joined, solo, and warning cases where practical + +## Result + +- Startup summary now logs solo, joined, or warning join status in the same operator-facing line as configuration +- Join warning summaries include joined count and error text +- Removed the separate joined/solo startup log line to avoid repetition +- Added tests for joined, solo, and warning startup summary formatting diff --git "a/tasks/1_todo/\360\237\233\240\357\270\217-\360\237\224\224-\360\237\233\235-startup_configuration.md" "b/tasks/3_done/\360\237\233\240\357\270\217-\360\237\224\224-\360\237\233\235-startup_configuration.md" similarity index 54% rename from "tasks/1_todo/\360\237\233\240\357\270\217-\360\237\224\224-\360\237\233\235-startup_configuration.md" rename to "tasks/3_done/\360\237\233\240\357\270\217-\360\237\224\224-\360\237\233\235-startup_configuration.md" index 5fc9c12..1c71b70 100644 --- "a/tasks/1_todo/\360\237\233\240\357\270\217-\360\237\224\224-\360\237\233\235-startup_configuration.md" +++ "b/tasks/3_done/\360\237\233\240\357\270\217-\360\237\224\224-\360\237\233\235-startup_configuration.md" @@ -1,7 +1,7 @@ --- id: task-20260517-cli title: Print a clear startup configuration -status: 1_todo +status: 3_done type: maintenance priority: normal effort: walk @@ -19,12 +19,11 @@ psstd is easiest to like when it feels obvious what it is doing: which database - Startup logs show DB path, HTTP listen address, advertised URL, gossip listen address, web enabled state, and version - Seed and mDNS discovery results are summarized without noisy repetition -## Problem 2 -Just a general cleanup of cli flags and provide some of the basics like help text. +## Result -## Done when -- Terminal mirror mode should be default mode (-t) -- If a -v flag is provided, then it prints the log instead -- If a -tv flag is provided, then we print both the terminal rendering and a log stream (similar to how screen does window splits) -- A -r flag does "read only" and '-t' is still default, and -v and -tv still do the same, just as a read-only 'observer' of the cluster -- Existing log lines remain useful for troubleshooting +- Added a concise startup summary including version, node, DB path, web state, HTTP listen address, advertised URL, gossip address, explicit seed count, and mDNS discovery count +- Split the CLI mode flag cleanup into `task-20260518-cli-mode-flags` for planning + +## Forked follow-up + +- `task-20260518-startup-join-outcome-summary`: include the actual join outcome in the operator-facing startup summary diff --git a/templates/dashboard.html b/templates/dashboard.html index 335f6a7..549f3d9 100644 --- a/templates/dashboard.html +++ b/templates/dashboard.html @@ -15,6 +15,8 @@ --text: #f7f7f7; --muted: #9ca3af; --offline: #f97316; + --stale: #facc15; + --fresh: #22c55e; --accent: #60a5fa; --toolbar: #111111; --toolbar-border: #2a2a2a; @@ -28,6 +30,8 @@ --text: #0f172a; --muted: #475569; --offline: #b91c1c; + --stale: #b45309; + --fresh: #15803d; --accent: #2563eb; --toolbar: #eef2ff; --toolbar-border: #cbd5e1; @@ -40,6 +44,8 @@ --text: #93a1a1; --muted: #657b83; --offline: #dc322f; + --stale: #b58900; + --fresh: #859900; --accent: #b58900; --toolbar: #00212b; --toolbar-border: #586e75; @@ -52,6 +58,8 @@ --text: #d0d0d0; --muted: #94a3b8; --offline: #ef4444; + --stale: #facc15; + --fresh: #8ae234; --accent: #ef8b00; --toolbar: #20242b; --toolbar-border: #4b5263; @@ -64,6 +72,8 @@ --text: #eceff4; --muted: #d8dee9; --offline: #bf616a; + --stale: #ebcb8b; + --fresh: #a3be8c; --accent: #88c0d0; --toolbar: #242933; --toolbar-border: #4c566a; @@ -76,6 +86,8 @@ --text: #ebdbb2; --muted: #a89984; --offline: #fb4934; + --stale: #fabd2f; + --fresh: #b8bb26; --accent: #fabd2f; --toolbar: #1d2021; --toolbar-border: #665c54; @@ -88,6 +100,8 @@ --text: #f8f8f2; --muted: #bd93f9; --offline: #ff5555; + --stale: #f1fa8c; + --fresh: #50fa7b; --accent: #50fa7b; --toolbar: #21222c; --toolbar-border: #6272a4; @@ -100,6 +114,8 @@ --text: #cdd6f4; --muted: #a6adc8; --offline: #f38ba8; + --stale: #f9e2af; + --fresh: #a6e3a1; --accent: #89b4fa; --toolbar: #181825; --toolbar-border: #585b70; @@ -112,6 +128,8 @@ --text: #d3c6aa; --muted: #9da9a0; --offline: #e67e80; + --stale: #dbbc7f; + --fresh: #a7c080; --accent: #a7c080; --toolbar: #232a2e; --toolbar-border: #56635f; @@ -124,6 +142,8 @@ --text: #e5e7eb; --muted: #93c5fd; --offline: #fb7185; + --stale: #fde047; + --fresh: #34d399; --accent: #22d3ee; --toolbar: #030712; --toolbar-border: #374151; @@ -136,6 +156,8 @@ --text: #d4ffd9; --muted: #6ee787; --offline: #ff6b6b; + --stale: #faff00; + --fresh: #00ff66; --accent: #00ff66; --toolbar: #000000; --toolbar-border: #1f6b3a; @@ -148,6 +170,8 @@ --text: #ffe9bd; --muted: #f4b860; --offline: #ff5c3a; + --stale: #ffcf33; + --fresh: #83ff66; --accent: #ffb000; --toolbar: #0b0703; --toolbar-border: #7c4a12; @@ -161,6 +185,8 @@ --text: #22201b; --muted: #6f6758; --offline: #b42318; + --stale: #a16207; + --fresh: #166534; --accent: #006d77; --toolbar: #eee7d5; --toolbar-border: #c8bfa8; @@ -173,6 +199,8 @@ --text: #ffffff; --muted: #cfcfcf; --offline: #ff3b30; + --stale: #ffff00; + --fresh: #00ff66; --accent: #00e5ff; --toolbar: #000000; --toolbar-border: #ffffff; @@ -205,6 +233,24 @@ align-items: center; flex-wrap: wrap; } +.summary { + color: var(--muted); + display: flex; + gap: 10px; + flex-wrap: wrap; + align-items: center; +} +.summary strong { color: var(--text); } +.summary .fresh { color: var(--fresh); } +.summary .stale { color: var(--stale); } +.summary .offline { color: var(--offline); } +.nodes { + width: 100%; + display: flex; + flex-wrap: wrap; + align-content: flex-start; + gap: 6px; +} .toolbar label { font-size: 0.95rem; color: var(--muted); @@ -247,6 +293,7 @@ .cell-header a:hover { text-decoration: underline; } +.cell.stale { border-color: var(--stale); } .cell.offline { opacity: 0.55; border-color: var(--offline); } pre { line-height: 1.4; white-space: pre; color: var(--text); } @@ -258,6 +305,9 @@ const theme = params.get('theme') || storedTheme; const palette = params.get('palette') || storedPalette; const refreshMs = {{.RefreshMs}}; + const storedSort = localStorage.getItem('psstd-sort') || 'name'; + const storedHideStale = localStorage.getItem('psstd-hide-stale') === 'true'; + const storedHideOffline = localStorage.getItem('psstd-hide-offline') === 'true'; document.documentElement.dataset.theme = theme; document.documentElement.dataset.palette = palette; @@ -290,14 +340,44 @@ document.documentElement.dataset.palette = paletteValue; localStorage.setItem('psstd-palette', paletteValue); updateQuery('palette', paletteValue); + }, + applyDashboardControls() { + const sortSelect = document.getElementById('sort-select'); + const hideStale = document.getElementById('hide-stale'); + const hideOffline = document.getElementById('hide-offline'); + const nodes = document.getElementById('nodes'); + if (!sortSelect || !hideStale || !hideOffline || !nodes) return; + + localStorage.setItem('psstd-sort', sortSelect.value); + localStorage.setItem('psstd-hide-stale', hideStale.checked ? 'true' : 'false'); + localStorage.setItem('psstd-hide-offline', hideOffline.checked ? 'true' : 'false'); + + const cards = Array.from(nodes.querySelectorAll('.cell')); + const sortKey = sortSelect.value; + cards.sort((a, b) => { + if (sortKey === 'name') return a.dataset.name.localeCompare(b.dataset.name); + return Number(b.dataset[sortKey] || 0) - Number(a.dataset[sortKey] || 0); + }); + for (const card of cards) { + const state = card.dataset.state; + card.hidden = (hideStale.checked && state === 'stale') || (hideOffline.checked && state === 'offline'); + nodes.appendChild(card); + } } }; window.addEventListener('DOMContentLoaded', () => { const themeSelect = document.getElementById('theme-select'); const paletteSelect = document.getElementById('palette-select'); + const sortSelect = document.getElementById('sort-select'); + const hideStale = document.getElementById('hide-stale'); + const hideOffline = document.getElementById('hide-offline'); if (themeSelect) themeSelect.value = theme; if (paletteSelect) paletteSelect.value = palette; + if (sortSelect) sortSelect.value = storedSort; + if (hideStale) hideStale.checked = storedHideStale; + if (hideOffline) hideOffline.checked = storedHideOffline; + window.psstd.applyDashboardControls(); }); setTimeout(() => location.replace({{.RefreshURL}}), refreshMs); @@ -331,14 +411,38 @@
{{.HTML}}