Skip to content

Commit 68abb35

Browse files
fix: ansi theme colors, sampler interface spike, config and render audit
- ansi theme: carry palette indices end to end so the waveform uses the terminal's own colors instead of rendering black; blue down, green up, bright crests, honest 16-color and dumb-terminal escapes - sampler: reset the window when the sampled interface changes, which used to fabricate a multi-terabyte spike in auto mode - config: stop dropping history on save, reload against defaults, reject NaN smoothing, accept 10m/2h windows, print warnings, and name the offending value in them - history: write stats.json atomically so a crash cannot truncate it - format: clamp pad/spark, populate DownSamples, compile once per stream - render: fix descending-gradient uint8 wrap and the 256-color gray ramp - collector: report a down link as down, not up - bump VERSION to 0.3.5
1 parent 53d0f4b commit 68abb35

28 files changed

Lines changed: 929 additions & 216 deletions

‎CHANGELOG.md‎

Lines changed: 52 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,54 @@
1+
## [0.3.5] - 2026-10-18
2+
3+
Theme correctness and an audit pass over the sampling, config, and render
4+
paths. No new features.
5+
6+
### Fixed
7+
- **The `ansi` theme rendered a black waveform.** Palette indices carried in
8+
the gradient LUT were converted to RGB before reaching the canvas, and
9+
non-hex strings parsed as `#000000`, so the graph was invisible on that
10+
theme. The LUT now carries palette indices end to end, and the canvas emits
11+
classic 16-color SGR codes (`30-37`/`90-97`) for them, so the terminal's own
12+
palette is used rather than a 256-color approximation.
13+
- **The `ansi` theme discoloured its accents.** Download digits and swatches
14+
were sampled from the middle of the gradient and came out cyan, upload came
15+
out yellow. Accents now follow the theme's semantic tokens: bright blue for
16+
download, bright green for upload, like every other theme. Status colors
17+
moved to the bright variants for legibility on dark backgrounds, and both
18+
gradients now climb to a bright crest instead of dimming at the top.
19+
- **16-color terminals received 256-color escapes.** `Cap16` now quantizes to
20+
the 16 palette entries and emits classic SGR; `TERM=dumb` disables escapes
21+
entirely. The 256-color gray ramp mapped its brightest gray one step short
22+
of white.
23+
- **A multi-terabyte rate spike when the sampled interface changed.** With
24+
`interface = "auto"`, switching NICs subtracted unrelated lifetime counters.
25+
The sampler now resets its window on an interface change and reports a
26+
zero-baseline tick instead of fabricated traffic.
27+
- **`history` was silently deleted from the config.** The rendered config
28+
omitted the key, so saving (for example from the theme picker) rewrote the
29+
file without it.
30+
- **Config warnings named the corrected value, not the offending one**, and
31+
were never printed. `flow` now surfaces load-time warnings on stderr; NaN
32+
`graph.smoothing` is rejected; `graph.window` accepts `10m`/`2h` style
33+
values and warns on unrecognized ones; the flat `view` key is validated like
34+
its section form.
35+
- **`Reload` decoded over a zero config**, so keys deleted from the file
36+
reverted to zero values instead of defaults.
37+
- **Daily totals could be lost on crash.** `stats.json` is now written
38+
atomically (temp + rename) instead of truncating in place.
39+
- **`NewGradientLUT` wrapped at descending gradients** (bright-to-dark ramps
40+
snapped back to bright at the midpoint) due to uint8 subtraction.
41+
- **Interface details reported down links as up** when any flag was set; only
42+
the `up` flag counts now.
43+
- **`--format` template edge cases**: negative `{{pad}}` widths and
44+
negative/NaN `{{spark}}` samples are clamped, `{{spark .DownSamples}}` is
45+
populated, and the template is compiled once instead of per sample.
46+
- **The UI title showed `dev` for `go install @latest` builds** while
47+
`--version` showed the module version.
48+
- `sampler.New` clamps a non-positive interval (it reached
49+
`time.NewTicker`, which panics); `config.Watch` guards its ticker the same
50+
way.
51+
152
## [0.3.4] - 2026-10-09
253

