Skip to content

Commit 0332a44

Browse files
committed
feat: terminal cell "brick-laying"
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.
1 parent 82bfbbf commit 0332a44

15 files changed

Lines changed: 500 additions & 67 deletions

README.md

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,7 @@ export PSSTD_SEEDS="10.0.1.20:7946,10.0.1.21:7946" # explicit peer sync addresse
4949
export PSSTD_DB="./data" # local state directory
5050
export PSSTD_WEB="true" # set false for sync-only nodes
5151
export PSSTD_NODE_NAME="rack-a-01" # optional stable node identity override
52+
export PSSTD_NODE_TTL="15s" # how long this node's heartbeat stays online
5253
./psstd
5354
```
5455

@@ -57,6 +58,10 @@ By default, psstd uses the OS hostname as the node identity. Set
5758
would otherwise publish the same hostname. The override must be non-empty and
5859
must not contain whitespace.
5960

61+
Each node publishes its own heartbeat TTL with `PSSTD_NODE_TTL`. Shorter values
62+
make stale/offline indication react faster; longer values are better for slow or
63+
lossy networks. The default is `15s`, and values must be at least `2s`.
64+
6065
## Discovery
6166

6267
| Environment | How nodes find each other |

main.go

Lines changed: 84 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ package main
22

33
import (
44
"encoding/json"
5+
"flag"
56
"fmt"
67
"log"
78
"net"
@@ -15,6 +16,10 @@ import (
1516
"github.com/hashicorp/memberlist"
1617
)
1718

19+
type cliOptions struct {
20+
List bool
21+
}
22+
1823
const (
1924
envDB = "PSSTD_DB"
2025
envHTTP = "PSSTD_HTTP"
@@ -23,16 +28,22 @@ const (
2328
envHTTPAd = "PSSTD_ADVERTISE_HTTP"
2429
envWeb = "PSSTD_WEB" // "true" to enable HTTP, default true
2530
envNodeName = "PSSTD_NODE_NAME"
31+
envNodeTTL = "PSSTD_NODE_TTL"
2632
gossipPort = 7946
2733
httpPort = 8080
2834
)
2935

3036
func main() {
37+
opts := parseCLI(os.Args[1:])
3138
hostname, _ := os.Hostname()
3239
nodeName, err := nodeNameFromEnv(hostname)
3340
if err != nil {
3441
log.Fatalf("node name: %v", err)
3542
}
43+
nodeTTL, err := nodeTTLFromEnv()
44+
if err != nil {
45+
log.Fatalf("node ttl: %v", err)
46+
}
3647

3748
dbPath := envOr(envDB, "./data")
3849
httpAddr := envOr(envHTTP, fmt.Sprintf(":%d", httpPort))
@@ -52,7 +63,7 @@ func main() {
5263
if err != nil {
5364
if pebbleLockHeld(err) {
5465
log.Printf("psstd already appears to own %s; starting terminal mirror instead", dbPath)
55-
runTerminalMirror(nodeName, gossipAddr, seeds)
66+
runTerminalMirror(nodeName, gossipAddr, seeds, opts.List)
5667
return
5768
}
5869
log.Fatalf("pebble open: %v", err)
@@ -84,7 +95,7 @@ func main() {
8495
}
8596
db = nil
8697
log.Printf("psstd already appears to be listening on %s; starting terminal mirror instead", gossipAddr)
87-
runTerminalMirror(nodeName, gossipAddr, seeds)
98+
runTerminalMirror(nodeName, gossipAddr, seeds, opts.List)
8899
return
89100
}
90101
log.Fatalf("memberlist create: %v", err)
@@ -99,29 +110,28 @@ func main() {
99110
// 2. Scan for existing peers (mDNS + any explicit seeds)
100111
discovered := discoverPeers()
101112
allSeeds := append(seeds, discovered...)
102-
logStartupConfig(startupConfig{
103-
NodeName: nodeName,
104-
DBPath: dbPath,
105-
HTTPAddr: httpAddr,
106-
WebURL: webURL,
107-
GossipAddr: gossipAddr,
108-
WebEnabled: webEnabled,
109-
Version: appVersion,
110-
SeedCount: len(seeds),
111-
MDNSCount: len(discovered),
112-
})
113+
joinedPeers := 0
114+
joinErr := error(nil)
113115
if len(allSeeds) > 0 {
114-
if n, err := list.Join(allSeeds); err != nil {
115-
log.Printf("join warning (joined %d): %v", n, err)
116-
} else {
117-
log.Printf("joined cluster, %d peer(s)", n)
118-
}
119-
} else {
120-
log.Println("no peers found — running solo, will be discovered by others")
116+
joinedPeers, joinErr = list.Join(allSeeds)
121117
}
118+
logStartupConfig(startupConfig{
119+
NodeName: nodeName,
120+
DBPath: dbPath,
121+
HTTPAddr: httpAddr,
122+
WebURL: webURL,
123+
GossipAddr: gossipAddr,
124+
WebEnabled: webEnabled,
125+
Version: appVersion,
126+
NodeTTL: nodeTTL,
127+
SeedCount: len(seeds),
128+
MDNSCount: len(discovered),
129+
JoinedPeers: joinedPeers,
130+
JoinErr: joinErr,
131+
})
122132

123133
// ── Stats heartbeat ─────────────────────────────────────────────────────
124-
go statsLoop(nodeName, webURL, appVersion, db, delegate)
134+
go statsLoop(nodeName, webURL, appVersion, nodeTTL, db, delegate)
125135

126136
// ── HTTP ─────────────────────────────────────────────────────────────────
127137
if webEnabled {
@@ -135,21 +145,44 @@ func main() {
135145
}
136146
}
137147

148+
func parseCLI(args []string) cliOptions {
149+
fs := flag.NewFlagSet("psstd", flag.ExitOnError)
150+
fs.SetOutput(os.Stderr)
151+
var opts cliOptions
152+
fs.BoolVar(&opts.List, "l", false, "render terminal mirror as a vertical node list")
153+
fs.BoolVar(&opts.List, "list", false, "render terminal mirror as a vertical node list")
154+
_ = fs.Parse(args)
155+
return opts
156+
}
157+
138158
type startupConfig struct {
139-
NodeName string
140-
DBPath string
141-
HTTPAddr string
142-
WebURL string
143-
GossipAddr string
144-
WebEnabled bool
145-
Version string
146-
SeedCount int
147-
MDNSCount int
159+
NodeName string
160+
DBPath string
161+
HTTPAddr string
162+
WebURL string
163+
GossipAddr string
164+
WebEnabled bool
165+
Version string
166+
NodeTTL time.Duration
167+
SeedCount int
168+
MDNSCount int
169+
JoinedPeers int
170+
JoinErr error
148171
}
149172

150173
func logStartupConfig(cfg startupConfig) {
151-
log.Printf("psstd startup: version=%s node=%s db=%s web=%t http=%s advertise=%s gossip=%s seeds=%d mdns=%d",
152-
cfg.Version, cfg.NodeName, cfg.DBPath, cfg.WebEnabled, cfg.HTTPAddr, cfg.WebURL, cfg.GossipAddr, cfg.SeedCount, cfg.MDNSCount)
174+
log.Print(startupSummary(cfg))
175+
}
176+
177+
func startupSummary(cfg startupConfig) string {
178+
join := "solo"
179+
if cfg.JoinErr != nil {
180+
join = fmt.Sprintf("warning joined=%d error=%q", cfg.JoinedPeers, cfg.JoinErr)
181+
} else if cfg.JoinedPeers > 0 {
182+
join = fmt.Sprintf("joined=%d", cfg.JoinedPeers)
183+
}
184+
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",
185+
cfg.Version, cfg.NodeName, cfg.DBPath, cfg.WebEnabled, cfg.HTTPAddr, cfg.WebURL, cfg.GossipAddr, cfg.NodeTTL, cfg.SeedCount, cfg.MDNSCount, join)
153186
}
154187

155188
func pebbleLockHeld(err error) bool {
@@ -168,7 +201,7 @@ func addressInUse(err error) bool {
168201
strings.Contains(msg, "bind: only one usage of each socket address")
169202
}
170203

171-
func runTerminalMirror(hostname, gossipAddr string, seeds []string) {
204+
func runTerminalMirror(hostname, gossipAddr string, seeds []string, listMode bool) {
172205
tmpDir, err := os.MkdirTemp("", "psstd-view-*")
173206
if err != nil {
174207
log.Fatalf("terminal mirror temp db: %v", err)
@@ -203,7 +236,7 @@ func runTerminalMirror(hostname, gossipAddr string, seeds []string) {
203236
log.Printf("terminal mirror joined cluster, %d peer(s)", n)
204237
}
205238

206-
terminalRenderLoop(db)
239+
terminalRenderLoop(db, listMode)
207240
}
208241

209242
func terminalMirrorSeeds(gossipAddr string, seeds []string) []string {
@@ -219,11 +252,11 @@ func terminalMirrorSeeds(gossipAddr string, seeds []string) []string {
219252

220253
// ── Stats loop ───────────────────────────────────────────────────────────────
221254

222-
func statsLoop(hostname, webURL, version string, db *pebble.DB, d *kvDelegate) {
255+
func statsLoop(hostname, webURL, version string, ttl time.Duration, db *pebble.DB, d *kvDelegate) {
223256
ticker := time.NewTicker(2 * time.Second)
224257
defer ticker.Stop()
225258
for range ticker.C {
226-
stats, err := collectStats(hostname, webURL, version)
259+
stats, err := collectStats(hostname, webURL, version, ttl)
227260
if err != nil {
228261
log.Printf("stats error: %v", err)
229262
continue
@@ -303,6 +336,21 @@ func nodeNameFromEnv(hostname string) (string, error) {
303336
return override, nil
304337
}
305338

339+
func nodeTTLFromEnv() (time.Duration, error) {
340+
value, ok := os.LookupEnv(envNodeTTL)
341+
if !ok || value == "" {
342+
return defaultNodeTTL, nil
343+
}
344+
ttl, err := time.ParseDuration(value)
345+
if err != nil {
346+
return 0, fmt.Errorf("%s must be a duration such as 15s or 1m: %w", envNodeTTL, err)
347+
}
348+
if ttl < 2*time.Second {
349+
return 0, fmt.Errorf("%s must be at least 2s", envNodeTTL)
350+
}
351+
return ttl, nil
352+
}
353+
306354
func splitCSV(s string) []string {
307355
var out []string
308356
for _, p := range strings.Split(s, ",") {

main_test.go

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,11 @@
11
package main
22

33
import (
4+
"errors"
45
"os"
6+
"strings"
57
"testing"
8+
"time"
69
)
710

811
func withoutNodeNameEnv(t *testing.T) {
@@ -54,3 +57,77 @@ func TestNodeNameFromEnvRejectsWhitespaceOverride(t *testing.T) {
5457
})
5558
}
5659
}
60+
61+
func TestNodeTTLFromEnvDefaultAndOverride(t *testing.T) {
62+
t.Setenv(envNodeTTL, "")
63+
got, err := nodeTTLFromEnv()
64+
if err != nil {
65+
t.Fatalf("default ttl: %v", err)
66+
}
67+
if got != defaultNodeTTL {
68+
t.Fatalf("default ttl = %s, want %s", got, defaultNodeTTL)
69+
}
70+
71+
t.Setenv(envNodeTTL, "45s")
72+
got, err = nodeTTLFromEnv()
73+
if err != nil {
74+
t.Fatalf("override ttl: %v", err)
75+
}
76+
if got != 45*time.Second {
77+
t.Fatalf("override ttl = %s, want 45s", got)
78+
}
79+
}
80+
81+
func TestNodeTTLFromEnvRejectsInvalidValues(t *testing.T) {
82+
for _, value := range []string{"soon", "1s"} {
83+
t.Run(value, func(t *testing.T) {
84+
t.Setenv(envNodeTTL, value)
85+
if got, err := nodeTTLFromEnv(); err == nil {
86+
t.Fatalf("ttl = %s, want error", got)
87+
}
88+
})
89+
}
90+
}
91+
92+
func TestStartupSummaryIncludesJoinOutcome(t *testing.T) {
93+
base := startupConfig{
94+
Version: "v1",
95+
NodeName: "node-a",
96+
DBPath: "./data",
97+
HTTPAddr: ":8080",
98+
WebURL: "http://node-a:8080",
99+
GossipAddr: ":7946",
100+
WebEnabled: true,
101+
NodeTTL: 15 * time.Second,
102+
SeedCount: 2,
103+
MDNSCount: 1,
104+
}
105+
106+
joined := base
107+
joined.JoinedPeers = 3
108+
if got := startupSummary(joined); !strings.Contains(got, "join=joined=3") {
109+
t.Fatalf("joined summary missing outcome: %s", got)
110+
}
111+
112+
solo := base
113+
if got := startupSummary(solo); !strings.Contains(got, "join=solo") {
114+
t.Fatalf("solo summary missing outcome: %s", got)
115+
}
116+
117+
warn := base
118+
warn.JoinedPeers = 1
119+
warn.JoinErr = errors.New("partial join")
120+
if got := startupSummary(warn); !strings.Contains(got, `join=warning joined=1 error="partial join"`) {
121+
t.Fatalf("warning summary missing outcome: %s", got)
122+
}
123+
}
124+
125+
func TestParseCLIListFlag(t *testing.T) {
126+
for _, args := range [][]string{{"-l"}, {"--list"}} {
127+
t.Run(strings.Join(args, " "), func(t *testing.T) {
128+
if opts := parseCLI(args); !opts.List {
129+
t.Fatalf("List = false, want true")
130+
}
131+
})
132+
}
133+
}

0 commit comments

Comments
 (0)