From 82bfbbfbc1598399bfce28da82864aa459cc0ae3 Mon Sep 17 00:00:00 2001 From: Andrew LeTourneau Date: Mon, 18 May 2026 07:59:15 -0500 Subject: [PATCH 1/4] vibe: this is an implementation spike of the planned items for review --- README.md | 6 + discovery.go | 1 - gossip.go | 20 +-- main.go | 80 +++++++-- main_test.go | 56 ++++++ render.go | 160 +++++++++++++++--- render_test.go | 86 ++++++++++ ...237\217\225\357\270\217-cli_mode_flags.md" | 30 ++++ ...24-\360\237\215\260-node_name_override.md" | 8 +- ...-\360\237\233\235-cluster_summary_band.md" | 8 +- ...\360\237\233\235-dashboard_sort_filter.md" | 7 +- ...\360\237\233\235-stale_node_indication.md" | 8 +- ...\360\237\233\235-startup_configuration.md" | 13 +- templates/dashboard.html | 103 ++++++++++- terminal.go | 4 +- 15 files changed, 519 insertions(+), 71 deletions(-) create mode 100644 main_test.go create mode 100644 "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" rename "tasks/1_todo/\360\237\222\241-\360\237\224\224-\360\237\215\260-node_name_override.md" => "tasks/3_done/\360\237\222\241-\360\237\224\224-\360\237\215\260-node_name_override.md" (71%) rename "tasks/1_todo/\360\237\222\241-\360\237\224\224-\360\237\233\235-cluster_summary_band.md" => "tasks/3_done/\360\237\222\241-\360\237\224\224-\360\237\233\235-cluster_summary_band.md" (75%) rename "tasks/1_todo/\360\237\222\241-\360\237\224\224-\360\237\233\235-dashboard_sort_filter.md" => "tasks/3_done/\360\237\222\241-\360\237\224\224-\360\237\233\235-dashboard_sort_filter.md" (70%) rename "tasks/1_todo/\360\237\222\241-\360\237\224\224-\360\237\233\235-stale_node_indication.md" => "tasks/3_done/\360\237\222\241-\360\237\224\224-\360\237\233\235-stale_node_indication.md" (71%) rename "tasks/1_todo/\360\237\233\240\357\270\217-\360\237\224\224-\360\237\233\235-startup_configuration.md" => "tasks/3_done/\360\237\233\240\357\270\217-\360\237\224\224-\360\237\233\235-startup_configuration.md" (54%) diff --git a/README.md b/README.md index 859e304..0c30909 100644 --- a/README.md +++ b/README.md @@ -48,9 +48,15 @@ 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 ./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. + ## 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..a275405 100644 --- a/main.go +++ b/main.go @@ -16,18 +16,23 @@ import ( ) 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" + gossipPort = 7946 + httpPort = 8080 ) func main() { hostname, _ := os.Hostname() + nodeName, err := nodeNameFromEnv(hostname) + if err != nil { + log.Fatalf("node name: %v", err) + } dbPath := envOr(envDB, "./data") httpAddr := envOr(envHTTP, fmt.Sprintf(":%d", httpPort)) @@ -47,7 +52,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) return } log.Fatalf("pebble open: %v", err) @@ -65,7 +70,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 +84,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) return } log.Fatalf("memberlist create: %v", err) @@ -88,12 +93,23 @@ 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...) + logStartupConfig(startupConfig{ + NodeName: nodeName, + DBPath: dbPath, + HTTPAddr: httpAddr, + WebURL: webURL, + GossipAddr: gossipAddr, + WebEnabled: webEnabled, + Version: appVersion, + SeedCount: len(seeds), + MDNSCount: len(discovered), + }) if len(allSeeds) > 0 { if n, err := list.Join(allSeeds); err != nil { log.Printf("join warning (joined %d): %v", n, err) @@ -105,22 +121,37 @@ func main() { } // ── Stats heartbeat ───────────────────────────────────────────────────── - go statsLoop(hostname, webURL, appVersion, db, delegate) + go statsLoop(nodeName, webURL, appVersion, 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 } } +type startupConfig struct { + NodeName string + DBPath string + HTTPAddr string + WebURL string + GossipAddr string + WebEnabled bool + Version string + SeedCount int + MDNSCount int +} + +func logStartupConfig(cfg startupConfig) { + log.Printf("psstd startup: version=%s node=%s db=%s web=%t http=%s advertise=%s gossip=%s seeds=%d mdns=%d", + cfg.Version, cfg.NodeName, cfg.DBPath, cfg.WebEnabled, cfg.HTTPAddr, cfg.WebURL, cfg.GossipAddr, cfg.SeedCount, cfg.MDNSCount) +} + func pebbleLockHeld(err error) bool { msg := strings.ToLower(err.Error()) return strings.Contains(msg, "lock") && @@ -255,6 +286,23 @@ 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 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..9c5dcbd --- /dev/null +++ b/main_test.go @@ -0,0 +1,56 @@ +package main + +import ( + "os" + "testing" +) + +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) + } + }) + } +} diff --git a/render.go b/render.go index baf5d1b..900174b 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 @@ -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(defaultNodeTTL / 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,10 +260,6 @@ 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 computeRefreshIntervalMs(nodes []NodeStats) int { if len(nodes) == 0 { return 3000 @@ -265,6 +310,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 +450,15 @@ 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 + CPU float64 + Mem float64 + Load float64 + Age float64 + Link bool } type pageData struct { @@ -374,6 +468,7 @@ type pageData struct { RefreshLabel string RefreshURL string BestHint string + Summary clusterSummary } func makeHandler(db *pebble.DB, selfName string) http.HandlerFunc { @@ -392,18 +487,26 @@ 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, + CPU: avgCPU(s), + Mem: memPct, + Load: s.Load[0], + Age: health.Age.Seconds(), + Link: nodeURL != "", }) } @@ -423,6 +526,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..4621d12 100644 --- a/render_test.go +++ b/render_test.go @@ -53,3 +53,89 @@ 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"`, + } { + if !strings.Contains(body, want) { + t.Fatalf("dashboard missing %q:\n%s", want, body) + } + } +} 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/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/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 70% 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..3bb5d3d 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,9 @@ 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 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 71% 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..2c1c91e 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,9 @@ 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 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..804975f 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,7 @@ 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 diff --git a/templates/dashboard.html b/templates/dashboard.html index 335f6a7..119829f 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,33 @@
- Auto-refresh {{.RefreshLabel}} · {{.BestHint}} + + + + +
+
+ Cluster + online {{.Summary.Fresh}} + stale {{.Summary.Stale}} + offline {{.Summary.Offline}} + {{if .Summary.HasHot}}hottest {{.Summary.Hottest}} {{printf "%.0f" .Summary.HotCPU}}% CPU {{printf "%.2f" .Summary.HotLoad}} load{{else}}hottest none{{end}} + Auto-refresh {{.RefreshLabel}} · {{.BestHint}}
+
{{range .Nodes}} -
+
{{if .Link}}{{.Name}}{{else}}{{.Name}}{{end}}
{{.HTML}}
{{end}} +
diff --git a/terminal.go b/terminal.go index 0271dc6..64808d2 100644 --- a/terminal.go +++ b/terminal.go @@ -34,7 +34,9 @@ func renderTerminalSnapshot(db *pebble.DB) { return } - fmt.Printf("psstd terminal mirror - %d node(s) - %s\n\n", len(nodes), time.Now().Format(time.RFC3339)) + fmt.Printf("psstd terminal mirror - %d node(s) - %s\n", len(nodes), time.Now().Format(time.RFC3339)) + fmt.Println(summarizeCluster(nodes).TerminalHeader()) + fmt.Println() fmt.Print(renderTerminalNodes(nodes)) } From 0332a447f57d4f8bcb67ae0a4c383587a3f1d0f1 Mon Sep 17 00:00:00 2001 From: Andrew LeTourneau Date: Mon, 18 May 2026 09:02:17 -0500 Subject: [PATCH 2/4] feat: terminal cell "brick-laying" MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ask: one small change I want to get in with this feature set, prob doesn't need a ticket... but when the "terminal view" of htop to terminal was implemented, it's printing each node out line-by-line... which is to be expected. But can we make this behavior from a '-l --list' flag and instead we "wrap" these outputs into "cells" across the terminal and then wrap/stack those like "bricks" It gets difficult because we don't want their positions changing randomly... and also the terminal is drawn line by line, so we'd have to get clever with curses or something to accomplish this maybe? If this is _not_ a small change, maybe we do need to plan this out in a ticket? codex-5.5-medium: Worked for 1m 46s Implemented it as a small change, no new ticket needed. Terminal mirror behavior now: Default: renders nodes as stable, sorted “brick” cells across the terminal width. -l / --list: keeps the previous vertical list rendering. Details: Uses deterministic name sorting, so nodes do not jump around randomly. Packs cells based on COLUMNS, falling back to width 100. Composes the full frame as text before redraw, so no curses dependency is needed. Verification: GOCACHE=/tmp/psstd-go-build go test ./... passes. --- README.md | 5 + main.go | 120 ++++++++++++------ main_test.go | 77 +++++++++++ render.go | 60 ++++++--- render_test.go | 26 ++++ ...270\217-selectable_metrics_and_sensors.md" | 41 ++++++ ...\215\260-node_health_ttl_configuration.md" | 34 +++++ ...235-dashboard_metric_sort_completeness.md" | 33 +++++ ...\360\237\233\235-dashboard_sort_filter.md" | 3 + ...\360\237\233\235-stale_node_indication.md" | 4 + ...7\215\260-startup_join_outcome_summary.md" | 33 +++++ ...\360\237\233\235-startup_configuration.md" | 4 + templates/dashboard.html | 13 +- terminal.go | 83 +++++++++++- terminal_test.go | 31 +++++ 15 files changed, 500 insertions(+), 67 deletions(-) create mode 100644 "tasks/0_planning/\360\237\222\241-\360\237\224\224-\360\237\233\251\357\270\217-selectable_metrics_and_sensors.md" create mode 100644 "tasks/3_done/\360\237\222\241-\360\237\224\224-\360\237\215\260-node_health_ttl_configuration.md" create mode 100644 "tasks/3_done/\360\237\222\241-\360\237\224\224-\360\237\233\235-dashboard_metric_sort_completeness.md" create mode 100644 "tasks/3_done/\360\237\233\240\357\270\217-\360\237\224\224-\360\237\215\260-startup_join_outcome_summary.md" diff --git a/README.md b/README.md index 0c30909..4016519 100644 --- a/README.md +++ b/README.md @@ -49,6 +49,7 @@ export PSSTD_SEEDS="10.0.1.20:7946,10.0.1.21:7946" # explicit peer sync addresse 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 ``` @@ -57,6 +58,10 @@ By default, psstd uses the OS hostname as the node identity. Set 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/main.go b/main.go index a275405..0576ef5 100644 --- a/main.go +++ b/main.go @@ -2,6 +2,7 @@ package main import ( "encoding/json" + "flag" "fmt" "log" "net" @@ -15,6 +16,10 @@ import ( "github.com/hashicorp/memberlist" ) +type cliOptions struct { + List bool +} + const ( envDB = "PSSTD_DB" envHTTP = "PSSTD_HTTP" @@ -23,16 +28,22 @@ const ( 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)) @@ -52,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(nodeName, gossipAddr, seeds) + runTerminalMirror(nodeName, gossipAddr, seeds, opts.List) return } log.Fatalf("pebble open: %v", err) @@ -84,7 +95,7 @@ func main() { } db = nil log.Printf("psstd already appears to be listening on %s; starting terminal mirror instead", gossipAddr) - runTerminalMirror(nodeName, gossipAddr, seeds) + runTerminalMirror(nodeName, gossipAddr, seeds, opts.List) return } log.Fatalf("memberlist create: %v", err) @@ -99,29 +110,28 @@ func main() { // 2. Scan for existing peers (mDNS + any explicit seeds) discovered := discoverPeers() allSeeds := append(seeds, discovered...) - logStartupConfig(startupConfig{ - NodeName: nodeName, - DBPath: dbPath, - HTTPAddr: httpAddr, - WebURL: webURL, - GossipAddr: gossipAddr, - WebEnabled: webEnabled, - Version: appVersion, - SeedCount: len(seeds), - MDNSCount: len(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(nodeName, webURL, appVersion, db, delegate) + go statsLoop(nodeName, webURL, appVersion, nodeTTL, db, delegate) // ── HTTP ───────────────────────────────────────────────────────────────── if webEnabled { @@ -135,21 +145,44 @@ func main() { } } +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 - SeedCount int - MDNSCount int + 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.Printf("psstd startup: version=%s node=%s db=%s web=%t http=%s advertise=%s gossip=%s seeds=%d mdns=%d", - cfg.Version, cfg.NodeName, cfg.DBPath, cfg.WebEnabled, cfg.HTTPAddr, cfg.WebURL, cfg.GossipAddr, cfg.SeedCount, cfg.MDNSCount) + 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 { @@ -168,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) @@ -203,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 { @@ -219,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 @@ -303,6 +336,21 @@ func nodeNameFromEnv(hostname string) (string, error) { 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 index 9c5dcbd..f37d62b 100644 --- a/main_test.go +++ b/main_test.go @@ -1,8 +1,11 @@ package main import ( + "errors" "os" + "strings" "testing" + "time" ) func withoutNodeNameEnv(t *testing.T) { @@ -54,3 +57,77 @@ func TestNodeNameFromEnvRejectsWhitespaceOverride(t *testing.T) { }) } } + +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 900174b..f6061f5 100644 --- a/render.go +++ b/render.go @@ -36,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 @@ -53,7 +53,7 @@ func collectStats(hostname, webURL, version string) (NodeStats, error) { Name: hostname, Version: version, WebURL: webURL, - TTLSeconds: int(defaultNodeTTL / time.Second), + TTLSeconds: int(ttl / time.Second), CPU: cpuPcts, MemUsed: vmStat.Used, MemTotal: vmStat.Total, @@ -260,6 +260,16 @@ func avgCPU(s NodeStats) float64 { return sum / float64(len(s.CPU)) } +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 { if len(nodes) == 0 { return 3000 @@ -450,15 +460,20 @@ func displayQuery(r *http.Request, winW, winH int) url.Values { // ── HTTP handler ────────────────────────────────────────────────────────────── type cellData struct { - Name string - URL string - HTML template.HTML - State healthState - CPU float64 - Mem float64 - Load float64 - Age float64 - 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 { @@ -498,15 +513,20 @@ func makeHandler(db *pebble.DB, selfName string) http.HandlerFunc { } cells = append(cells, cellData{ - Name: s.Name, - URL: nodeURL, - HTML: template.HTML(htmlBytes), - State: health.State, - CPU: avgCPU(s), - Mem: memPct, - Load: s.Load[0], - Age: health.Age.Seconds(), - 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 != "", }) } diff --git a/render_test.go b/render_test.go index 4621d12..8393716 100644 --- a/render_test.go +++ b/render_test.go @@ -133,9 +133,35 @@ func TestDashboardRendersClusterSummaryAndStateData(t *testing.T) { `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/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/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/3_done/\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" index 3bb5d3d..dfd0bb7 100644 --- "a/tasks/3_done/\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" @@ -27,3 +27,6 @@ Large clusters benefit from sorting by CPU% or memory to spot hot nodes quickly. - 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/3_done/\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" index 2c1c91e..3012f80 100644 --- "a/tasks/3_done/\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" @@ -26,3 +26,7 @@ Offline nodes and recently stale nodes can look too similar. Operators should qu - 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/3_done/\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" index 804975f..1c71b70 100644 --- "a/tasks/3_done/\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" @@ -23,3 +23,7 @@ psstd is easiest to like when it feels obvious what it is doing: which database - 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 119829f..549f3d9 100644 --- a/templates/dashboard.html +++ b/templates/dashboard.html @@ -414,9 +414,14 @@ @@ -433,7 +438,7 @@
{{range .Nodes}} -
+
{{if .Link}}{{.Name}}{{else}}{{.Name}}{{end}}
{{.HTML}}
diff --git a/terminal.go b/terminal.go index 64808d2..5e49ead 100644 --- a/terminal.go +++ b/terminal.go @@ -3,24 +3,38 @@ package main import ( "fmt" "log" + "os" "sort" + "strconv" "strings" "time" + "github.com/charmbracelet/lipgloss" "github.com/cockroachdb/pebble/v2" ) -func terminalRenderLoop(db *pebble.DB) { +const ( + terminalCellWidth = 42 + terminalCellGap = 2 +) + +var terminalCellStyle = lipgloss.NewStyle(). + Border(lipgloss.RoundedBorder()). + BorderForeground(lipgloss.Color("8")). + Padding(0, 1). + Width(terminalCellWidth) + +func terminalRenderLoop(db *pebble.DB, listMode bool) { ticker := time.NewTicker(2 * time.Second) defer ticker.Stop() for { - renderTerminalSnapshot(db) + renderTerminalSnapshot(db, listMode) <-ticker.C } } -func renderTerminalSnapshot(db *pebble.DB) { +func renderTerminalSnapshot(db *pebble.DB, listMode bool) { nodes, err := dbScanAll(db) if err != nil { log.Printf("terminal render: %v", err) @@ -37,13 +51,15 @@ func renderTerminalSnapshot(db *pebble.DB) { fmt.Printf("psstd terminal mirror - %d node(s) - %s\n", len(nodes), time.Now().Format(time.RFC3339)) fmt.Println(summarizeCluster(nodes).TerminalHeader()) fmt.Println() - fmt.Print(renderTerminalNodes(nodes)) + if listMode { + fmt.Print(renderTerminalNodes(nodes)) + return + } + fmt.Print(renderTerminalGrid(nodes, terminalWidth())) } func renderTerminalNodes(nodes []NodeStats) string { - sort.Slice(nodes, func(i, j int) bool { - return nodes[i].Name < nodes[j].Name - }) + sortNodesByName(nodes) var sb strings.Builder for i, node := range nodes { @@ -54,3 +70,56 @@ func renderTerminalNodes(nodes []NodeStats) string { } return sb.String() } + +func renderTerminalGrid(nodes []NodeStats, width int) string { + sortNodesByName(nodes) + if len(nodes) == 0 { + return "" + } + + cols := terminalColumns(width) + rows := make([]string, 0, (len(nodes)+cols-1)/cols) + for start := 0; start < len(nodes); start += cols { + end := start + cols + if end > len(nodes) { + end = len(nodes) + } + cells := make([]string, 0, end-start) + for _, node := range nodes[start:end] { + cells = append(cells, renderTerminalCell(node)) + } + rows = append(rows, lipgloss.JoinHorizontal(lipgloss.Top, cells...)) + } + return strings.Join(rows, "\n") + "\n" +} + +func renderTerminalCell(node NodeStats) string { + return terminalCellStyle.Render(strings.TrimRight(renderANSI(node), "\n")) +} + +func terminalColumns(width int) int { + cellAndGap := terminalCellWidth + terminalCellGap + if width < cellAndGap { + return 1 + } + cols := (width + terminalCellGap) / cellAndGap + if cols < 1 { + return 1 + } + return cols +} + +func terminalWidth() int { + if value := os.Getenv("COLUMNS"); value != "" { + if width, err := strconv.Atoi(value); err == nil && width > 0 { + return width + } + } + return 100 +} + +func sortNodesByName(nodes []NodeStats) { + sort.Slice(nodes, func(i, j int) bool { + return nodes[i].Name < nodes[j].Name + }) +} diff --git a/terminal_test.go b/terminal_test.go index d60f2c1..7baad62 100644 --- a/terminal_test.go +++ b/terminal_test.go @@ -22,3 +22,34 @@ func TestRenderTerminalNodesSortsByName(t *testing.T) { t.Fatalf("nodes were not sorted by name:\n%s", out) } } + +func TestRenderTerminalGridPacksStableRows(t *testing.T) { + now := time.Now().UnixNano() + out := renderTerminalGrid([]NodeStats{ + {Name: "c-node", Version: appVersion, CPU: []float64{1}, MemTotal: 1, UpdatedAt: now}, + {Name: "a-node", Version: appVersion, CPU: []float64{1}, MemTotal: 1, UpdatedAt: now}, + {Name: "b-node", Version: appVersion, CPU: []float64{1}, MemTotal: 1, UpdatedAt: now}, + }, terminalCellWidth*2+terminalCellGap) + + first := strings.Index(out, "a-node") + second := strings.Index(out, "b-node") + third := strings.Index(out, "c-node") + if first < 0 || second < 0 || third < 0 { + t.Fatalf("missing rendered nodes:\n%s", out) + } + if first > second || second > third { + t.Fatalf("grid nodes were not sorted by name:\n%s", out) + } + if strings.Count(out, "╭") != 3 { + t.Fatalf("grid did not render three bordered cells:\n%s", out) + } +} + +func TestTerminalColumns(t *testing.T) { + if got := terminalColumns(terminalCellWidth - 1); got != 1 { + t.Fatalf("narrow columns = %d, want 1", got) + } + if got := terminalColumns(terminalCellWidth*2 + terminalCellGap); got != 2 { + t.Fatalf("wide columns = %d, want 2", got) + } +} From 9010a9c2b4bca8ec5b9b79f95a1bd6f1f2bc21c2 Mon Sep 17 00:00:00 2001 From: Andrew LeTourneau Date: Mon, 18 May 2026 09:11:51 -0500 Subject: [PATCH 3/4] ci: relaxing rules a bit --- .commitlintrc.js | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.commitlintrc.js b/.commitlintrc.js index 84dcb12..d486aa6 100644 --- a/.commitlintrc.js +++ b/.commitlintrc.js @@ -1,3 +1,6 @@ module.exports = { extends: ['@commitlint/config-conventional'], + rules: { + 'body-max-line-length': [0], + }, }; From bbcb45d6da7882b18fe5e6267276e464c38c4a35 Mon Sep 17 00:00:00 2001 From: Andrew LeTourneau Date: Mon, 18 May 2026 09:28:28 -0500 Subject: [PATCH 4/4] ci: relaxing commitlint --- .commitlintrc.js | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/.commitlintrc.js b/.commitlintrc.js index d486aa6..0025947 100644 --- a/.commitlintrc.js +++ b/.commitlintrc.js @@ -2,5 +2,22 @@ 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', + ]], }, };