354
Reliability, hardening, and verification patch.
@@ -19,7 +70,7 @@ Reliability, hardening, and verification patch.
1970
silently skipping. A separate `make dev-check` target is provided for
2071
fast local cycles without the linter.
2172
- **CI pinned and synchronised.** CI workflows now synchronise on `go.mod`
22-
via `go-version-file`, enforce the pinned `golangci-lint` v2.14.0 release,
73+
via `go-version-file`, enforce the pinned `golangci-lint` v2.1.6 release,
2374
run `make check`, and exercise a dedicated CLI smoke test.
2475

2576
### Added

‎VERSION‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
0.3.4
1+
0.3.5

‎cmd/flow/main.go‎

Lines changed: 28 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,11 @@ func main() {
9898
fmt.Fprintf(os.Stderr, "flow: config error: %v\n", err)
9999
// Non-fatal: continue with defaults already populated.
100100
}
101+
// Surface load-time warnings (unknown keys, clamped values) so config
102+
// mistakes are visible instead of silently ignored.
103+
for _, w := range cfg.Warnings() {
104+
fmt.Fprintf(os.Stderr, "flow: config: %s\n", w)
105+
}
101106

102107
if *flagIface != "" {
103108
cfg.Interface = *flagIface
@@ -191,7 +196,7 @@ func main() {
191196
initialIface = "auto"
192197
}
193198

194-
ui.SetVersion(version)
199+
ui.SetVersion(buildVersion())
195200

196201
model := ui.New(cfg, smp, ifaces, initialIface, cancel, forced)
197202
if *flagWindow != "" {
@@ -391,6 +396,14 @@ func autoUnitExt(bps float64, bits bool) string {
391396
}
392397

393398
func runFormat(smp *sampler.Sampler, tmplStr string, stream bool, width int, refresh time.Duration) {
399+
// Compile before sampling: a bad template is a usage error and should fail
400+
// immediately, not after the first sample.
401+
tmpl, err := format.Compile(tmplStr)
402+
if err != nil {
403+
fmt.Fprintf(os.Stderr, "flow: template error: %v\n", err)
404+
os.Exit(2)
405+
}
406+
394407
ctx, cancel := context.WithCancel(context.Background())
395408
defer cancel()
396409

@@ -404,13 +417,24 @@ func runFormat(smp *sampler.Sampler, tmplStr string, stream bool, width int, ref
404417
tr := history.NewTracker()
405418
tr.Record(s1.DownBps, s1.UpBps, sampleInterval(s1, refresh))
406419

420+
// Today totals are read once: they only change when this process writes
421+
// them, which format mode never does.
422+
todayDown, todayUp := todayTotals()
423+
424+
// recentDown feeds {{spark .DownSamples}}; it mirrors the sample stream
425+
// rather than the full history so streaming stays allocation-light.
426+
recentDown := make([]float64, 0, 60)
427+
407428
for s := range smp.Out {
408429
if s.Err != nil {
409430
fmt.Fprintf(os.Stderr, "flow: %v\n", s.Err)
410431
os.Exit(1)
411432
}
412433
tr.Record(s.DownBps, s.UpBps, sampleInterval(s, refresh))
413-
todayDown, todayUp := todayTotals()
434+
recentDown = append(recentDown, s.DownBps)
435+
if len(recentDown) > 60 {
436+
recentDown = recentDown[len(recentDown)-60:]
437+
}
414438
data := format.Data{
415439
Iface: s.Interface,
416440
DownBps: s.DownBps,
@@ -421,9 +445,10 @@ func runFormat(smp *sampler.Sampler, tmplStr string, stream bool, width int, ref
421445
PeakUpBps: tr.PeakUp,
422446
TodayDown: format.Bytes(todayDown),
423447
TodayUp: format.Bytes(todayUp),
448+
DownSamples: recentDown,
424449
Time: s.At.UTC().Format(time.RFC3339Nano),
425450
}
426-
out, err := format.RenderTemplate(tmplStr, data)
451+
out, err := format.Render(tmpl, data)
427452
if err != nil {
428453
fmt.Fprintf(os.Stderr, "flow: template error: %v\n", err)
429454
os.Exit(2)

‎docs/themes.md‎

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,10 @@ directory.
99
`tokyo-night`, `rose-pine`, `kanagawa`, `ansi`.
1010

1111
`ansi` steps through terminal palette indexes instead of RGB, so `flow` blends
12-
into whatever colorscheme your terminal defines. It is the safest choice on
12+
into whatever colorscheme your terminal defines. Waveform colors are emitted as
13+
classic 16-color SGR codes (`30-37`/`90-97`), which every terminal resolves
14+
through its own palette; download runs blue -> cyan, upload runs green ->
15+
yellow, and both crest toward bright white. It is the safest choice on
1316
terminals without truecolor.
1417

1518
## semantic tokens
@@ -84,7 +87,7 @@ tokens are added.
8487

8588
`NO_COLOR` in the environment and `--no-color` on the command line both disable
8689
color entirely. `COLORTERM=truecolor` and a `TERM` containing `256color` select
87-
the gradient quality.
90+
the gradient quality; `TERM=dumb` disables color output.
8891

8992
## switching
9093

‎internal/collector/collector.go‎

Lines changed: 3 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,6 @@ type Reader interface {
2525

2626
type counter struct {
2727
bytes uint64
28-
at time.Time
2928
}
3029

3130
// Collector reads interface byte counters.
@@ -80,7 +79,7 @@ func (c *Collector) Read() (Snapshot, error) {
8079
if c.iface != "auto" {
8180
for _, s := range stats {
8281
if s.Name == c.iface {
83-
c.prev[s.Name] = counter{bytes: s.BytesRecv + s.BytesSent, at: time.Now()}
82+
c.prev[s.Name] = counter{bytes: s.BytesRecv + s.BytesSent}
8483
return Snapshot{Interface: s.Name, RxBytes: s.BytesRecv, TxBytes: s.BytesSent}, nil
8584
}
8685
}
@@ -137,7 +136,7 @@ func (c *Collector) pickAuto(stats []gnet.IOCountersStat) (string, uint64, uint6
137136
for i := range candidates {
138137
s := candidates[i]
139138
seen[s.Name] = true
140-
c.prev[s.Name] = counter{bytes: s.BytesRecv + s.BytesSent, at: now}
139+
c.prev[s.Name] = counter{bytes: s.BytesRecv + s.BytesSent}
141140
}
142141
for name := range c.prev {
143142
if !seen[name] {
@@ -270,7 +269,7 @@ func InterfaceDetails(name string) (*InterfaceDetail, error) {
270269
detail := &InterfaceDetail{
271270
Name: iface.Name,
272271
HardwareAddr: iface.HardwareAddr,
273-
IsUp: len(iface.Flags) > 0,
272+
IsUp: false,
274273
Mtu: iface.MTU,
275274
}
276275
for _, addr := range iface.Addrs {
@@ -333,11 +332,3 @@ func isVirtual(name string) bool {
333332
}
334333

335334
var virtualPrefixes = []string{"docker", "br-", "veth", "virbr", "vmnet", "vbox", "tun", "tap", "wg", "zt"}
336-
337-
// pickAutoForTest drives auto-selection with a supplied counters set and
338-
// returns the chosen interface. It exists so the selection policy can be tested
339-
// against specific traffic shapes without depending on real host interfaces.
340-
func (c *Collector) pickAutoForTest(s []gnet.IOCountersStat) string {
341-
name, _, _ := c.pickAuto(s)
342-
return name
343-
}

‎internal/collector/collector_test.go‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,14 @@ func stat(name string, rx, tx uint64) gnet.IOCountersStat {
1616
// stats builds a slice of counters rows.
1717
func stats(rows ...gnet.IOCountersStat) []gnet.IOCountersStat { return rows }
1818

19+
// pickAutoForTest drives auto-selection with a supplied counters set and
20+
// returns the chosen interface, so the selection policy can be tested against
21+
// specific traffic shapes without depending on real host interfaces.
22+
func (c *Collector) pickAutoForTest(s []gnet.IOCountersStat) string {
23+
name, _, _ := c.pickAuto(s)
24+
return name
25+
}
26+
1927
func TestIsVirtual(t *testing.T) {
2028
virtual := []string{"lo", "lo0", "docker0", "br-1234", "veth123", "virbr0", "vmnet8", "vboxnet0", "tun0", "tap0", "wg0"}
2129
for _, n := range virtual {

0 commit comments

Comments
 (0)