From 5bc547062c3dd425c5702b214cebcdedc6b1121f Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Mon, 7 Sep 2026 00:39:30 +0000 Subject: [PATCH] fix: match native demos to TypeScript reference; release 0.1.12 --- apps/benchmark/package.json | 2 +- apps/demo/package.json | 4 +- apps/demo/scripts/native-parity.ts | 4 +- apps/demo/src/main.ts | 2 +- apps/web/app/docs/page.tsx | 9 +- apps/web/app/page.tsx | 2 +- apps/web/package.json | 2 +- bun.lock | 8 +- examples/package.json | 2 +- packages/hqtui/package.json | 2 +- packages/hqtui/src/cli.ts | 2 +- ports/c/CMakeLists.txt | 2 +- ports/conformance/DEMO.md | 36 ++ .../fixtures/demo-parity.json} | 0 ports/cpp/CMakeLists.txt | 2 +- ports/go/internal/demo/components.go | 110 +++++ ports/go/internal/demo/dashboard.go | 444 ++++++++++++++++++ ports/go/internal/demo/demo_test.go | 38 +- ports/go/internal/demo/main.go | 10 +- ports/go/internal/demo/model.go | 22 +- ports/go/internal/demo/parity_test.go | 105 +++++ ports/go/internal/demo/showcase.go | 157 +++++++ ports/go/internal/demo/telemetry_view.go | 404 ++++++++++++++++ ports/go/internal/demo/view.go | 375 ++------------- ports/python/hqtui/__init__.py | 4 +- ports/python/hqtui_demo/__init__.py | 2 +- ports/python/hqtui_demo/__main__.py | 3 +- ports/python/hqtui_demo/dashboard.py | 181 +++++++ ports/python/hqtui_demo/model.py | 9 +- ports/python/hqtui_demo/showcase.py | 134 ++++++ ports/python/hqtui_demo/telemetry_view.py | 140 ++++++ ports/python/hqtui_demo/view.py | 260 +--------- ports/python/pyproject.toml | 2 +- ports/python/tests/test_demo.py | 24 +- ports/python/tests/test_parity.py | 28 ++ ports/rust/Cargo.toml | 2 +- ports/rust/demo/Cargo.toml | 4 +- ports/rust/demo/src/tests.rs | 2 +- ports/rust/src/lib.rs | 2 +- ports/zig/build.zig | 1 + ports/zig/build.zig.zon | 2 +- ports/zig/demo/dashboard.zig | 289 ++++++++++++ ports/zig/demo/main.zig | 17 +- ports/zig/demo/model.zig | 17 +- ports/zig/demo/reference.zig | 195 ++++++++ ports/zig/demo/showcase.zig | 350 ++++++++++++++ ports/zig/demo/telemetry_view.zig | 335 +++++++++++++ ports/zig/demo/tests.zig | 73 ++- ports/zig/demo/view.zig | 291 ++---------- ports/zig/src/widgets/meters.zig | 5 +- 50 files changed, 3228 insertions(+), 888 deletions(-) create mode 100644 ports/conformance/DEMO.md rename ports/{rust/demo/parity.json => conformance/fixtures/demo-parity.json} (100%) create mode 100644 ports/go/internal/demo/components.go create mode 100644 ports/go/internal/demo/dashboard.go create mode 100644 ports/go/internal/demo/parity_test.go create mode 100644 ports/go/internal/demo/showcase.go create mode 100644 ports/go/internal/demo/telemetry_view.go create mode 100644 ports/python/hqtui_demo/dashboard.py create mode 100644 ports/python/hqtui_demo/showcase.py create mode 100644 ports/python/hqtui_demo/telemetry_view.py create mode 100644 ports/python/tests/test_parity.py create mode 100644 ports/zig/demo/dashboard.zig create mode 100644 ports/zig/demo/reference.zig create mode 100644 ports/zig/demo/showcase.zig create mode 100644 ports/zig/demo/telemetry_view.zig diff --git a/apps/benchmark/package.json b/apps/benchmark/package.json index d243012..7a995a1 100644 --- a/apps/benchmark/package.json +++ b/apps/benchmark/package.json @@ -7,6 +7,6 @@ "start": "bun src/main.ts" }, "dependencies": { - "@profullstack/hqtui": "^0.1.11" + "@profullstack/hqtui": "^0.1.12" } } diff --git a/apps/demo/package.json b/apps/demo/package.json index 1e34f46..97d02d7 100644 --- a/apps/demo/package.json +++ b/apps/demo/package.json @@ -1,6 +1,6 @@ { "name": "@profullstack/hqtui-demo", - "version": "0.1.11", + "version": "0.1.12", "description": "The HQTUI reference dashboard: a btop-grade terminal system monitor. Runs on real system metrics or a deterministic simulation.", "license": "MIT", "type": "module", @@ -27,7 +27,7 @@ "audit:scroll": "bun scripts/scrollaudit.ts" }, "dependencies": { - "@profullstack/hqtui": "^0.1.11" + "@profullstack/hqtui": "^0.1.12" }, "publishConfig": { "access": "public" diff --git a/apps/demo/scripts/native-parity.ts b/apps/demo/scripts/native-parity.ts index e256dec..5bba506 100644 --- a/apps/demo/scripts/native-parity.ts +++ b/apps/demo/scripts/native-parity.ts @@ -29,5 +29,5 @@ for (const theme of ["dark", "dracula", "nord"]) { } } } -writeFileSync(new URL("ports/rust/demo/parity.json",root),JSON.stringify(cases,null,2)+"\n"); -console.log(`Generated ${cases.length} TypeScript dashboard reference cases.`); +writeFileSync(new URL("ports/conformance/fixtures/demo-parity.json",root),JSON.stringify(cases,null,2)+"\n"); +console.log(`Generated ${cases.length} shared TypeScript screen reference cases.`); diff --git a/apps/demo/src/main.ts b/apps/demo/src/main.ts index bc97726..5f1268e 100755 --- a/apps/demo/src/main.ts +++ b/apps/demo/src/main.ts @@ -51,7 +51,7 @@ function parseArgs(argv: string[]): Options { case "-h": case "--help": printHelp(); process.exit(0); case "-v": - case "--version": console.log("hqtui-demo 0.1.11"); process.exit(0); + case "--version": console.log("hqtui-demo 0.1.12"); process.exit(0); } } return options; diff --git a/apps/web/app/docs/page.tsx b/apps/web/app/docs/page.tsx index 1781fe5..3bd960b 100644 --- a/apps/web/app/docs/page.tsx +++ b/apps/web/app/docs/page.tsx @@ -81,9 +81,12 @@ export default async function Docs() { They work from any directory and never switch branches, reset, or pull in your checkout. All four dashboard commands now launch ten-screen native demos. Live metrics currently require Linux; use --sim for generated sample data on other platforms. - Rust screen bodies pass 120 cell-by-cell TypeScript comparisons, but live-data - collection and interaction parity are incomplete. Go, Python and Zig also still - need layout-parity work. Use 1–9 / 0 or Tab to change screens and q to quit. + All four native demos now use the TypeScript reference's ten screen layouts, + including its responsive dashboard, detailed telemetry tabs and widget showcases. + Each port is checked against 120 shared TypeScript reference frames across four + terminal sizes and three themes. Live-data availability still depends on the host + and permissions; full cross-platform collection and interaction parity is not claimed. + Use 1–9 / 0 or Tab to change screens and q to quit. Headless screenshots work without a TTY. Zig requires version 0.16.

diff --git a/apps/web/app/page.tsx b/apps/web/app/page.tsx index 18126e3..2add662 100644 --- a/apps/web/app/page.tsx +++ b/apps/web/app/page.tsx @@ -149,7 +149,7 @@ export default async function Home() { High Quality Terminal UI for TypeScript, Rust, Go, Python and Zig

- v0.1.11 · 5 languages · MIT + v0.1.12 · 5 languages · MIT

Terminal dashboards that diff --git a/apps/web/package.json b/apps/web/package.json index 9ad0cd0..d1d7e4a 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -9,7 +9,7 @@ }, "dependencies": { "@base-ui/react": "1.7.0", - "@profullstack/hqtui": "^0.1.11", + "@profullstack/hqtui": "^0.1.12", "class-variance-authority": "0.7.1", "clsx": "2.1.1", "lucide-react": "1.37.0", diff --git a/bun.lock b/bun.lock index a2d1c99..f67383b 100644 --- a/bun.lock +++ b/bun.lock @@ -21,7 +21,7 @@ }, "apps/demo": { "name": "@profullstack/hqtui-demo", - "version": "0.1.11", + "version": "0.1.12", "bin": { "hqtui-demo": "./src/main.ts", }, @@ -34,7 +34,7 @@ "version": "0.1.0", "dependencies": { "@base-ui/react": "1.7.0", - "@profullstack/hqtui": "^0.1.11", + "@profullstack/hqtui": "^0.1.12", "class-variance-authority": "0.7.1", "clsx": "2.1.1", "lucide-react": "1.37.0", @@ -58,12 +58,12 @@ "name": "@profullstack/hqtui-examples", "version": "0.1.0", "dependencies": { - "@profullstack/hqtui": "^0.1.11", + "@profullstack/hqtui": "^0.1.12", }, }, "packages/hqtui": { "name": "@profullstack/hqtui", - "version": "0.1.11", + "version": "0.1.12", "bin": { "hqtui": "./bin/hqtui.mjs", }, diff --git a/examples/package.json b/examples/package.json index acd60e2..7260f25 100644 --- a/examples/package.json +++ b/examples/package.json @@ -4,6 +4,6 @@ "version": "0.1.0", "type": "module", "dependencies": { - "@profullstack/hqtui": "^0.1.11" + "@profullstack/hqtui": "^0.1.12" } } diff --git a/packages/hqtui/package.json b/packages/hqtui/package.json index e1efa91..c8f863c 100644 --- a/packages/hqtui/package.json +++ b/packages/hqtui/package.json @@ -1,6 +1,6 @@ { "name": "@profullstack/hqtui", - "version": "0.1.11", + "version": "0.1.12", "description": "High Quality Terminal UI for TypeScript. btop-grade dashboards with a one-import API, dark by default, zero runtime dependencies.", "license": "MIT", "type": "module", diff --git a/packages/hqtui/src/cli.ts b/packages/hqtui/src/cli.ts index 798ba92..4886010 100644 --- a/packages/hqtui/src/cli.ts +++ b/packages/hqtui/src/cli.ts @@ -13,7 +13,7 @@ import { detectCapabilities } from "./capabilities.ts"; import { themeList, themes } from "./theme.ts"; import { BrailleCanvas } from "./graphics/braille.ts"; -const VERSION = "0.1.11"; +const VERSION = "0.1.12"; function help(): void { console.log(`hqtui ${VERSION} — High Quality Terminal UI for TypeScript diff --git a/ports/c/CMakeLists.txt b/ports/c/CMakeLists.txt index e936902..d0b228c 100644 --- a/ports/c/CMakeLists.txt +++ b/ports/c/CMakeLists.txt @@ -1,5 +1,5 @@ cmake_minimum_required(VERSION 3.20) -project(hqtui_c VERSION 0.1.11 LANGUAGES C) +project(hqtui_c VERSION 0.1.12 LANGUAGES C) include(GNUInstallDirs) include(CTest) option(HQTUI_SANITIZE "Build with address/undefined behavior sanitizers" OFF) diff --git a/ports/conformance/DEMO.md b/ports/conformance/DEMO.md new file mode 100644 index 0000000..e279bbe --- /dev/null +++ b/ports/conformance/DEMO.md @@ -0,0 +1,36 @@ +# Demo visual contract + +`fixtures/demo-parity.json` is generated by the real TypeScript screen functions, +not by a native port or by replaying screenshots. Regenerate it from the repo root: + +```sh +mise exec bun@1.4.0 -- bun apps/demo/scripts/native-parity.ts +``` + +The 120 cases cover all ten screens at 80×30, 120×40, 168×46 and 200×60 in dark, +Dracula and Nord. Every native implementation renders the same source sample +using its own library. Tests compare glyphs, foreground/background colors and +attributes. No JavaScript renderer or snapshot playback ships in a native demo. + +Rust, Python and Zig require exact cell hashes. Go also checks exact colors and +attributes, allowing at most one Braille dot in the Components gauge: Go's cosine +and the reference runtime differ by one ULP at 2π/3, which straddles a half-pixel. +That tolerance cannot hide spacing, color, text or other-screen differences. + +The reference frames check screen bodies, not every possible interaction or live +system sample. Native suites additionally exercise keyboard/overlay priority, +scroll offsets, mouse selection, real-source parsing, real/simulated CLI launches +and terminal restoration. Real missing data must stay missing, never be replaced +with simulated rows. Traffic protocol/direction and HTTP-rate estimates remain +identified as estimates. + +Run the complete acceptance checks with the pinned toolchains: + +```sh +mise run test:go +mise run test:python +mise run test:zig +mise exec rust@1.97.1 -- cargo test --manifest-path ports/rust/Cargo.toml --example dashboard +mise exec python@3.12.13 -- python apps/demo/scripts/check-native-data.py +mise exec python@3.12.13 -- python apps/demo/scripts/check-native-commands.py +``` diff --git a/ports/rust/demo/parity.json b/ports/conformance/fixtures/demo-parity.json similarity index 100% rename from ports/rust/demo/parity.json rename to ports/conformance/fixtures/demo-parity.json diff --git a/ports/cpp/CMakeLists.txt b/ports/cpp/CMakeLists.txt index 6d2d51f..1697ee5 100644 --- a/ports/cpp/CMakeLists.txt +++ b/ports/cpp/CMakeLists.txt @@ -1,5 +1,5 @@ cmake_minimum_required(VERSION 3.20) -project(hqtui_cpp VERSION 0.1.11 LANGUAGES C CXX) +project(hqtui_cpp VERSION 0.1.12 LANGUAGES C CXX) include(CTest) include(GNUInstallDirs) if(NOT TARGET hqtui::c) diff --git a/ports/go/internal/demo/components.go b/ports/go/internal/demo/components.go new file mode 100644 index 0000000..83a9ad0 --- /dev/null +++ b/ports/go/internal/demo/components.go @@ -0,0 +1,110 @@ +package demo + +import ui "github.com/profullstack/hqtui/ports/go" + +func (s *state) scrollHandlers(name string, pane *pane) ui.ScrollHandlers { + return ui.ScrollHandlers{OnFocus: s.action(func() { s.focused[s.screen] = name }), OnScroll: func(d int) { + if !s.overlay() { + s.focused[s.screen] = name + pane.move(d) + } + }, OnSelectRow: func(i int) { + if !s.overlay() { + pane.selected = clamp(pane.offset+i, 0, pane.total-1) + } + }} +} +func processTree() []ui.TreeNode { + node := func(name, cpu, mem string, children ...ui.TreeNode) ui.TreeNode { + return ui.TreeNode{Label: name, Values: []ui.TreeValue{{Text: cpu, Width: 6}, {Text: mem, Width: 6}}, Children: children} + } + return []ui.TreeNode{node("systemd", "1.3", "0.1", node("bash", "0.1", "0.2"), node("bun", "32.8", "4.2", node("bun:worker", "12.4", "1.8"), node("bun:worker", "8.7", "1.3")), node("node", "18.1", "2.1", node("node:worker", "6.1", "0.8")), node("postgres", "6.7", "1.8"))} +} +func (s *state) components(p *ui.Container) { + t := p.Theme() + c, m, n := obj(s.sample["cpu"]), obj(s.sample["memory"]), obj(s.sample["network"]) + row(p, ui.Fr(1), 1, func(r *ui.Container) { + r.Column(ui.ColumnOptions{Layout: ui.Layout{Gap: 1}}, func(left *ui.Container) { + left.Panel(ui.PanelOptions{Title: "Buttons & Inputs", Layout: fixed(13)}, func(p *ui.Container) { + row(p, ui.Cells(1), 1, func(r *ui.Container) { + for i, label := range []string{"Primary", "Success", "Warning", "Danger"} { + width := []int{11, 11, 11, 10}[i] + var action func() + if i == 0 { + action = s.action(func() { s.modal = true }) + } + r.Button(ui.ButtonOptions{Label: label, Width: width, Variant: []ui.ButtonVariant{ui.ButtonPrimary, ui.ButtonSuccess, ui.ButtonWarning, ui.ButtonDanger}[i]}, action, fixed(width)) + } + r.Spacer(ui.Fill()) + }) + p.Spacer(ui.Cells(1)) + row(p, ui.Cells(1), 2, func(r *ui.Container) { + options := []string{"Dark", "Dracula", "Nord", "Tokyo Night"} + r.Select(ui.SelectOptions{Value: options[s.selectIndex%4], Width: 20, Open: s.selectOpen, Options: options, SelectedIndex: s.selectIndex}, s.action(func() { s.selectOpen = !s.selectOpen }), fixed(20)) + r.Checkbox(ui.CheckboxOptions{Label: "Toggle", Checked: s.toggle, Variant: ui.CheckboxToggle}, s.action(func() { s.toggle = !s.toggle }), fixed(12)) + r.Checkbox(ui.CheckboxOptions{Label: "Checkbox", Checked: s.checked}, s.action(func() { s.checked = !s.checked }), fixed(14)) + r.Spacer(ui.Fill()) + }) + p.Spacer(ui.Cells(1)) + p.TextInput(ui.TextInputOptions{Label: "Search", Value: s.input, Placeholder: "type to filter…", Focused: s.editing}, fixed(1)) + p.Spacer(ui.Cells(1)) + p.Meter(ui.MeterOptions{Label: "Slider", Value: s.slider, Heat: ref(false), Color: &t.Primary}) + p.Progress(ui.ProgressOptions{Label: "Progress", Value: 37, Max: ref(120.), ShowCount: true}) + }) + left.Panel(ui.PanelOptions{Title: "Table Widget"}, func(p *ui.Container) { + rows := []any{} + for _, r := range [][4]string{{"src", "4.2 KB", "dir", "2m ago"}, {"test", "1.1 KB", "dir", "5m ago"}, {"package.json", "1.2 KB", "file", "10m ago"}, {"README.md", "3.4 KB", "file", "1h ago"}, {"bun.lockb", "12 KB", "file", "1h ago"}} { + rows = append(rows, object{"name": r[0], "size": r[1], "type": r[2], "modified": r[3]}) + } + s.dataTable(p, "components.files", rows, []dataColumn{dc("name", "Name", 0, 10, &t.Primary, false), dc("size", "Size", 9, 0, nil, true), dc("type", "Type", 6, 0, nil, false), dc("modified", "Modified", 10, 0, &t.Muted, true)}, true, false, true) + }) + left.Panel(ui.PanelOptions{Title: "Log Viewer", Layout: fixed(11)}, func(p *ui.Container) { + data := arr(s.sample["logs"]) + pane := s.pane("components.logs", len(data)) + pane.log = true + entries := []ui.LogEntry{} + for _, v := range data { + d := obj(v) + entries = append(entries, ui.LogEntry{Time: scalar(d["time"]), Level: scalar(d["level"]), Message: scalar(d["message"]), Meta: "{" + scalar(d["meta"]) + "}"}) + } + p.Log(ui.LogOptions{Entries: entries, FromEnd: pane.offset, Scrollbar: true}, ui.ScrollHandlers{OnFocus: s.action(func() { s.focused[s.screen] = "components.logs" }), OnScroll: func(d int) { + if !s.overlay() { + s.focused[s.screen] = "components.logs" + pane.offset = clamp(pane.offset-d, 0, len(data)-1) + } + }}) + }) + }) + r.Column(ui.ColumnOptions{Layout: ui.Layout{Gap: 1}}, func(right *ui.Container) { + right.Panel(ui.PanelOptions{Title: "Process Tree", Layout: fixed(13)}, func(p *ui.Container) { + row(p, ui.Cells(1), 0, func(r *ui.Container) { + r.StyledText("Name", ui.TextStyle{Fg: &t.Muted, Bold: true}) + r.StyledText("CPU% MEM%", ui.TextStyle{Fg: &t.Muted, Bold: true, Align: ui.AlignRight}) + }) + pane := s.pane("components.tree", 8) + p.Tree(ui.TreeOptions{Nodes: processTree(), Selected: &pane.selected, Offset: &pane.offset, FollowSelection: true}, s.scrollHandlers("components.tree", pane)) + }) + right.Panel(ui.PanelOptions{Title: "Sparklines & Gauges", Layout: fixed(12)}, func(p *ui.Container) { + p.Sparkline(ui.SparklineWidgetOptions{Label: "CPU ", Values: values(c["history"]), Text: percent(num(c["total"])), Color: &t.Success}) + p.Sparkline(ui.SparklineWidgetOptions{Label: "Mem ", Values: values(m["history"]), Text: percent(num(m["used"]) / num(m["total"])), Color: &t.Warning}) + p.Sparkline(ui.SparklineWidgetOptions{Label: "Net ", Values: values(n["downHistory"]), Text: bytes(num(n["downRate"]), 2) + "/s", Color: &t.Primary}) + p.Spacer(ui.Cells(1)) + row(p, ui.Fr(1), 2, func(r *ui.Container) { + r.Gauge(ui.GaugeOptions{Value: num(c["total"]), Label: percent(num(c["total"]))}) + r.Donut(ui.DonutOptions{Segments: []ui.DonutSegment{{Value: num(m["used"]), Color: &t.Primary, Label: "Used"}, {Value: num(m["available"]), Color: &t.Warning, Label: "Free"}}}) + }) + }) + right.Panel(ui.PanelOptions{Title: "Lists & Badges"}, func(p *ui.Container) { + row(p, ui.Cells(1), 1, func(r *ui.Container) { + r.Badge(ui.BadgeOptions{Text: "active", Color: &t.Success}, fixed(10)) + r.Badge(ui.BadgeOptions{Text: "idle", Color: &t.Warning, Variant: ui.BadgeSubtle}, fixed(8)) + r.Badge(ui.BadgeOptions{Text: "failed", Color: &t.Danger, Variant: ui.BadgeOutline}, fixed(10)) + r.Spacer(ui.Fill()) + }) + p.Spacer(ui.Cells(1)) + pane := s.pane("components.list", 4) + p.List(ui.ListOptions{Items: []ui.ListItem{{Label: "apps/demo", Color: &t.Primary}, {Label: "packages/hqtui"}, {Label: "apps/web"}, {Label: "docs"}}, Selected: &pane.selected, Offset: &pane.offset, FollowSelection: true, Bullet: "▸", Scrollbar: true}, s.scrollHandlers("components.list", pane)) + }) + }) + }) +} diff --git a/ports/go/internal/demo/dashboard.go b/ports/go/internal/demo/dashboard.go new file mode 100644 index 0000000..8de8155 --- /dev/null +++ b/ports/go/internal/demo/dashboard.go @@ -0,0 +1,444 @@ +package demo + +// Native counterpart of apps/demo/src/screens/dashboard.ts. Keep widget +// options and responsive tracks in sync; parity_test.go compares actual cells. +import ( + "fmt" + ui "github.com/profullstack/hqtui/ports/go" + "math" + "strconv" + "strings" +) + +func ref[T any](v T) *T { return &v } +func fixed(n int) ui.Layout { return ui.Layout{Size: ref(ui.Cells(n))} } +func fraction(n float64) ui.Layout { return ui.Layout{Size: ref(ui.Fr(n))} } +func row(p *ui.Container, size ui.Size, gap int, fn func(*ui.Container)) { + p.Row(ui.RowOptions{Layout: ui.Layout{Size: &size, Gap: gap}}, fn) +} +func col(p *ui.Container, size ui.Size, fn func(*ui.Container)) { + p.Column(ui.ColumnOptions{Layout: ui.Layout{Size: &size}}, fn) +} +func txt(p *ui.Container, text string, color ui.Color) { p.StyledText(text, ui.TextStyle{Fg: &color}) } +func kv(label, value string, color ui.Color) ui.KeyValueRow { + return ui.KeyValueRow{Label: label, Value: value, Color: &color} +} +func keys(p *ui.Container, rows []ui.KeyValueRow, spread bool) { + p.KeyValues(ui.KeyValueOptions{Rows: rows, NoSpread: !spread}) +} +func scalar(v any) string { + if v == nil { + return "—" + } + if n, ok := v.(float64); ok { + return strconv.FormatFloat(n, 'f', -1, 64) + } + return fmt.Sprint(v) +} +func bytes(v float64, digits int) string { + if math.IsNaN(v) || math.IsInf(v, 0) { + return "—" + } + v = math.Max(0, v) + unit := 0 + units := []string{"B", "KiB", "MiB", "GiB", "TiB", "PiB"} + for v >= 1024 && unit < 5 { + v /= 1024 + unit++ + } + if unit == 0 { + digits = 0 + } + return fmt.Sprintf("%.*f %s", digits, v, units[unit]) +} +func percent(v float64) string { return fmt.Sprintf("%.0f%%", math.Floor(ui.ClampRatio(v)*100+0.5)) } +func bitRate(v float64) string { + v = math.Max(0, v) * 8 + for i, scale := range []float64{1e9, 1e6, 1e3} { + if v >= scale { + return fmt.Sprintf("%.1f %s", v/scale, []string{"Gb/s", "Mb/s", "Kb/s"}[i]) + } + } + return fmt.Sprintf("%.0f b/s", v) +} +func byteRate(v float64) string { + for i, scale := range []float64{1e9, 1e6, 1e3} { + if v >= scale { + return fmt.Sprintf("%.1f %s", v/scale, []string{"GB/s", "MB/s", "KB/s"}[i]) + } + } + return fmt.Sprintf("%.0f B/s", math.Max(0, v)) +} +func duration(v float64) string { + n := int(math.Max(0, v)) + d, h, m := n/86400, n%86400/3600, n%3600/60 + if d > 0 { + return fmt.Sprintf("%dd %dh %dm", d, h, m) + } + if h > 0 { + return fmt.Sprintf("%dh %dm", h, m) + } + return fmt.Sprintf("%dm %ds", m, n%60) +} +func plot(p *ui.Container, v any, c ui.Color, maxValue *float64, axis bool) { + p.Graph(ui.GraphOptions{Values: values(v), Axis: axis, Plot: ui.PlotOptions{Min: ref(0.), Max: maxValue, Fill: ref(true), Color: &c}}) +} +func multi(p *ui.Container, a, b any, ca, cb ui.Color) { + p.Graph(ui.GraphOptions{Series: []ui.Series{{Values: values(a), Color: &ca, Fill: ref(true)}, {Values: values(b), Color: &cb, Fill: ref(true)}}, Plot: ui.PlotOptions{Min: ref(0.)}}) +} +func meter(p *ui.Container, v float64, c *ui.Color) { + p.Meter(ui.MeterOptions{Value: v, Color: c, HideValue: true, Style: ui.BarSegmented}) +} + +func (s *state) cpuPanel(p *ui.Container, columns int) { + c := obj(s.sample["cpu"]) + p.Panel(ui.PanelOptions{Title: "CPU Overview", Subtitle: percent(num(c["total"]))}, func(p *ui.Container) { + t := p.Theme() + p.Label(fmt.Sprintf("%s %.1f GHz", scalar(c["model"]), num(c["frequencyGhz"]))) + plot(p, c["history"], t.Success, ref(100.), false) + items := []ui.MeterItem{} + for i, v := range arr(c["cores"]) { + items = append(items, ui.Meter(fmt.Sprintf("P%d", i), num(v))) + } + p.Meters(ui.MetersOptions{Items: items, Columns: columns, LabelWidth: 4, ValueWidth: 5, Style: ui.BarSegmented}) + p.Divider(ui.DividerOptions{}) + load := values(c["load"]) + for len(load) < 3 { + load = append(load, 0) + } + keys(p, []ui.KeyValueRow{kv("Load Avg", fmt.Sprintf("%.2f %.2f %.2f", load[0], load[1], load[2]), t.Warning)}, true) + }) +} +func (s *state) memoryPanel(p *ui.Container) { + m := obj(s.sample["memory"]) + used, swap := num(m["used"])/math.Max(1, num(m["total"])), num(m["swapUsed"])/math.Max(1, num(m["swapTotal"])) + p.Panel(ui.PanelOptions{Title: "Memory & Swap"}, func(p *ui.Container) { + t := p.Theme() + txt(p, fmt.Sprintf("Memory %s / %s (%s)", bytes(num(m["used"]), 2), bytes(num(m["total"]), 2), percent(used)), t.Foreground) + meter(p, used, nil) + p.Spacer(ui.Cells(1)) + keys(p, []ui.KeyValueRow{kv("Used:", bytes(num(m["used"]), 2), t.Warning), kv("Available:", bytes(num(m["available"]), 2), t.Success), kv("Cached:", bytes(num(m["cached"]), 2), t.Accent), kv("Buffers:", bytes(num(m["buffers"]), 2), t.Secondary), kv("Free:", bytes(num(m["free"]), 2), t.Muted)}, true) + p.Spacer(ui.Fill()) + p.Divider(ui.DividerOptions{}) + txt(p, fmt.Sprintf("Swap %s / %s (%s)", bytes(num(m["swapUsed"]), 2), bytes(num(m["swapTotal"]), 2), percent(swap)), t.Foreground) + meter(p, swap, &t.Secondary) + keys(p, []ui.KeyValueRow{kv("Used:", bytes(num(m["swapUsed"]), 2), t.Secondary), kv("Free:", bytes(num(m["swapTotal"])-num(m["swapUsed"]), 2), t.Muted)}, true) + }) +} +func (s *state) disksPanel(p *ui.Container) { + disks := arr(s.sample["disks"]) + p.Panel(ui.PanelOptions{Title: "Disks"}, func(p *ui.Container) { + t := p.Theme() + if len(disks) == 0 { + p.Label("No disks reported") + return + } + for i, v := range disks[:min(2, len(disks))] { + d := obj(v) + used := num(d["used"]) / math.Max(1, num(d["total"])) + txt(p, fmt.Sprintf("%s — %s (%s)", scalar(d["device"]), bytes(num(d["total"]), 2), scalar(d["type"])), t.Foreground) + txt(p, fmt.Sprintf("Used: %s (%s)", bytes(num(d["used"]), 2), percent(used)), t.Muted) + meter(p, used, nil) + txt(p, "Free: "+bytes(num(d["total"])-num(d["used"]), 2), t.Muted) + row(p, ui.Cells(1), 0, func(r *ui.Container) { + txt(r, "Read: "+byteRate(num(d["readRate"])), t.Success) + r.StyledText("Write: "+byteRate(num(d["writeRate"])), ui.TextStyle{Fg: &t.Secondary, Align: ui.AlignRight}) + }) + multi(p, d["readHistory"], d["writeHistory"], t.Success, t.Secondary) + if i == 0 && len(disks) > 1 { + p.Divider(ui.DividerOptions{}) + } + } + }) +} +func (s *state) systemPanel(p *ui.Container) { + sys, c, m := obj(s.sample["system"]), obj(s.sample["cpu"]), obj(s.sample["memory"]) + p.Panel(ui.PanelOptions{Title: "System"}, func(p *ui.Container) { + t := p.Theme() + used := num(m["used"]) / math.Max(1, num(m["total"])) + source := "simulated" + if s.real { + source = "linux/proc" + } + count := int(num(sys["processCount"])) + if count == 0 { + count = len(arr(s.sample["processes"])) + } + swap := "—" + if num(m["swapTotal"]) > 0 { + swap = percent(num(m["swapUsed"]) / num(m["swapTotal"])) + } + load := values(c["load"]) + for len(load) < 3 { + load = append(load, 0) + } + p.Row(ui.RowOptions{Layout: ui.Layout{Size: ref(ui.Cells(6)), Min: ref(6), Gap: 2}}, func(r *ui.Container) { + keys(r, []ui.KeyValueRow{kv("OS:", scalar(sys["os"]), t.Foreground), kv("Kernel:", scalar(sys["kernel"]), t.Foreground), kv("Uptime:", duration(num(sys["uptime"])), t.Foreground), kv("Hostname:", scalar(sys["hostname"]), t.Foreground), kv("Shell:", scalar(sys["shell"]), t.Foreground), kv("Source:", source, t.Accent)}, false) + keys(r, []ui.KeyValueRow{kv("CPU:", percent(num(c["total"])), ui.HeatColor(t, num(c["total"]))), kv("Memory:", fmt.Sprintf("%s (%s)", percent(used), bytes(num(m["used"]), 2)), t.Warning), kv("Swap:", swap, t.Secondary), kv("Load:", fmt.Sprintf("%.2f %.2f %.2f", load[0], load[1], load[2]), t.Foreground), kv("Processes:", fmt.Sprint(count), t.Foreground), kv("Threads:", scalar(sys["threadCount"]), t.Foreground)}, false) + }) + p.Panel(ui.PanelOptions{Title: "CPU History", Layout: ui.Layout{Min: ref(5)}}, func(g *ui.Container) { plot(g, c["history"], t.Success, ref(100.), true) }) + if p.Width() >= 46 && p.Height() >= 16 { + row(p, ui.Cells(6), 1, func(r *ui.Container) { + r.Panel(ui.PanelOptions{Title: "Quick Stats"}, func(q *ui.Container) { + threads := scalar(sys["threadCount"]) + if num(sys["threadCount"]) == 0 { + threads = "—" + } + keys(q, []ui.KeyValueRow{kv("Uptime", duration(num(sys["uptime"])), t.Accent), kv("Procs", fmt.Sprint(count), t.Accent), kv("Threads", threads, t.Accent), kv("Ctx/s", fmt.Sprintf("%.1fK", num(obj(obj(s.sample["telemetry"])["kernel"])["contextSwitchRate"])/1000), t.Accent)}, true) + }) + r.Panel(ui.PanelOptions{Title: "Memory"}, func(q *ui.Container) { + txt(q, percent(used), t.Warning) + plot(q, m["history"], t.Primary, ref(100.), false) + }) + r.Panel(ui.PanelOptions{Title: "Temp", Layout: fixed(14)}, func(q *ui.Container) { + v, label := num(c["total"]), percent(num(c["total"])) + if temps := arr(s.sample["temperatures"]); len(temps) > 0 { + temp := obj(temps[0]) + maxV := num(temp["max"]) + if maxV == 0 { + maxV = 100 + } + v = math.Min(1, num(temp["value"])/maxV) + label = fmt.Sprintf("%.0f°C", math.Floor(num(temp["value"])+.5)) + } + q.Gauge(ui.GaugeOptions{Value: v, Label: label}) + }) + }) + } else { + p.Divider(ui.DividerOptions{}) + keys(p, []ui.KeyValueRow{kv("Threads", scalar(sys["threadCount"]), t.Accent), kv("Ctx switches", fmt.Sprintf("%.1fK", num(sys["contextSwitches"])/1000), t.Accent)}, true) + } + }) +} + +// Table formatting stays native. Per-cell colors preserve TS render callbacks. +type dataColumn struct { + key string + ui.TableColumn + format func(object) string + color func(object) ui.Color +} + +func dc(key, title string, width, minWidth int, color *ui.Color, right bool) dataColumn { + c := ui.Col(title) + if width > 0 { + c = c.Sized(ui.Cells(width)) + } + if minWidth > 0 { + c.Min = ref(minWidth) + } + c.Color = color + if right { + c = c.Right() + } + return dataColumn{key: key, TableColumn: c} +} +func (s *state) dataTable(p *ui.Container, name string, data []any, cols []dataColumn, zebra bool, flags ...bool) { + p.Column(ui.ColumnOptions{}, func(surface *ui.Container) { s.drawDataTable(surface, name, data, cols, zebra, flags...) }) +} +func (s *state) drawDataTable(p *ui.Container, name string, data []any, cols []dataColumn, zebra bool, flags ...bool) { + pane := s.pane(name, len(data)) + headerRows := 1 + if len(flags) > 0 && flags[0] { + headerRows = 0 + } + pane.offset = ui.ResolveOffset(&pane.offset, &pane.selected, max(0, p.Height()-headerRows), len(data), true) + rows := make([]ui.TableRow, 0, len(data)) + columns := make([]ui.TableColumn, 0, len(cols)) + for _, c := range cols { + columns = append(columns, c.TableColumn) + } + for _, v := range data { + d := obj(v) + r := ui.TableRow{} + for _, c := range cols { + text := scalar(d[c.key]) + if c.format != nil { + text = c.format(d) + } + r.Cells = append(r.Cells, text) + color := c.Color + if c.color != nil { + color = ref(c.color(d)) + } + r.CellColors = append(r.CellColors, color) + } + rows = append(rows, r) + } + p.Table(ui.TableOptions{Rows: rows, Columns: columns, Selected: &pane.selected, Offset: &pane.offset, FollowSelection: true, Zebra: zebra, Scrollbar: len(flags) < 2 || !flags[1], NoHeader: len(flags) > 0 && flags[0]}, ui.ScrollHandlers{OnFocus: s.action(func() { s.focused[s.screen] = name }), OnScroll: func(d int) { + if !s.overlay() { + s.focused[s.screen] = name + pane.move(d) + } + }, OnSelectRow: func(i int) { + if !s.overlay() { + pane.selected = clamp(pane.offset+i, 0, len(data)-1) + } + }}) +} +func (s *state) processesPanel(p *ui.Container) { + p.Panel(ui.PanelOptions{Title: "Processes (sorted by " + []string{"CPU", "MEM", "PID", "NAME"}[s.sort] + ")", Subtitle: func() string { + if s.filter != "" { + return "filter: " + s.filter + } + return "" + }(), Focusable: true}, func(p *ui.Container) { + t := p.Theme() + cols := []dataColumn{dc("pid", "PID", 7, 0, nil, true), dc("name", "Name", 0, 8, &t.Primary, false), dc("cpu", "CPU%", 6, 0, nil, true), dc("mem", "MEM%", 6, 0, &t.Warning, true), dc("rss", "RSS", 9, 0, nil, true), dc("threads", "Threads", 7, 0, nil, true), dc("state", "S", 2, 0, nil, false), dc("user", "User", 10, 0, &t.Muted, false), dc("command", "Command", 0, 10, &t.Muted, false)} + cols[2].format = func(d object) string { return fmt.Sprintf("%.1f", num(d["cpu"])) } + cols[2].color = func(d object) ui.Color { return ui.HeatColor(t, math.Min(1, num(d["cpu"])/100)) } + cols[3].format = func(d object) string { return fmt.Sprintf("%.1f", num(d["mem"])) } + cols[4].format = func(d object) string { return bytes(num(d["rss"]), 0) } + cols[6].color = func(d object) ui.Color { + if d["state"] == "R" { + return t.Success + } + return t.Muted + } + s.dataTable(p, "dashboard.processes", s.processes(), cols, false) + }) +} +func (s *state) networkPanel(p *ui.Container) { + n := obj(s.sample["network"]) + p.Panel(ui.PanelOptions{Title: "Network"}, func(p *ui.Container) { + t := p.Theme() + row(p, ui.Cells(1), 0, func(r *ui.Container) { + txt(r, "Download: "+bitRate(num(n["downRate"])), t.Primary) + r.StyledText("Upload: "+bitRate(num(n["upRate"])), ui.TextStyle{Fg: &t.Secondary, Align: ui.AlignRight}) + }) + for i, key := range []string{"downHistory", "upHistory"} { + color := []ui.Color{t.Primary, t.Secondary}[i] + p.Graph(ui.GraphOptions{Values: values(n[key]), Axis: true, AxisFormat: func(v float64) string { return strings.ReplaceAll(bitRate(v), " ", "") }, Plot: ui.PlotOptions{Min: ref(0.), Color: &color, Fill: ref(true)}}) + } + p.Divider(ui.DividerOptions{}) + row(p, ui.Cells(3), 2, func(r *ui.Container) { + for i, prefix := range []string{"down", "up"} { + color := []ui.Color{t.Primary, t.Secondary}[i] + keys(r, []ui.KeyValueRow{kv("Total:", bytes(num(n[prefix+"Total"]), 2), color), kv("Current:", bitRate(num(n[prefix+"Rate"])), color), kv("Peak:", bitRate(num(n[prefix+"Peak"])), color)}, false) + } + }) + }) +} +func (s *state) diskUsagePanel(p *ui.Container) { + disks := arr(s.sample["disks"]) + first := object{} + if len(disks) > 0 { + first = obj(disks[0]) + } + subtitle := "" + if len(disks) > 0 { + subtitle = scalar(first["device"]) + } + p.Panel(ui.PanelOptions{Title: "Disk Usage", Subtitle: subtitle}, func(p *ui.Container) { + t := p.Theme() + for _, v := range disks { + d := obj(v) + used := num(d["used"]) / math.Max(1, num(d["total"])) + p.Meter(ui.MeterOptions{Value: used, Text: fmt.Sprintf("%s %s / %s", percent(used), bytes(num(d["used"]), 0), bytes(num(d["total"]), 0)), Style: ui.BarSegmented}) + p.Label(fmt.Sprintf("%s (%s)", scalar(d["mount"]), scalar(d["device"]))) + } + p.Spacer(ui.Cells(1)) + p.Panel(ui.PanelOptions{Title: "I/O Summary"}, func(io *ui.Container) { + row(io, ui.Fill(), 2, func(r *ui.Container) { + for i, prefix := range []string{"read", "write"} { + color := []ui.Color{t.Success, t.Secondary}[i] + label := []string{"Read: ", "Write: "}[i] + col(r, ui.Fill(), func(c *ui.Container) { + txt(c, label+byteRate(num(first[prefix+"Rate"])), color) + plot(c, first[prefix+"History"], color, nil, false) + }) + } + }) + }) + }) +} +func (s *state) temperaturesPanel(p *ui.Container) { + p.Panel(ui.PanelOptions{Title: "Temperatures"}, func(p *ui.Container) { + t := p.Theme() + temps := arr(s.sample["temperatures"]) + if len(temps) == 0 { + txt(p, "No thermal sensors on this host.", t.Muted) + p.Spacer(ui.Cells(1)) + p.Label("Run with --sim to see this panel populated.") + return + } + for _, v := range temps[:min(10, len(temps))] { + temp := obj(v) + maxV := num(temp["max"]) + if maxV == 0 { + maxV = 100 + } + ratio := math.Min(1, num(temp["value"])/maxV) + row(p, ui.Cells(1), 0, func(r *ui.Container) { + r.StyledText(scalar(temp["label"]), ui.TextStyle{Fg: &t.Muted}, fixed(16)) + r.HeatBar(ui.HeatBarOptions{Value: ratio}) + r.StyledText(fmt.Sprintf("%.0f°C", math.Floor(num(temp["value"])+.5)), ui.TextStyle{Fg: ref(ui.HeatColor(t, ratio)), Align: ui.AlignRight}, fixed(6)) + }) + } + }) +} +func (s *state) sensorsPanel(p *ui.Container) { + p.Panel(ui.PanelOptions{Title: "Sensors"}, func(p *ui.Container) { + t := p.Theme() + sensors := arr(s.sample["sensors"]) + if len(sensors) == 0 { + p.Label("No hardware sensors on this host.") + p.Spacer(ui.Cells(1)) + p.Label("Probed: /sys/class/hwmon, thermal zones, lm-sensors,") + p.Label("power supplies and nvidia-smi.") + return + } + rows := []ui.KeyValueRow{} + for _, v := range sensors { + d := obj(v) + rows = append(rows, kv(scalar(d["label"]), scalar(d["value"]), t.Accent)) + } + keys(p, rows, true) + }) +} +func (s *state) logsPanel(p *ui.Container) { + p.Panel(ui.PanelOptions{Title: "Logs"}, func(p *ui.Container) { + data := arr(s.sample["logs"]) + pane := s.pane("dashboard.logs", len(data)) + pane.log = true + entries := []ui.LogEntry{} + for _, v := range data { + d := obj(v) + entries = append(entries, ui.LogEntry{Time: scalar(d["time"]), Level: scalar(d["level"]), Message: scalar(d["message"]), Meta: "{" + scalar(d["meta"]) + "}"}) + } + p.Log(ui.LogOptions{Entries: entries, FromEnd: pane.offset, Scrollbar: true}, ui.ScrollHandlers{OnFocus: s.action(func() { s.focused[s.screen] = "dashboard.logs" }), OnScroll: func(delta int) { + if !s.overlay() { + s.focused[s.screen] = "dashboard.logs" + pane.offset = clamp(pane.offset-delta, 0, len(data)-1) + } + }}) + }) +} +func (s *state) dashboard(p *ui.Container) { + if p.Width() >= 150 { + height := 16 + if p.Height() >= 44 { + height = 19 + } + row(p, ui.Cells(height), 1, func(r *ui.Container) { + col(r, ui.Fr(1), func(c *ui.Container) { s.cpuPanel(c, 2) }) + col(r, ui.Fr(.95), s.memoryPanel) + col(r, ui.Fr(.95), s.disksPanel) + col(r, ui.Fr(1.35), s.systemPanel) + }) + row(p, ui.Fr(1), 1, func(r *ui.Container) { + col(r, ui.Fr(2), s.processesPanel) + col(r, ui.Fr(1.2), s.networkPanel) + col(r, ui.Fr(1.2), s.diskUsagePanel) + }) + row(p, ui.Cells(12), 1, func(r *ui.Container) { s.temperaturesPanel(r); s.sensorsPanel(r); col(r, ui.Fr(1.6), s.logsPanel) }) + } else if p.Width() >= 100 { + row(p, ui.Cells(14), 1, func(r *ui.Container) { s.cpuPanel(r, 2); s.memoryPanel(r); s.systemPanel(r) }) + row(p, ui.Fr(1), 1, func(r *ui.Container) { col(r, ui.Fr(1.6), s.processesPanel); col(r, ui.Fill(), s.networkPanel) }) + row(p, ui.Cells(10), 1, func(r *ui.Container) { s.temperaturesPanel(r); s.logsPanel(r) }) + } else { + row(p, ui.Cells(10), 1, func(r *ui.Container) { s.cpuPanel(r, 1); s.memoryPanel(r) }) + col(p, ui.Fill(), s.processesPanel) + row(p, ui.Cells(8), 1, s.networkPanel) + } +} diff --git a/ports/go/internal/demo/demo_test.go b/ports/go/internal/demo/demo_test.go index d519ac2..6beef28 100644 --- a/ports/go/internal/demo/demo_test.go +++ b/ports/go/internal/demo/demo_test.go @@ -1,6 +1,7 @@ package demo import ( + "fmt" ui "github.com/profullstack/hqtui/ports/go" "io" "reflect" @@ -8,6 +9,36 @@ import ( "testing" ) +func TestHTTPPaneUsesFinalViewport(t *testing.T) { + s := newState(false, 1337) + s.screen = 1 + paths := []any{} + for i := 0; i < 50; i++ { + paths = append(paths, object{"path": fmt.Sprintf("/route-%03d", i), "count": i}) + } + obj(obj(s.sample["telemetry"])["http"])["topPaths"] = paths + pane := s.pane("traffic.paths", 50) + pane.selected = 49 + frame := ui.RenderToScreen(200, 60, "dark", s.render) + x, y, found := frame.Find("/route-049") + if !found { + t.Fatal("last HTTP path is not visible") + } + for _, region := range frame.Regions { + if region.OnClick != nil && x >= region.Rect.X && x < region.Rect.X+region.Rect.Width && y >= region.Rect.Y && y < region.Rect.Y+region.Rect.Height { + if pane.offset != 50-region.Rect.Height { + t.Fatalf("offset %d does not match final height %d", pane.offset, region.Rect.Height) + } + region.OnClick(0, 0, "left") + if pane.selected != pane.offset { + t.Fatal("no-header click selected wrong row") + } + return + } + } + t.Fatal("HTTP path table has no hit region") +} + func TestAllScreensThemesAndSizes(t *testing.T) { s := newState(false, 1337) for screen, name := range screens { @@ -16,7 +47,8 @@ func TestAllScreensThemesAndSizes(t *testing.T) { s.screen = screen s.theme = theme frame := ui.RenderToScreen(120, 40, themeName, s.render) - if !frame.Contains("hqtui") || !frame.Contains(name) { + title := []string{"CPU Overview", "Protocols", "Active Sessions", "Connections", "Filesystems", "Buttons & Inputs", "Braille (2×4", "Theme ", "Last Events", "Full-screen churn"}[screen] + if !frame.Contains("hqtui") || !frame.Contains(title) { t.Fatal("missing screen header") } }) @@ -102,8 +134,8 @@ func TestIndependentPanes(t *testing.T) { if len(regions) < 2 { t.Fatal("missing scroll regions") } - regions[1].OnScroll(3) - if log.selected != 3 { + regions[1].OnScroll(-3) + if log.offset != 3 || log.selected != 0 { t.Fatal("wheel ignored") } } diff --git a/ports/go/internal/demo/main.go b/ports/go/internal/demo/main.go index 14ebbac..f0c02ee 100644 --- a/ports/go/internal/demo/main.go +++ b/ports/go/internal/demo/main.go @@ -11,7 +11,7 @@ import ( "time" ) -const version = "0.1.11" +const version = "0.1.12" type options struct { sim, real, snapshot, version bool @@ -128,6 +128,7 @@ func run(args []string) int { results := make(chan result, 1) busy := false next := time.Time{} + lastFrame := time.Now() app.Render(func(frame ui.RenderArgs) { select { case r := <-results: @@ -139,6 +140,13 @@ func run(args []string) int { default: } now := time.Now() + s.fps = 1 / math.Max(.001, now.Sub(lastFrame).Seconds()) + lastFrame = now + s.clock = now.UTC().Format("15:04:05") + stats := app.Stats() + s.renderMs = float64(stats.Render) / float64(time.Millisecond) + s.changedCells = stats.ChangedCells + s.outputBytes = stats.Bytes if !s.paused && !busy && now.After(next) { if real { busy = true diff --git a/ports/go/internal/demo/model.go b/ports/go/internal/demo/model.go index f29cc55..007067e 100644 --- a/ports/go/internal/demo/model.go +++ b/ports/go/internal/demo/model.go @@ -111,9 +111,18 @@ func clone(sample object) object { return result } -type pane struct{ selected, offset, total int } +type pane struct { + selected, offset, total int + log bool +} -func (p *pane) move(delta int) { p.selected = clamp(p.selected+delta, 0, p.total-1) } +func (p *pane) move(delta int) { + if p.log { + p.offset = clamp(p.offset-delta, 0, p.total-1) + return + } + p.selected = clamp(p.selected+delta, 0, p.total-1) +} type state struct { sample object @@ -127,10 +136,13 @@ type state struct { missing []string tick int seed uint32 + fps, renderMs, slider float64 + changedCells, outputBytes int + clock string } func newState(real bool, seed uint32) *state { - s := &state{sample: loadSample(real), real: real, seed: seed, checked: true, toggle: true, panes: map[string]*pane{}, focused: map[int]string{}} + s := &state{sample: loadSample(real), real: real, seed: seed, checked: true, toggle: true, slider: .7, clock: "12:00:00", lastKey: "—", lastMouse: "—", panes: map[string]*pane{}, focused: map[int]string{}} if !real { for i := 0; i < 120; i++ { s.simulate() @@ -331,9 +343,9 @@ func (s *state) key(key, char string) bool { case "escape": s.selectOpen = false case "up": - s.selectIndex = (s.selectIndex + len(themes) - 1) % len(themes) + s.selectIndex = (s.selectIndex + 3) % 4 case "down": - s.selectIndex = (s.selectIndex + 1) % len(themes) + s.selectIndex = (s.selectIndex + 1) % 4 case "enter": s.theme = s.selectIndex s.selectOpen = false diff --git a/ports/go/internal/demo/parity_test.go b/ports/go/internal/demo/parity_test.go new file mode 100644 index 0000000..74d6e61 --- /dev/null +++ b/ports/go/internal/demo/parity_test.go @@ -0,0 +1,105 @@ +package demo + +import ( + "encoding/json" + ui "github.com/profullstack/hqtui/ports/go" + "math/bits" + "os" + "strings" + "testing" +) + +func TestTypeScriptScreenCells(t *testing.T) { + raw, err := os.ReadFile("../../../conformance/fixtures/demo-parity.json") + if err != nil { + t.Fatal(err) + } + var cases []struct { + Screen, Theme, Text string + Width, Height int + Hashes []uint32 + } + if err = json.Unmarshal(raw, &cases); err != nil { + t.Fatal(err) + } + for _, c := range cases { + t.Run(c.Screen+"/"+c.Theme+"/"+scalar(c.Width), func(t *testing.T) { + s := newState(false, 1337) + s.sample = loadSample(false) + s.theme = index(themes, c.Theme) + s.screen = index(screens, c.Screen) + f := ui.RenderToScreen(c.Width, c.Height, c.Theme, func(p *ui.Container) { + s.body(p) + }) + expected := strings.Split(c.Text, "\n") + gaugeDots := 0 + for y := 0; y < c.Height; y++ { + hash := uint32(2166136261) + add := func(v byte) { hash = (hash ^ uint32(v)) * 16777619 } + for x := 0; x < c.Width; x++ { + cell := f.Cell(x, y) + for _, b := range []byte(cell.Char) { + add(b) + } + add(0) + for _, v := range []uint32{uint32(cell.Fg), uint32(cell.Bg), uint32(cell.Attrs)} { + for i := 0; i < 4; i++ { + add(byte(v >> (i * 8))) + } + } + } + if hash != c.Hashes[y] { + // libm and Go's pure-Go cosine differ by one ULP at 2π/3. + // In the Components gauge this straddles a half-pixel. Permit + // one Braille dot per frame only; colors/attributes, spacing, + // every other glyph and every other screen remain exact. + if c.Screen == "components" { + glyphs := []string{} + for _, ch := range expected[y] { + glyphs = append(glyphs, string(ch)) + if ui.StringWidth(string(ch)) == 2 { + glyphs = append(glyphs, "") + } + } + for len(glyphs) < c.Width { + glyphs = append(glyphs, " ") + } + normalized := uint32(2166136261) + addExpected := func(v byte) { normalized = (normalized ^ uint32(v)) * 16777619 } + dots := 0 + valid := true + for x := 0; x < c.Width; x++ { + cell := f.Cell(x, y) + want := glyphs[x] + if cell.Char != want { + if y != 20 || x <= c.Width/2 || x >= c.Width*3/4 { + valid = false + } + a, b := []rune(cell.Char), []rune(want) + if len(a) != 1 || len(b) != 1 || a[0] < 0x2800 || a[0] > 0x28ff || b[0] < 0x2800 || b[0] > 0x28ff { + valid = false + } else { + dots += bits.OnesCount32(uint32(a[0] ^ b[0])) + } + } + for _, b := range []byte(want) { + addExpected(b) + } + addExpected(0) + for _, v := range []uint32{uint32(cell.Fg), uint32(cell.Bg), uint32(cell.Attrs)} { + for i := 0; i < 4; i++ { + addExpected(byte(v >> (i * 8))) + } + } + } + if valid && dots > 0 && gaugeDots+dots <= 1 && normalized == c.Hashes[y] { + gaugeDots += dots + continue + } + } + t.Errorf("row %d\nTS: %s\nGO: %s", y, expected[y], f.Line(y)) + } + } + }) + } +} diff --git a/ports/go/internal/demo/showcase.go b/ports/go/internal/demo/showcase.go new file mode 100644 index 0000000..02948e0 --- /dev/null +++ b/ports/go/internal/demo/showcase.go @@ -0,0 +1,157 @@ +package demo + +import ( + "fmt" + ui "github.com/profullstack/hqtui/ports/go" + "math" +) + +func wave(phase, freq float64) []float64 { + out := make([]float64, 240) + for i := range out { + out[i] = math.Sin(float64(i)/freq+phase)*50 + 50 + } + return out +} +func (s *state) graphics(p *ui.Container) { + t := p.Theme() + time := num(s.sample["time"]) + a, b, c := wave(time/3, 9), wave(time/3+2, 5), wave(time/2, 17) + row(p, ui.Fr(1), 1, func(r *ui.Container) { + r.Column(ui.ColumnOptions{Layout: ui.Layout{Gap: 1}}, func(left *ui.Container) { + for i, title := range []string{"Braille (2×4 pixels per cell)", "Block elements", "ASCII fallback"} { + left.Panel(ui.PanelOptions{Title: title}, func(p *ui.Container) { + o := ui.PlotOptions{Min: ref(0.), Max: ref(100.)} + switch i { + case 0: + o.Color = &t.Accent + o.Grid = true + o.Fill = ref(true) + case 1: + o.Mode = ui.FillBlock + o.Colors = t.Heat + case 2: + o.Mode = ui.FillASCII + o.Color = &t.Foreground + } + p.Graph(ui.GraphOptions{Values: a, Plot: o}) + }) + } + }) + r.Column(ui.ColumnOptions{Layout: ui.Layout{Gap: 1}}, func(right *ui.Container) { + right.Panel(ui.PanelOptions{Title: "Multi-series"}, func(p *ui.Container) { + p.Graph(ui.GraphOptions{Series: []ui.Series{{Values: a, Color: &t.Primary, Label: "alpha"}, {Values: b, Color: &t.Success, Label: "beta"}, {Values: c, Color: &t.Secondary, Label: "gamma"}}, Axis: true, Legend: true, Plot: ui.PlotOptions{Min: ref(0.), Max: ref(100.)}}) + }) + right.Panel(ui.PanelOptions{Title: "Gradients"}, func(p *ui.Container) { + p.Draw(func(surface ui.Surface) { + steps := ui.GradientOf(t.Heat).Steps(surface.Width()) + for y := 0; y < surface.Height(); y++ { + for x, color := range steps { + surface.Glyph(x, y, '█', ui.Style{Fg: &color}) + } + } + }) + }) + right.Panel(ui.PanelOptions{Title: "Raw Braille canvas"}, func(p *ui.Container) { + p.Canvas(&t.Accent, func(c *ui.BrailleCanvas, _ ui.Surface) { + cx, cy := float64(c.Width)/2, float64(c.Height)/2 + radius := math.Min(cx, cy) - 2 + c.Circle(cx, cy, radius) + for i := 0; i < 12; i++ { + angle := float64(i)/12*math.Pi*2 + time/4 + c.Line(cx, cy, cx+math.Cos(angle)*radius, cy+math.Sin(angle)*radius*.9) + } + }) + }) + }) + }) +} +func (s *state) themeScreen(p *ui.Container) { + p.Label(fmt.Sprintf("Theme %d/9: %s ←/→ or F2 to change", s.theme+1, p.Theme().Name)) + p.Spacer(ui.Cells(1)) + p.Grid(ui.GridOptions{ColumnCount: 3, RowCount: 3, Layout: ui.Layout{Gap: 1}}, func(g *ui.GridContainer) { + for i, name := range themes { + e := ui.ResolveTheme(name) + border := e.Border + if i == s.theme { + border = e.BorderFocused + } + g.Panel(ui.PanelOptions{Title: e.Name, BorderColor: &border, Layout: ui.Layout{Background: &e.Background}}, ui.CellOptions{}, func(p *ui.Container) { + row(p, ui.Cells(1), 1, func(r *ui.Container) { + for i, label := range []string{"primary", "ok", "warn", "err"} { + color := []ui.Color{e.Primary, e.Success, e.Warning, e.Danger}[i] + r.Badge(ui.BadgeOptions{Text: label, Color: &color}, fixed([]int{10, 5, 7, 6}[i])) + } + r.Spacer(ui.Fill()) + }) + p.Meter(ui.MeterOptions{Value: .72, Label: "cpu", Background: &e.Background}) + p.Graph(ui.GraphOptions{Values: values(obj(s.sample["cpu"])["history"]), Plot: ui.PlotOptions{Min: ref(0.), Max: ref(100.), Fill: ref(true), Color: &e.Graph[0], Background: &e.Background}}) + p.Draw(func(surface ui.Surface) { + for ci, color := range e.Graph { + for x := 0; x < 3; x++ { + surface.Glyph(ci*4+x, 0, '█', ui.Style{Fg: &color, Bg: &e.Background}) + } + } + }, fixed(1)) + }) + } + }) +} +func (s *state) inputScreen(p *ui.Container) { + t := p.Theme() + row(p, ui.Fr(1), 1, func(r *ui.Container) { + r.Panel(ui.PanelOptions{Title: "Last Events"}, func(p *ui.Container) { + keys(p, []ui.KeyValueRow{kv("Key", s.lastKey, t.Accent), kv("Mouse", s.lastMouse, t.Primary)}, true) + p.Spacer(ui.Cells(1)) + p.Divider(ui.DividerOptions{Label: "history"}) + items := []ui.ListItem{} + for i := len(s.keyLog) - 1; i >= max(0, len(s.keyLog)-20); i-- { + items = append(items, ui.Item(scalar(s.keyLog[i]))) + } + p.List(ui.ListOptions{Items: items}, ui.ScrollHandlers{}) + }) + r.Panel(ui.PanelOptions{Title: "Try it"}, func(p *ui.Container) { + txt(p, "Press any key — modifiers are normalized.", t.Foreground) + p.Label("Arrows, Function keys, Ctrl/Alt/Shift combinations,") + p.Label("paste, focus, mouse move, click, drag and scroll.") + p.Spacer(ui.Cells(1)) + p.Divider(ui.DividerOptions{Label: "focusable controls"}) + p.Spacer(ui.Cells(1)) + row(p, ui.Cells(1), 2, func(r *ui.Container) { + r.Button(ui.ButtonOptions{Label: "Button A", Width: 12}, nil, fixed(12)) + r.Button(ui.ButtonOptions{Label: "Button B", Width: 12, Variant: ui.ButtonSuccess}, nil, fixed(12)) + r.Checkbox(ui.CheckboxOptions{Label: "Check", Checked: s.checked}, s.action(func() { s.checked = !s.checked }), fixed(12)) + r.Spacer(ui.Fill()) + }) + p.Spacer(ui.Cells(1)) + p.Label("Tab / Shift+Tab moves focus. Enter activates.") + p.Spacer(ui.Fill()) + keys(p, []ui.KeyValueRow{ui.KV("Mouse tracking", "on"), ui.KV("Bracketed paste", "on"), ui.KV("Focus events", "on")}, true) + }) + }) +} +func (s *state) stress(p *ui.Container) { + t := p.Theme() + row(p, ui.Cells(3), 1, func(r *ui.Container) { + for i, title := range []string{"Render", "Changed cells", "Bytes/frame", "FPS"} { + r.Panel(ui.PanelOptions{Title: title}, func(p *ui.Container) { + txt(p, []string{fmt.Sprintf("%.2f ms/frame", s.renderMs), fmt.Sprint(s.changedCells), fmt.Sprint(s.outputBytes), fmt.Sprintf("%.1f", s.fps)}[i], []ui.Color{t.Success, t.Warning, t.Primary, t.Accent}[i]) + }) + } + }) + p.Panel(ui.PanelOptions{Title: "Full-screen churn"}, func(p *ui.Container) { + p.Draw(func(surface ui.Surface) { + ramp := ui.GradientOf(t.Graph) + chars := []rune("▖▗▘▙▚▛▜▝▞▟█▓▒░") + time := num(s.sample["time"]) + for y := 0; y < surface.Height(); y++ { + for x := 0; x < surface.Width(); x++ { + v := (math.Sin(float64(x)/6+time) + math.Cos(float64(y)/4-time)) / 2 + n := (v + 1) / 2 + color := ramp.Sample(n) + surface.Glyph(x, y, chars[int(math.Floor(n*float64(len(chars)-1)))], ui.Style{Fg: &color}) + } + } + }) + }) +} diff --git a/ports/go/internal/demo/telemetry_view.go b/ports/go/internal/demo/telemetry_view.go new file mode 100644 index 0000000..dac6edb --- /dev/null +++ b/ports/go/internal/demo/telemetry_view.go @@ -0,0 +1,404 @@ +package demo + +import ( + "fmt" + ui "github.com/profullstack/hqtui/ports/go" + "math" + "slices" + "strings" +) + +func rate(v float64) string { + if v >= 1e6 { + return fmt.Sprintf("%.1fM/s", v/1e6) + } + if v >= 1000 { + return fmt.Sprintf("%.1fK/s", v/1000) + } + return fmt.Sprintf("%.0f/s", v) +} +func commas(v float64) string { + s := fmt.Sprintf("%.0f", v) + for i := len(s) - 3; i > 0; i -= 3 { + s = s[:i] + "," + s[i:] + } + return s +} +func statusColor(t ui.Theme, class string) ui.Color { + switch class { + case "1xx": + return t.Secondary + case "2xx": + return t.Success + case "3xx": + return t.Accent + case "4xx": + return t.Warning + case "5xx": + return t.Danger + } + return t.Muted +} +func (s *state) telemetry(p *ui.Container) { + switch s.screen { + case 1: + s.trafficScreen(p) + case 2: + s.sessionsScreen(p) + case 3: + s.networkScreen(p) + case 4: + s.servicesScreen(p) + } +} +func (s *state) trafficScreen(p *ui.Container) { + if s.real { + p.Label(trafficNotice()) + } + t := p.Theme() + d := obj(s.sample["telemetry"]) + net := obj(d["net"]) + rates := obj(net["rates"]) + http := obj(d["http"]) + row(p, ui.Cells(13), 1, func(r *ui.Container) { + r.Panel(ui.PanelOptions{Title: "Protocols", Subtitle: fmt.Sprintf("%s in / %s out", scalar(d["inboundConnections"]), scalar(d["outboundConnections"])), BorderColor: &t.Accent}, func(p *ui.Container) { + data := arr(d["protocols"]) + if len(data) == 0 { + p.Label("No sockets visible.") + return + } + maxV := 1. + for _, v := range data { + maxV = math.Max(maxV, num(obj(v)["total"])) + } + items := []ui.MeterItem{} + for i, v := range data[:min(9, len(data))] { + b := obj(v) + items = append(items, ui.MeterItem{Label: scalar(b["protocol"]), Value: num(b["total"]) / maxV, Color: ref(ui.SeriesColor(t, i)), Text: scalar(b["total"])}) + } + p.Meters(ui.MetersOptions{Items: items, LabelWidth: 13, ValueWidth: 5}) + }) + r.Panel(ui.PanelOptions{Title: "TCP", Layout: fraction(.9), BorderColor: &t.Primary}, func(p *ui.Container) { + row(p, ui.Cells(1), 0, func(r *ui.Container) { + txt(r, "↓ "+rate(num(rates["inSegs"]))+" seg", t.Primary) + r.StyledText("↑ "+rate(num(rates["outSegs"]))+" seg", ui.TextStyle{Fg: &t.Secondary, Align: ui.AlignRight}) + }) + multi(p, d["netInHistory"], d["netOutHistory"], t.Primary, t.Secondary) + p.Divider(ui.DividerOptions{}) + keys(p, []ui.KeyValueRow{kv("Established", scalar(net["tcpEstablished"]), t.Success), kv("Opens in/out", rate(num(rates["passiveOpens"]))+" / "+rate(num(rates["activeOpens"])), t.Accent), kv("Resets sent", commas(num(net["tcpOutRsts"])), t.Muted)}, true) + }) + color := t.Success + if num(net["retransRatio"]) > .02 { + color = t.Danger + } + r.Panel(ui.PanelOptions{Title: "Retransmits", Layout: fraction(.7), BorderColor: &color}, func(p *ui.Container) { + p.StyledText(fmt.Sprintf("%.2f%%", ui.ClampRatio(num(net["retransRatio"]))*100), ui.TextStyle{Fg: &color, Bold: true}) + p.Label("of outbound segments") + plot(p, d["retransHistory"], t.Danger, nil, false) + keys(p, []ui.KeyValueRow{kv("UDP in/out", rate(num(rates["udpIn"]))+" / "+rate(num(rates["udpOut"])), t.Muted), kv("ICMP", scalar(net["icmpInMsgs"])+" / "+scalar(net["icmpOutMsgs"]), t.Muted)}, true) + }) + }) + row(p, ui.Fr(1), 1, func(r *ui.Container) { + r.Column(ui.ColumnOptions{Layout: ui.Layout{Gap: 1}}, func(c *ui.Container) { + subtitle := "no access log" + if len(http) > 0 { + subtitle = fmt.Sprintf("%.1f req/s", num(http["requestsPerSecond"])) + } + c.Panel(ui.PanelOptions{Title: "HTTP", Subtitle: subtitle, BorderColor: &t.Success}, func(p *ui.Container) { + if len(http) == 0 { + p.Label("No readable HTTP access log.") + p.Label("nginx, apache, httpd and caddy logs are") + p.Label("root/adm readable — run with sudo to track requests.") + return + } + row(p, ui.Cells(1), 0, func(r *ui.Container) { + txt(r, scalar(http["source"]), t.Muted) + r.StyledText(scalar(http["upgrades"])+" upgrades (ws)", ui.TextStyle{Fg: &t.Secondary, Align: ui.AlignRight}) + }) + p.Graph(ui.GraphOptions{Values: values(http["history"]), Plot: ui.PlotOptions{Min: ref(0.), Fill: ref(true), Color: &t.Success}}, fixed(6)) + p.Divider(ui.DividerOptions{Label: "status"}) + maxV := 1. + for _, v := range arr(http["statusClasses"]) { + maxV = math.Max(maxV, num(obj(v)["count"])) + } + items := []ui.MeterItem{} + for _, v := range arr(http["statusClasses"]) { + b := obj(v) + items = append(items, ui.MeterItem{Label: scalar(b["class"]), Value: num(b["count"]) / maxV, Color: ref(statusColor(t, scalar(b["class"]))), Text: scalar(b["count"])}) + } + p.Meters(ui.MetersOptions{Items: items, LabelWidth: 5, ValueWidth: 7}) + p.Divider(ui.DividerOptions{Label: "top paths"}) + s.dataTable(p, "traffic.paths", arr(http["topPaths"]), []dataColumn{dc("path", "Path", 0, 20, &t.Primary, false), dc("count", "Hits", 7, 0, &t.Accent, true)}, false, true) + }) + }) + r.Column(ui.ColumnOptions{Layout: ui.Layout{Size: ref(ui.Fr(.85)), Gap: 1}}, func(c *ui.Container) { + c.Panel(ui.PanelOptions{Title: "SSH Activity", Subtitle: fmt.Sprint(len(arr(d["ssh"]))), BorderColor: &t.Warning}, func(p *ui.Container) { + data := slices.Clone(arr(d["ssh"])) + slices.Reverse(data) + if len(data) == 0 { + p.Label("No sshd events in the journal.") + return + } + cols := []dataColumn{dc("time", "Time", 9, 0, &t.Muted, false), dc("action", "Action", 11, 0, nil, false), dc("user", "User", 12, 0, &t.Primary, false), dc("from", "From", 0, 14, &t.Accent, false), dc("method", "Method", 10, 0, &t.Muted, false)} + cols[1].color = func(d object) ui.Color { + switch d["action"] { + case "accepted": + return t.Success + case "disconnect": + return t.Muted + } + return t.Danger + } + s.dataTable(p, "traffic.ssh", data, cols, true) + }) + c.Panel(ui.PanelOptions{Title: "Top Remote Hosts", Layout: fixed(10), BorderColor: &t.Secondary}, func(p *ui.Container) { + data := arr(d["remotes"]) + if len(data) == 0 { + p.Label("No remote peers.") + return + } + s.dataTable(p, "traffic.remotes", data, []dataColumn{dc("host", "Host", 0, 16, &t.Accent, false), dc("connections", "Conns", 6, 0, &t.Success, true), dc("protocols", "Protocols", 0, 12, &t.Muted, false)}, true) + }) + }) + }) + if len(arr(http["recent"])) > 0 { + p.Panel(ui.PanelOptions{Title: "Recent Requests", Layout: fixed(10), BorderColor: &t.Primary}, func(p *ui.Container) { + cols := []dataColumn{dc("time", "Time", 9, 0, &t.Muted, false), dc("method", "Method", 7, 0, &t.Secondary, false), dc("path", "Path", 0, 24, &t.Primary, false), dc("status", "Status", 7, 0, nil, true), dc("client", "Client", 16, 0, &t.Accent, false), dc("bytes", "Bytes", 9, 0, &t.Muted, true)} + cols[3].color = func(d object) ui.Color { + v := scalar(d["status"]) + if len(v) > 0 { + return statusColor(t, v[:1]+"xx") + } + return t.Muted + } + s.dataTable(p, "traffic.requests", arr(http["recent"]), cols, true) + }) + } +} +func (s *state) sessionsScreen(p *ui.Container) { + t := p.Theme() + d := obj(s.sample["telemetry"]) + row(p, ui.Cells(9), 1, func(r *ui.Container) { + r.Panel(ui.PanelOptions{Title: "Active Sessions", Subtitle: fmt.Sprint(len(arr(d["sessions"]))), BorderColor: &t.Success}, func(p *ui.Container) { + data := arr(d["sessions"]) + if len(data) == 0 { + p.Label("No interactive sessions.") + p.Label("(`who` reports nothing on this host)") + return + } + s.dataTable(p, "sessions.active", data, []dataColumn{dc("user", "User", 12, 0, &t.Primary, false), dc("tty", "TTY", 10, 0, nil, false), dc("from", "From", 0, 12, &t.Accent, false), dc("loginAt", "Login", 14, 0, &t.Muted, false), dc("idle", "Idle", 8, 0, nil, true)}, false) + }) + r.Panel(ui.PanelOptions{Title: "Process States", Layout: fixed(34), BorderColor: &t.Primary}, func(p *ui.Container) { + states := obj(d["states"]) + for i, key := range []string{"running", "sleeping", "stopped", "zombie"} { + color := []ui.Color{t.Success, t.Primary, t.Warning, t.Danger}[i] + p.Meter(ui.MeterOptions{Label: []string{"run ", "slp ", "stop", "zomb"}[i], Value: num(states[key]) / math.Max(1, num(states["total"])), Text: scalar(states[key]), Heat: ref(false), Color: &color}) + } + p.Spacer(ui.Cells(1)) + keys(p, []ui.KeyValueRow{kv("Total", scalar(states["total"]), t.Accent)}, true) + }) + }) + row(p, ui.Fr(1), 1, func(r *ui.Container) { + r.Panel(ui.PanelOptions{Title: "Recent Logins", Subtitle: fmt.Sprintf("%d from wtmp", len(arr(d["logins"]))), BorderColor: &t.Accent}, func(p *ui.Container) { + data := arr(d["logins"]) + if len(data) == 0 { + p.Label("No login history available.") + return + } + cols := []dataColumn{dc("user", "User", 12, 0, &t.Primary, false), dc("tty", "TTY", 12, 0, &t.Muted, false), dc("from", "From", 0, 14, &t.Accent, false), dc("when", "When", 0, 16, &t.Muted, false), dc("status", "Status", 8, 0, nil, false)} + cols[4].color = func(d object) ui.Color { + if d["status"] == "still" { + return t.Success + } + return t.Muted + } + s.dataTable(p, "sessions.logins", data, cols, true) + }) + r.Column(ui.ColumnOptions{Layout: ui.Layout{Size: ref(ui.Fr(.8)), Gap: 1}}, func(c *ui.Container) { + c.Panel(ui.PanelOptions{Title: "Failed Logins", BorderColor: &t.Danger}, func(p *ui.Container) { + data := arr(d["failedLogins"]) + if len(data) == 0 { + p.Label("None recorded.") + p.Label("(btmp is usually root-only)") + return + } + s.dataTable(p, "sessions.failed", data, []dataColumn{dc("user", "User", 12, 0, &t.Danger, false), dc("from", "From", 0, 12, nil, false), dc("when", "When", 0, 14, &t.Muted, false)}, false) + }) + c.Panel(ui.PanelOptions{Title: "Session History", Layout: fixed(8), BorderColor: &t.Secondary}, func(p *ui.Container) { + p.Label("concurrent sessions") + plot(p, d["sessionHistory"], t.Success, nil, false) + }) + }) + }) +} +func (s *state) networkScreen(p *ui.Container) { + t := p.Theme() + d := obj(s.sample["telemetry"]) + active := []any{} + for _, v := range arr(d["interfaces"]) { + i := obj(v) + if num(i["rxTotal"]) > 0 || i["state"] == "up" { + active = append(active, v) + } + } + if len(active) == 0 { + active = arr(d["interfaces"]) + } + active = active[:min(3, len(active))] + row(p, ui.Cells(13), 1, func(r *ui.Container) { + if len(active) == 0 { + r.Panel(ui.PanelOptions{Title: "Interfaces"}, func(p *ui.Container) { p.Label("No interfaces reported.") }) + return + } + for i, v := range active { + d := obj(v) + color := []ui.Color{t.Primary, t.Success, t.Secondary}[i] + r.Panel(ui.PanelOptions{Title: scalar(d["name"]) + " (" + scalar(d["state"]) + ")", Subtitle: scalar(d["ip"]), BorderColor: &color}, func(p *ui.Container) { + row(p, ui.Cells(1), 0, func(r *ui.Container) { + txt(r, "↓ "+byteRate(num(d["rxRate"])), t.Primary) + r.StyledText("↑ "+byteRate(num(d["txRate"])), ui.TextStyle{Fg: &t.Secondary, Align: ui.AlignRight}) + }) + multi(p, d["rxHistory"], d["txHistory"], t.Primary, t.Secondary) + p.Divider(ui.DividerOptions{}) + keys(p, []ui.KeyValueRow{kv("RX total", bytes(num(d["rxTotal"]), 2), t.Primary), kv("TX total", bytes(num(d["txTotal"]), 2), t.Secondary), kv("MAC", scalar(d["mac"]), t.Muted), kv("MTU / err / drop", scalar(d["mtu"])+" / "+scalar(d["errors"])+" / "+scalar(d["drops"]), t.Muted)}, true) + }) + } + }) + row(p, ui.Fr(1), 1, func(r *ui.Container) { + r.Panel(ui.PanelOptions{Title: "Connections", Subtitle: fmt.Sprintf("%d open", len(arr(d["connections"]))), BorderColor: &t.Accent}, func(p *ui.Container) { + data := arr(d["connections"]) + if len(data) == 0 { + p.Label("No connections visible (`ss` unavailable).") + return + } + s.dataTable(p, "network.connections", data, []dataColumn{dc("proto", "Proto", 6, 0, &t.Muted, false), dc("local", "Local", 0, 18, nil, false), dc("remote", "Remote", 0, 18, &t.Accent, false), dc("state", "State", 10, 0, &t.Success, false), dc("process", "Process", 0, 12, &t.Primary, false)}, true) + }) + r.Column(ui.ColumnOptions{Layout: ui.Layout{Size: ref(ui.Fr(.7)), Gap: 1}}, func(c *ui.Container) { + c.Panel(ui.PanelOptions{Title: "Listening Ports", Subtitle: fmt.Sprint(len(arr(d["listeners"]))), BorderColor: &t.Warning}, func(p *ui.Container) { + s.dataTable(p, "network.listeners", arr(d["listeners"]), []dataColumn{dc("proto", "Proto", 6, 0, &t.Muted, false), dc("port", "Port", 7, 0, &t.Warning, true), dc("address", "Address", 0, 10, &t.Muted, false), dc("process", "Process", 0, 10, &t.Primary, false)}, true) + }) + c.Panel(ui.PanelOptions{Title: "Open Connections", Layout: fixed(6), BorderColor: &t.Secondary}, func(p *ui.Container) { plot(p, d["connectionHistory"], t.Accent, nil, false) }) + }) + }) +} +func (s *state) servicesScreen(p *ui.Container) { + t := p.Theme() + d := obj(s.sample["telemetry"]) + failed := 0 + for _, v := range arr(d["services"]) { + if obj(v)["active"] == "failed" { + failed++ + } + } + row(p, ui.Fr(1), 1, func(r *ui.Container) { + subtitle := fmt.Sprintf("%d units", len(arr(d["services"]))) + border, subcolor := t.Success, t.Muted + if failed > 0 { + subtitle = fmt.Sprintf("%d failed", failed) + border = t.Danger + subcolor = t.Danger + } + r.Panel(ui.PanelOptions{Title: "Services", Subtitle: subtitle, SubtitleColor: &subcolor, BorderColor: &border}, func(p *ui.Container) { + data := arr(d["services"]) + if len(data) == 0 { + p.Label("systemd not available on this host.") + return + } + cols := []dataColumn{dc("name", "Unit", 0, 18, &t.Primary, false), dc("active", "Active", 10, 0, nil, false), dc("sub", "Sub", 10, 0, &t.Muted, false), dc("description", "Description", 0, 16, &t.Muted, false)} + cols[1].color = func(d object) ui.Color { + switch d["active"] { + case "failed": + return t.Danger + case "active": + return t.Success + } + return t.Muted + } + s.dataTable(p, "services.units", data, cols, true) + }) + r.Column(ui.ColumnOptions{Layout: ui.Layout{Size: ref(ui.Fr(.85)), Gap: 1}}, func(c *ui.Container) { + c.Panel(ui.PanelOptions{Title: "Kernel", Layout: fixed(11), BorderColor: &t.Accent}, func(p *ui.Container) { + k := obj(d["kernel"]) + blocked, entropy := t.Muted, t.Success + if num(k["procsBlocked"]) > 0 { + blocked = t.Warning + } + if num(k["entropy"]) < 200 { + entropy = t.Warning + } + krate := func(v float64) string { + if v >= 1000 { + return fmt.Sprintf("%.1fK/s", v/1000) + } + return fmt.Sprintf("%.0f/s", v) + } + keys(p, []ui.KeyValueRow{kv("Context switches", krate(num(k["contextSwitchRate"])), t.Accent), kv("Interrupts", krate(num(k["interruptRate"])), t.Accent), kv("Forks", krate(num(k["forkRate"])), t.Accent), kv("Procs running", scalar(k["procsRunning"]), t.Success), kv("Procs blocked", scalar(k["procsBlocked"]), blocked), kv("Open file descriptors", commas(num(k["openFiles"])), t.Primary), kv("Entropy available", scalar(k["entropy"]), entropy), kv("Page in / out", fmt.Sprintf("%.0fK / %.0fK", num(k["pageIn"])/1000, num(k["pageOut"])/1000), t.Muted)}, true) + }) + c.Panel(ui.PanelOptions{Title: "Containers", Layout: fixed(9), BorderColor: &t.Primary}, func(p *ui.Container) { + data := arr(d["containers"]) + if len(data) == 0 { + p.Label("No running containers.") + p.Label("(docker not installed or not reachable)") + return + } + s.dataTable(p, "services.containers", data, []dataColumn{dc("name", "Name", 0, 12, &t.Primary, false), dc("image", "Image", 0, 14, &t.Muted, false), dc("status", "Status", 0, 12, &t.Success, false)}, true) + }) + c.Panel(ui.PanelOptions{Title: "Hardware", BorderColor: &t.Warning}, func(p *ui.Container) { + rows := []ui.KeyValueRow{} + if power := obj(d["power"]); len(power) > 0 { + ac := "on battery" + if power["acConnected"] == true { + ac = "connected" + } + rows = append(rows, kv("Battery", scalar(power["battery"])+"% ("+scalar(power["timeRemaining"])+")", t.Success), kv("AC", ac, t.Muted), kv("Draw", fmt.Sprintf("%.1f W", num(power["powerDraw"])), t.Warning)) + } + for _, v := range arr(d["gpus"]) { + g := obj(v) + rows = append(rows, kv(scalar(g["name"]), percent(num(g["utilization"]))+" · "+scalar(g["temperature"])+"°C", t.Accent), kv("GPU memory", bytes(num(g["memoryUsed"]), 2)+" / "+bytes(num(g["memoryTotal"]), 2), t.Muted)) + } + if len(rows) == 0 { + p.Label("No battery or GPU telemetry on this host.") + return + } + keys(p, rows, true) + }) + }) + }) + p.Panel(ui.PanelOptions{Title: "Filesystems", Layout: fixed(10), BorderColor: &t.Secondary}, func(p *ui.Container) { + data := arr(d["filesystems"]) + if len(data) == 0 { + p.Label("No filesystems reported.") + return + } + cols := []dataColumn{dc("mount", "Mount", 0, 14, &t.Primary, false), dc("device", "Device", 0, 12, &t.Muted, false), dc("type", "Type", 8, 0, &t.Muted, false), dc("size", "Size", 10, 0, nil, true), dc("used", "Used", 10, 0, nil, true), dc("pct", "Use%", 6, 0, nil, true), dc("inodes", "Inodes", 16, 0, &t.Muted, true)} + for _, i := range []int{3, 4} { + key := cols[i].key + cols[i].format = func(d object) string { return bytes(num(d[key]), 0) } + } + cols[5].format = func(d object) string { + if num(d["size"]) == 0 { + return "-" + } + return percent(num(d["used"]) / num(d["size"])) + } + cols[5].color = func(d object) ui.Color { + if num(d["size"]) > 0 && num(d["used"])/num(d["size"]) > .9 { + return t.Danger + } + return t.Warning + } + cols[6].format = func(d object) string { + if num(d["inodesTotal"]) == 0 { + return "-" + } + return fmt.Sprintf("%s of %.1fM", percent(num(d["inodesUsed"])/num(d["inodesTotal"])), num(d["inodesTotal"])/1e6) + } + s.dataTable(p, "services.filesystems", data, cols, true) + }) +} + +// Keep estimates distinct from measured throughput without changing simulated +// reference layouts. The shell displays this only in real Traffic mode. +func trafficNotice() string { + return strings.Join([]string{"Protocol/direction: port-based estimates", "HTTP rate: estimated from log growth"}, "; ") +} diff --git a/ports/go/internal/demo/view.go b/ports/go/internal/demo/view.go index d8b9938..d1b364a 100644 --- a/ports/go/internal/demo/view.go +++ b/ports/go/internal/demo/view.go @@ -3,25 +3,8 @@ package demo import ( "fmt" ui "github.com/profullstack/hqtui/ports/go" - "math" - "strings" ) -type panelView struct { - title string - draw func(*ui.Container) -} -type column struct{ key, title string } - -func size(v float64) string { - for _, unit := range []string{"B", "KiB", "MiB", "GiB", "TiB"} { - if math.Abs(v) < 1024 || unit == "TiB" { - return fmt.Sprintf("%.1f %s", v, unit) - } - v /= 1024 - } - return "" -} func (s *state) overlay() bool { return s.help || s.modal || s.palette || s.filtering } func (s *state) action(fn func()) func() { return func() { @@ -30,341 +13,67 @@ func (s *state) action(fn func()) func() { } } } -func grid(p *ui.Container, panels []panelView, cols int) { - if p.Width() < 45 { - cols = 1 - } - columns := []ui.Size{} - rows := []ui.Size{} - for i := 0; i < cols; i++ { - columns = append(columns, ui.Fr(1)) - } - for i := 0; i < (len(panels)+cols-1)/cols; i++ { - rows = append(rows, ui.Fr(1)) - } - p.Grid(ui.GridOptions{Columns: columns, Rows: rows, Layout: ui.Layout{Gap: 1}}, func(g *ui.GridContainer) { - for _, panel := range panels { - g.Panel(ui.PanelOptions{Title: panel.title}, ui.CellOptions{}, panel.draw) - } - }) -} -func graph(p *ui.Container, data any, label string, second ...any) { - fill := true - series := []ui.Series{{Values: values(data), Label: label}} - if len(second) > 0 { - series = append(series, ui.Series{Values: values(second[0]), Label: "out"}) - } - p.Graph(ui.GraphOptions{Series: series, Axis: true, Legend: label != "", Plot: ui.PlotOptions{Fill: &fill}}) -} -func (s *state) table(p *ui.Container, name string, data []any, cols []column) { - pane := s.pane(name, len(data)) - capacity := max(0, p.Height()-1) - if name == "dashboard.processes" { - capacity = max(0, capacity-1) - } - pane.offset = ui.ResolveOffset(&pane.offset, &pane.selected, capacity, len(data), true) - rows := []ui.TableRow{} - columns := []ui.TableColumn{} - for _, c := range cols { - columns = append(columns, ui.Col(c.title)) - } - for _, row := range data { - cells := []string{} - for _, c := range cols { - cells = append(cells, str(obj(row)[c.key])) - } - rows = append(rows, ui.Row(cells...)) - } - p.Table(ui.TableOptions{Rows: rows, Columns: columns, Selected: &pane.selected, Offset: &pane.offset, FollowSelection: true, Zebra: true, Scrollbar: true}, ui.ScrollHandlers{ - OnFocus: s.action(func() { s.focused[s.screen] = name }), OnScroll: func(d int) { - if !s.overlay() { - s.focused[s.screen] = name - pane.move(d) - } - }, OnSelectRow: func(row int) { - if !s.overlay() { - pane.selected = clamp(pane.offset+row, 0, len(data)-1) - } - }}) -} -func (s *state) dashboard(p *ui.Container) { - c, m, n, sys := obj(s.sample["cpu"]), obj(s.sample["memory"]), obj(s.sample["network"]), obj(s.sample["system"]) - panels := []panelView{ - {"CPU Overview", func(p *ui.Container) { - p.Label(fmt.Sprintf("%s %.1f GHz", str(c["model"]), num(c["frequencyGhz"]))) - graph(p, c["history"], "CPU %") - for i, v := range arr(c["cores"]) { - p.Meter(ui.MeterOptions{Value: num(v), Label: fmt.Sprintf("P%d", i)}) - } - p.Label("Load: " + str(c["load"])) - }}, - {"Processes", func(p *ui.Container) { - p.Label(fmt.Sprintf("F3 filter: %s F6 sort: %s", s.filter, []string{"CPU", "MEM", "PID", "NAME"}[s.sort])) - s.table(p, "dashboard.processes", s.processes(), []column{{"pid", "PID"}, {"name", "Name"}, {"cpu", "CPU%"}, {"mem", "MEM%"}, {"threads", "THR"}, {"state", "S"}, {"user", "User"}, {"command", "Command"}}) - }}, - {"Memory & Swap", func(p *ui.Container) { - p.Meter(ui.MeterOptions{Value: num(m["used"]) / math.Max(1, num(m["total"])), Label: "RAM"}) - for _, k := range []string{"used", "available", "cached", "buffers", "free"} { - p.Text(fmt.Sprintf("%-12s %s", k, size(num(m[k])))) - } - p.Meter(ui.MeterOptions{Value: num(m["swapUsed"]) / math.Max(1, num(m["swapTotal"])), Label: "Swap"}) - graph(p, m["history"], "") - }}, - {"Network", func(p *ui.Container) { - p.Label(fmt.Sprintf("Down %s/s Up %s/s", size(num(n["downRate"])), size(num(n["upRate"])))) - graph(p, n["downHistory"], "down", n["upHistory"]) - p.Label("Received " + size(num(n["downTotal"]))) - p.Label("Sent " + size(num(n["upTotal"]))) - }}} - if p.Width() >= 90 && p.Height() >= 46 { - panels = append(panels, - panelView{"Disks", func(p *ui.Container) { - for i, v := range arr(s.sample["disks"]) { - if i >= 2 { - break - } - d := obj(v) - p.Label(str(d["device"]) + " " + str(d["mount"])) - p.Meter(ui.MeterOptions{Value: num(d["used"]) / math.Max(1, num(d["total"])), Label: "Used"}) - graph(p, d["readHistory"], "read", d["writeHistory"]) - } - }}, - panelView{"System", func(p *ui.Container) { - for _, k := range []string{"hostname", "os", "kernel", "shell", "processCount", "threadCount", "uptime"} { - p.Text(fmt.Sprintf("%-12s %s", k, str(sys[k]))) - } - graph(p, c["history"], "CPU History") - }}, - panelView{"Temperatures", func(p *ui.Container) { - temps := arr(s.sample["temperatures"]) - if len(temps) == 0 { - p.Label("No thermal sensors available") - } - for _, v := range temps { - t := obj(v) - p.Meter(ui.MeterOptions{Value: num(t["value"]) / 100, Label: str(t["label"]), Text: fmt.Sprintf("%.0f°C", num(t["value"]))}) - } - }}, - panelView{"Sensors", func(p *ui.Container) { - rows := arr(s.sample["sensors"]) - if len(rows) == 0 { - p.Label("No sensor readings available") - } - for _, v := range rows { - row := obj(v) - p.Label(str(row["label"]) + ": " + str(row["value"])) - } - }}, - panelView{"Logs", func(p *ui.Container) { - s.table(p, "dashboard.logs", arr(s.sample["logs"]), []column{{"time", "Time"}, {"level", "Level"}, {"message", "Message"}}) - }}) - } - cols := 2 - if p.Width() >= 160 && len(panels) > 4 { - cols = 3 - } - grid(p, panels, cols) -} -func (s *state) telemetry(p *ui.Container) { - t := obj(s.sample["telemetry"]) - tab := func(title, key string, cols []column) panelView { - return panelView{title, func(p *ui.Container) { s.table(p, screens[s.screen]+"."+key, arr(t[key]), cols) }} - } - trace := func(title, key string) panelView { - return panelView{title, func(p *ui.Container) { graph(p, t[key], title) }} - } - var panels []panelView +func (s *state) body(p *ui.Container) { switch s.screen { - case 2: - panels = []panelView{tab("Active Sessions", "sessions", []column{{"user", "User"}, {"tty", "TTY"}, {"from", "From"}, {"idle", "Idle"}, {"what", "Command"}}), tab("Login History", "logins", []column{{"user", "User"}, {"tty", "TTY"}, {"from", "From"}, {"when", "When"}, {"status", "Status"}}), tab("SSH Authentication", "ssh", []column{{"time", "Time"}, {"action", "Action"}, {"user", "User"}, {"from", "From"}}), trace("Session History", "sessionHistory")} - case 3: - panels = []panelView{tab("Interfaces", "interfaces", []column{{"name", "Name"}, {"state", "State"}, {"rxRate", "RX B/s"}, {"txRate", "TX B/s"}, {"errors", "Errors"}, {"drops", "Drops"}}), tab("Connections", "connections", []column{{"proto", "Proto"}, {"state", "State"}, {"local", "Local"}, {"remote", "Remote"}, {"process", "Process"}}), tab("Listeners", "listeners", []column{{"proto", "Proto"}, {"address", "Address"}, {"port", "Port"}, {"process", "Process"}}), trace("Connection History", "connectionHistory")} - case 4: - panels = []panelView{tab("Services", "services", []column{{"name", "Unit"}, {"active", "State"}, {"description", "Description"}}), tab("Filesystems", "filesystems", []column{{"mount", "Mount"}, {"device", "Device"}, {"type", "Type"}, {"used", "Used B"}, {"size", "Size B"}}), {"Kernel", func(p *ui.Container) { - k := obj(t["kernel"]) - for _, name := range []string{"contextSwitchRate", "interruptRate", "forkRate", "procsRunning", "procsBlocked", "entropy", "openFiles"} { - p.Text(name + " " + str(k[name])) - } - }}, tab("Journal", "journal", []column{{"time", "Time"}, {"level", "Level"}, {"unit", "Unit"}, {"message", "Message"}})} + case 0: + s.dashboard(p) + case 5: + s.components(p) + case 6: + s.graphics(p) + case 7: + s.themeScreen(p) + case 8: + s.inputScreen(p) + case 9: + s.stress(p) default: - panels = []panelView{tab("Protocols", "protocols", []column{{"protocol", "Protocol"}, {"inbound", "In"}, {"outbound", "Out"}, {"total", "Total"}}), {"TCP Segments", func(p *ui.Container) { graph(p, t["netInHistory"], "in", t["netOutHistory"]) }}, trace("Retransmits", "retransHistory"), {"HTTP", func(p *ui.Container) { - h := obj(t["http"]) - if len(h) == 0 { - p.Label("HTTP access log unavailable (read-only)") - return - } - p.Label(fmt.Sprintf("%.1f requests/s", num(h["requestsPerSecond"]))) - s.table(p, "traffic.http", arr(h["recent"]), []column{{"time", "Time"}, {"method", "Method"}, {"path", "Path"}, {"status", "Status"}, {"client", "Client"}}) - }}, tab("Remote Hosts", "remotes", []column{{"host", "Host"}, {"connections", "Connections"}, {"protocols", "Protocols"}}), tab("SSH Authentication", "ssh", []column{{"time", "Time"}, {"action", "Action"}, {"user", "User"}, {"from", "From"}})} - } - cols := 2 - if s.screen == 1 && p.Width() >= 120 { - cols = 3 - } - if s.screen == 2 { - panels = append(panels, tab("Failed Logins", "failedLogins", []column{{"user", "User"}, {"tty", "TTY"}, {"from", "From"}, {"when", "When"}, {"status", "Status"}})) - } - if s.screen == 1 && s.real { - p.Label("Protocol/direction: port-based estimates; HTTP rate: estimated from log growth") - } - grid(p, panels, cols) -} -func (s *state) components(p *ui.Container) { - grid(p, []panelView{ - {"Controls", func(p *ui.Container) { - p.Label("Click controls; e edits text; Esc finishes") - for _, v := range []string{"Primary", "Success", "Warning", "Danger", "Ghost"} { - p.Button(ui.ButtonOptions{Label: v}, s.action(func() { s.modal = true })) - } - p.Checkbox(ui.CheckboxOptions{Label: "Notifications", Checked: s.checked}, s.action(func() { s.checked = !s.checked })) - p.Checkbox(ui.CheckboxOptions{Label: "Live updates", Checked: s.toggle, Variant: ui.CheckboxToggle}, s.action(func() { s.toggle = !s.toggle })) - p.Select(ui.SelectOptions{Value: themes[s.selectIndex], Options: themes, SelectedIndex: s.selectIndex, Open: s.selectOpen}, s.action(func() { s.selectOpen = !s.selectOpen })) - p.TextInput(ui.TextInputOptions{Value: s.input, Placeholder: "Type here", Focused: s.editing}) - }}, - {"Meters & Gauge", func(p *ui.Container) { - for i := 0; i < 8; i++ { - p.Meter(ui.MeterOptions{Value: float64(i+1) / 8, Label: fmt.Sprintf("Meter %d", i+1)}) - } - p.Gauge(ui.GaugeOptions{Value: num(obj(s.sample["cpu"])["total"]), Label: "CPU"}) - }}, - {"Text & Badges", func(p *ui.Container) { - p.Heading("Typography & Unicode") - p.Label("日本語 中文 한국어 • café • 🚀") - for _, v := range []string{"READY", "WARNING", "LIVE"} { - p.Badge(ui.BadgeOptions{Text: v}) - } - p.Button(ui.ButtonOptions{Label: "Command palette"}, s.action(func() { s.palette = true })) - graph(p, obj(s.sample["cpu"])["history"], "History") - }}}, 3) -} -func (s *state) graphics(p *ui.Container) { - c, n := obj(s.sample["cpu"]), obj(s.sample["network"]) - grid(p, []panelView{ - {"Braille Canvas", func(p *ui.Container) { - p.Canvas(nil, func(c *ui.BrailleCanvas, _ ui.Surface) { - for x := 0; x < c.Width; x++ { - c.Pixel(float64(x), (.5+.35*math.Sin(float64(x)/12+num(s.sample["time"])))*float64(max(0, c.Height-1))) - } - }) - }}, - {"CPU Plot", func(p *ui.Container) { graph(p, c["history"], "CPU %") }}, - {"Network Plot", func(p *ui.Container) { graph(p, n["downHistory"], "down", n["upHistory"]) }}, - {"Gauge", func(p *ui.Container) { p.Gauge(ui.GaugeOptions{Value: num(c["total"]), Label: "CPU"}) }}}, 2) -} -func (s *state) themeScreen(p *ui.Container) { - grid(p, []panelView{{"Built-in Themes", func(p *ui.Container) { - p.Label("F2 or Left/Right to switch themes") - for i, name := range themes { - p.Button(ui.ButtonOptions{Label: name}, s.action(func() { s.theme = i })) - } - }}, {"Live Preview", func(p *ui.Container) { - p.Heading(themes[s.theme]) - theme := p.Theme() - for _, c := range []struct { - name string - color ui.Color - }{{"primary", theme.Primary}, {"secondary", theme.Secondary}, {"accent", theme.Accent}, {"success", theme.Success}, {"warning", theme.Warning}, {"danger", theme.Danger}, {"muted", theme.Muted}} { - p.Meter(ui.MeterOptions{Value: .7, Label: c.name, Color: &c.color}) - } - graph(p, obj(s.sample["cpu"])["history"], "Preview") - }}}, 2) -} -func (s *state) inputScreen(p *ui.Container) { - grid(p, []panelView{{"Input Inspector", func(p *ui.Container) { - p.Label("Last key: " + s.lastKey) - p.Label("Mouse: " + s.lastMouse) - p.Label("e to edit; Esc exits text input") - p.TextInput(ui.TextInputOptions{Value: s.input, Focused: s.editing}) - s.table(p, "input.events", s.keyLog, []column{{"event", "Key Events"}}) - }}, {"Diagnostics", func(p *ui.Container) { - p.Text("Renderer: native Go") - p.Text(fmt.Sprintf("Size: %d×%d", p.Width(), p.Height())) - p.Text("Arrows scroll the focused pane") - p.Text("Ctrl+K palette · F1 help · q quit") - }}}, 2) -} -func (s *state) stress(p *ui.Container) { - panels := []panelView{} - for i := 0; i < 12; i++ { - panels = append(panels, panelView{fmt.Sprintf("Stress %02d", i+1), func(p *ui.Container) { - p.Meter(ui.MeterOptions{Value: (math.Sin(num(s.sample["time"])+float64(i)) + 1) / 2, Label: "Load"}) - graph(p, obj(s.sample["cpu"])["history"], "") - }}) + s.telemetry(p) } - cols := 3 - if p.Width() >= 120 { - cols = 4 - } - grid(p, panels, cols) } func (s *state) render(p *ui.Container) { - one := ui.Cells(1) - source := "SIMULATED" - if s.real { - source = "REAL" - } - status := "LIVE" - if s.paused { - status = "PAUSED" - } - p.Row(ui.RowOptions{Layout: ui.Layout{Size: &one}}, func(r *ui.Container) { r.Heading("hqtui — go"); r.Spacer(ui.Fill()); r.Label(source + " " + status) }) - groups := [][]string{screens} - if p.Width() < 150 { - groups = [][]string{screens[:5], screens[5:]} - } - for _, group := range groups { - base := index(screens, group[0]) - tabs := []string{} - for i, name := range group { - tabs = append(tabs, fmt.Sprintf("%d %s", (base+i+1)%10, name)) + t := p.Theme() + row(p, ui.Cells(1), 0, func(r *ui.Container) { + r.StyledText(" hqtui.com", ui.TextStyle{Fg: &t.Title, Bold: true}, fixed(12)) + tabs := make([]string, len(screens)) + for i, name := range screens { + tabs[i] = fmt.Sprintf("%d %s", (i+1)%10, name) } - p.Tabs(ui.TabsOptions{Tabs: tabs, Active: s.screen - base}, func(i int) { + r.Tabs(ui.TabsOptions{Tabs: tabs, Active: s.screen}, func(i int) { if !s.overlay() { - s.screen = base + i - } - }) - } - if p.Width() < 30 || p.Height() < 12 { - p.Label("Terminal too small; resize to 30×12") - p.Spacer(ui.Fill()) - } else { - p.Column(ui.ColumnOptions{}, func(body *ui.Container) { - switch s.screen { - case 0: - s.dashboard(body) - case 5: - s.components(body) - case 6: - s.graphics(body) - case 7: - s.themeScreen(body) - case 8: - s.inputScreen(body) - case 9: - s.stress(body) - default: - s.telemetry(body) + s.screen = i } }) - } - if len(s.missing) > 0 { - p.Label("Unavailable: " + strings.Join(s.missing, ", ")) - } - p.StatusBar(ui.StatusBarOptions{Items: []ui.StatusItem{ui.StatusKey("F1", "help"), ui.StatusKey("F2", "theme"), ui.StatusKey("F3", "filter"), ui.StatusKey("^K", "palette"), ui.StatusKey("q", "quit")}, Right: []ui.StatusItem{ui.Status(screens[s.screen] + " · " + themes[s.theme])}}) + status, color := "live", t.Success + if s.paused { + status = "paused" + color = t.Warning + } + source := "simulated" + if s.real { + source = "real" + } + r.StyledText(fmt.Sprintf("%s %s %.0ffps %s ", status, source, s.fps, s.clock), ui.TextStyle{Fg: &color, Align: ui.AlignRight}) + }) + p.Spacer(ui.Cells(1)) + col(p, ui.Cells(max(0, p.Height()-4)), s.body) + p.Spacer(ui.Cells(1)) + filter := "Filter" if s.filtering { - p.Modal(ui.ModalOptions{Title: "Filter Processes", Message: s.filter + "▏\nEnter applies · Esc clears", Width: 60}, nil) + filter = "Filter: " + s.filter + "_" } + p.StatusBar(ui.StatusBarOptions{Items: []ui.StatusItem{ui.StatusKey("F1", "Help"), ui.StatusKey("F2", "Theme ("+t.Name+")"), {Key: "F3", Label: filter, Active: s.filtering}, ui.StatusKey("F6", "Sort: "+[]string{"cpu", "mem", "pid", "name"}[s.sort]), ui.StatusKey("^K", "Palette"), ui.StatusKey("Tab", "Screen"), ui.StatusKey("q", "Quit")}, Right: []ui.StatusItem{ui.Status(fmt.Sprintf("%.2fms %d cells %dB", s.renderMs, s.changedCells, s.outputBytes))}}) if s.help { - p.Modal(ui.ModalOptions{Title: "hqtui — Help", Width: 66, Message: "1–9/0 / Tab: screen\nF2: theme F3: filter F6: sort\nCtrl+K: palette Space: pause\nArrows / PgUp / PgDn / Home / End: scroll\nMouse: tabs, controls, selection and wheel\ne: edit text in Components/Input\nq / Ctrl+C: quit · Any key: close help"}, nil) + p.Modal(ui.ModalOptions{Title: "hqtui — Help", Message: "1–9/0 / Tab: screen\nF2 theme · F3 filter · F6 sort\nCtrl+K palette · Space pause\nArrows / PgUp / PgDn / Home / End: scroll\nMouse tabs, controls, selection and wheel\ne edits text · Esc finishes\nq / Ctrl+C quit · Any key closes help"}, nil) } if s.modal { - p.Modal(ui.ModalOptions{Title: "Read-only Demo", Width: 58, Message: "No process will be killed and no service changed.\nPress any key to close."}, nil) + p.Modal(ui.ModalOptions{Title: "Read-only Demo", Message: "No process will be killed and no service changed.\nPress any key to close."}, nil) } if s.palette { items := []ui.PaletteItem{} for _, name := range s.commands() { items = append(items, ui.PaletteItem{Label: name}) } - p.CommandPalette(ui.CommandPaletteOptions{Query: s.query, Items: items, Selected: s.paletteIndex, Placeholder: "Search commands"}) + p.CommandPalette(ui.CommandPaletteOptions{Query: s.query, Items: items, Selected: s.paletteIndex}) } } diff --git a/ports/python/hqtui/__init__.py b/ports/python/hqtui/__init__.py index 7a48886..223d1e5 100644 --- a/ports/python/hqtui/__init__.py +++ b/ports/python/hqtui/__init__.py @@ -31,9 +31,9 @@ from .ui import Cell, Container, Grid, GridSpec, Layout, Panel, ScrollHandlers from .unicode import Align, fit, string_width, truncate, wrap -__version__ = "0.1.11" +__version__ = "0.1.12" -REFERENCE_VERSION = "0.1.11" +REFERENCE_VERSION = "0.1.12" """The version of the TypeScript reference implementation this port tracks.""" __all__ = [ diff --git a/ports/python/hqtui_demo/__init__.py b/ports/python/hqtui_demo/__init__.py index cc50515..832b7e5 100644 --- a/ports/python/hqtui_demo/__init__.py +++ b/ports/python/hqtui_demo/__init__.py @@ -1,3 +1,3 @@ """The native Python HQTUI reference system monitor.""" -__version__ = "0.1.11" +__version__ = "0.1.12" diff --git a/ports/python/hqtui_demo/__main__.py b/ports/python/hqtui_demo/__main__.py index 24ffcf7..d4aa9b7 100644 --- a/ports/python/hqtui_demo/__main__.py +++ b/ports/python/hqtui_demo/__main__.py @@ -43,7 +43,7 @@ def main(argv=None): print("An interactive terminal is required. Use --snapshot for headless output.",file=sys.stderr) return 2 source=Simulation(args.seed) if args.sim or (args.snapshot and not args.real) else Collector() - source.refresh(0.1) + if not isinstance(source,Simulation): source.refresh(0.1) for _ in range(args.ticks): if isinstance(source,Simulation): source.refresh(.1) state=State(copy.deepcopy(source.sample),source.source,list(source.unavailable),source.sensor_note,screen=args.screen,theme_index=THEMES.index(args.theme)) @@ -69,6 +69,7 @@ def paste(event): def update(frame): nonlocal pending,next_poll,last_frame now=time.monotonic(); state.fps=1/max(.001,now-last_frame); last_frame=now + state.clock=time.strftime("%H:%M:%S",time.gmtime()) if pending is not None and pending.done(): pending.result(); pending=None if not state.paused: diff --git a/ports/python/hqtui_demo/dashboard.py b/ports/python/hqtui_demo/dashboard.py new file mode 100644 index 0000000..eb94d70 --- /dev/null +++ b/ports/python/hqtui_demo/dashboard.py @@ -0,0 +1,181 @@ +"""Native reference dashboard. Cell expectations come from the TypeScript app.""" +from __future__ import annotations +import math +import hqtui.widgets as w +from hqtui import Layout, Panel, ScrollHandlers +from hqtui.graphics import GaugeOptions, PlotOptions, Series +from hqtui.theme import heat_color +from hqtui.widgets.table import resolve_offset + +def scalar(v): + if v is None: return "—" + if isinstance(v, float) and v.is_integer(): return str(int(v)) + return str(v) + +def bytes(v, digits=2): + if v is None or not math.isfinite(v): return "—" + v=max(0,v) + for unit in ("B","KiB","MiB","GiB","TiB","PiB"): + if v<1024 or unit=="PiB": return f'{v:.{0 if unit=="B" else digits}f} {unit}' + v/=1024 + +def percent(v): return f'{math.floor(max(0,min(1,v))*100+.5)}%' +def bit_rate(v): + v=max(0,v)*8 + for scale,unit in ((1e9,"Gb/s"),(1e6,"Mb/s"),(1e3,"Kb/s")): + if v>=scale: return f'{v/scale:.1f} {unit}' + return f'{v:.0f} b/s' +def byte_rate(v): + for scale,unit in ((1e9,"GB/s"),(1e6,"MB/s"),(1e3,"KB/s")): + if v>=scale: return f'{v/scale:.1f} {unit}' + return f'{max(0,v):.0f} B/s' +def duration(v): + v=int(max(0,v));d,h,m=v//86400,v%86400//3600,v%3600//60 + if d: return f'{d}d {h}h {m}m' + if h: return f'{h}h {m}m' + return f'{m}m {v%60}s' +def row(p,size,gap,fn): p.row(Layout(size=size,gap=gap),fn) +def col(p,size,fn): p.column(Layout(size=size),fn) +def txt(p,value,color): p.text(value,w.TextStyle(fg=color)) +def kv(label,value,color=None): return w.KeyValueRow(label,scalar(value),color=color) +def keys(p,rows,spread=True): p.key_values(w.KeyValueOptions(rows=rows,spread=spread)) +def plot(p,values,color,maximum=None,axis=False): p.graph(w.GraphOptions(values=values,axis=axis,plot=PlotOptions(min=0,max=maximum,fill=True,color=color))) +def multi(p,a,b,ca,cb): p.graph(w.GraphOptions(series=[Series(values=a,color=ca,fill=True),Series(values=b,color=cb,fill=True)],plot=PlotOptions(min=0))) +def meter(p,value,color=None): p.meter(w.MeterOptions(value=value,color=color,show_value=False,style="segmented")) +def overlay(s): return s.help or s.modal or s.palette or s.filtering + +def data_table(p,s,name,data,columns,zebra=False,header=True,scrollbar=True): + p.column(Layout(),lambda surface:_data_table(surface,s,name,data,columns,zebra,header,scrollbar)) + +def _data_table(p,s,name,data,columns,zebra=False,header=True,scrollbar=True): + """Columns are (field, widget options, optional formatter, optional color).""" + pane=s.pane(name,len(data)) + pane.offset=resolve_offset(pane.offset,pane.selected,max(0,p.height-int(header)),len(data),True) + rows=[] + for d in data: + rows.append(w.TableRow([fmt(d) if fmt else scalar(d.get(key)) for key,c,fmt,color in columns],cell_colors=[color(d) if color else c.color for key,c,fmt,color in columns])) + def focus(): + if not overlay(s): s.focused[s.screen]=name + def scroll(delta): + if not overlay(s): focus();pane.move(delta) + def select(i): + if not overlay(s): pane.selected=min(max(0,len(data)-1),pane.offset+i) + p.table(w.TableOptions(rows=rows,columns=[c for _,c,_,_ in columns],selected=pane.selected,offset=pane.offset,follow_selection=True,zebra=zebra,scrollbar=scrollbar,header=header),ScrollHandlers(on_focus=focus,on_scroll=scroll,on_select_row=select)) +def dc(key,title,width=None,minimum=None,color=None,right=False,fmt=None,cell_color=None): + return key,w.TableColumn(title,width=width,min=minimum,color=color,align="right" if right else "left"),fmt,cell_color + +def cpu_panel(ui,s,columns): + c=s.sample["cpu"] + def draw(p): + t=p.theme;p.label(f'{c["model"]} {c["frequencyGhz"]:.1f} GHz');plot(p,c["history"],t.success,100) + p.meters(w.MetersOptions(items=[w.MeterItem(label=f'P{i}',value=v) for i,v in enumerate(c["cores"])],columns=columns,label_width=4,value_width=5,style="segmented")) + p.divider();keys(p,[kv("Load Avg"," ".join(f'{v:.2f}' for v in c["load"]),t.warning)]) + ui.panel(Panel(title="CPU Overview",subtitle=percent(c["total"])),draw) +def memory_panel(ui,s): + m=s.sample["memory"];used=m["used"]/max(1,m["total"]);swap=m["swapUsed"]/max(1,m["swapTotal"]) + def draw(p): + t=p.theme;txt(p,f'Memory {bytes(m["used"])} / {bytes(m["total"])} ({percent(used)})',t.foreground);meter(p,used);p.spacer(1) + keys(p,[kv(label,bytes(m[key]),color) for key,label,color in (("used","Used:",t.warning),("available","Available:",t.success),("cached","Cached:",t.accent),("buffers","Buffers:",t.secondary),("free","Free:",t.muted))]) + p.spacer();p.divider();txt(p,f'Swap {bytes(m["swapUsed"])} / {bytes(m["swapTotal"])} ({percent(swap)})',t.foreground);meter(p,swap,t.secondary) + keys(p,[kv("Used:",bytes(m["swapUsed"]),t.secondary),kv("Free:",bytes(m["swapTotal"]-m["swapUsed"]),t.muted)]) + ui.panel(Panel(title="Memory & Swap"),draw) +def disks_panel(ui,s): + disks=s.sample["disks"] + def draw(p): + t=p.theme + if not disks: p.label("No disks reported");return + for i,d in enumerate(disks[:2]): + used=d["used"]/max(1,d["total"]) + txt(p,f'{d["device"]} — {bytes(d["total"])} ({d["type"]})',t.foreground);txt(p,f'Used: {bytes(d["used"])} ({percent(used)})',t.muted);meter(p,used);txt(p,"Free: "+bytes(d["total"]-d["used"]),t.muted) + row(p,1,0,lambda r,d=d:(txt(r,"Read: "+byte_rate(d["readRate"]),t.success),r.text("Write: "+byte_rate(d["writeRate"]),w.TextStyle(fg=t.secondary,align="right")))) + multi(p,d["readHistory"],d["writeHistory"],t.success,t.secondary) + if i==0 and len(disks)>1: p.divider() + ui.panel(Panel(title="Disks"),draw) +def system_panel(ui,s): + sys,c,m=s.sample["system"],s.sample["cpu"],s.sample["memory"];used=m["used"]/max(1,m["total"]);count=sys["processCount"] or len(s.sample["processes"]) + def draw(p): + t=p.theme + def summary(r): + keys(r,[kv("OS:",sys["os"]),kv("Kernel:",sys["kernel"]),kv("Uptime:",duration(sys["uptime"])),kv("Hostname:",sys["hostname"]),kv("Shell:",sys["shell"]),kv("Source:","simulated" if s.source=="simulated" else "linux/proc",t.accent)],False) + keys(r,[kv("CPU:",percent(c["total"]),heat_color(t,c["total"])),kv("Memory:",f'{percent(used)} ({bytes(m["used"])})',t.warning),kv("Swap:",percent(m["swapUsed"]/m["swapTotal"]) if m["swapTotal"] else "—",t.secondary),kv("Load:"," ".join(f'{v:.2f}' for v in c["load"])),kv("Processes:",count),kv("Threads:",sys["threadCount"])],False) + p.row(Layout(size=6,min=6,gap=2),summary) + p.panel(Panel(title="CPU History",min=5),lambda g:plot(g,c["history"],t.success,100,True)) + if p.width>=46 and p.height>=16: + def stats(r): + r.panel(Panel(title="Quick Stats"),lambda q:keys(q,[kv("Uptime",duration(sys["uptime"]),t.accent),kv("Procs",count,t.accent),kv("Threads",sys["threadCount"] or "—",t.accent),kv("Ctx/s",f'{s.sample["telemetry"]["kernel"]["contextSwitchRate"]/1000:.1f}K',t.accent)])) + r.panel(Panel(title="Memory"),lambda q:(txt(q,percent(used),t.warning),plot(q,m["history"],t.primary,100))) + temp=next(iter(s.sample["temperatures"]),None) + r.panel(Panel(title="Temp",size=14),lambda q:q.gauge(GaugeOptions(value=min(1,temp["value"]/(temp["max"] or 100)) if temp else c["total"],label=f'{math.floor(temp["value"]+.5)}°C' if temp else percent(c["total"])))) + row(p,6,1,stats) + else: + p.divider();keys(p,[kv("Threads",sys["threadCount"],t.accent),kv("Ctx switches",f'{sys["contextSwitches"]/1000:.1f}K',t.accent)]) + ui.panel(Panel(title="System"),draw) +def processes_panel(ui,s): + def draw(p): + t=p.theme + columns=[dc("pid","PID",7,right=True),dc("name","Name",minimum=8,color=t.primary),dc("cpu","CPU%",6,right=True,fmt=lambda d:f'{d["cpu"]:.1f}',cell_color=lambda d:heat_color(t,min(1,d["cpu"]/100))),dc("mem","MEM%",6,color=t.warning,right=True,fmt=lambda d:f'{d["mem"]:.1f}'),dc("rss","RSS",9,right=True,fmt=lambda d:bytes(d["rss"],0)),dc("threads","Threads",7,right=True),dc("state","S",2,cell_color=lambda d:t.success if d["state"]=="R" else t.muted),dc("user","User",10,color=t.muted),dc("command","Command",minimum=10,color=t.muted)] + data_table(p,s,"dashboard.processes",s.processes(),columns) + ui.panel(Panel(title=f'Processes (sorted by {s.sort.upper()})',subtitle="filter: "+s.filter if s.filter else "",focusable=True),draw) +def network_panel(ui,s): + n=s.sample["network"] + def draw(p): + t=p.theme;row(p,1,0,lambda r:(txt(r,"Download: "+bit_rate(n["downRate"]),t.primary),r.text("Upload: "+bit_rate(n["upRate"]),w.TextStyle(fg=t.secondary,align="right")))) + for prefix,color in (("down",t.primary),("up",t.secondary)): + p.graph(w.GraphOptions(values=n[prefix+"History"],axis=True,axis_format=lambda v:bit_rate(v).replace(" ",""),plot=PlotOptions(min=0,fill=True,color=color))) + p.divider() + def totals(r): + for prefix,color in (("down",t.primary),("up",t.secondary)): + keys(r,[kv("Total:",bytes(n[prefix+"Total"]),color),kv("Current:",bit_rate(n[prefix+"Rate"]),color),kv("Peak:",bit_rate(n[prefix+"Peak"]),color)],False) + row(p,3,2,totals) + ui.panel(Panel(title="Network"),draw) +def disk_usage_panel(ui,s): + disks=s.sample["disks"];first=disks[0] if disks else {} + def draw(p): + t=p.theme + for d in disks: + used=d["used"]/max(1,d["total"]);p.meter(w.MeterOptions(value=used,text=f'{percent(used)} {bytes(d["used"],0)} / {bytes(d["total"],0)}',style="segmented"));p.label(f'{d["mount"]} ({d["device"]})') + p.spacer(1) + def io(p): + def columns(r): + for prefix,label,color in (("read","Read: ",t.success),("write","Write: ",t.secondary)): + col(r,"fill",lambda c,prefix=prefix,label=label,color=color:(txt(c,label+byte_rate(first.get(prefix+"Rate",0)),color),plot(c,first.get(prefix+"History",[]),color))) + row(p,"fill",2,columns) + p.panel(Panel(title="I/O Summary"),io) + ui.panel(Panel(title="Disk Usage",subtitle=first.get("device","")),draw) +def temperatures_panel(ui,s): + def draw(p): + t=p.theme;temps=s.sample["temperatures"] + if not temps: + txt(p,"No thermal sensors on this host.",t.muted);p.spacer(1);p.label("Run with --sim to see this panel populated.");return + for temp in temps[:10]: + v=min(1,temp["value"]/(temp["max"] or 100)) + row(p,1,0,lambda r,temp=temp,v=v:(r.text(temp["label"],w.TextStyle(fg=t.muted),Layout(size=16)),r.heat_bar(w.HeatBarOptions(value=v)),r.text(f'{math.floor(temp["value"]+.5)}°C',w.TextStyle(fg=heat_color(t,v),align="right"),Layout(size=6)))) + ui.panel(Panel(title="Temperatures"),draw) +def sensors_panel(ui,s): + def draw(p): + sensors=s.sample["sensors"] + if not sensors: + p.label("No hardware sensors on this host.");p.spacer(1);p.label("Probed: /sys/class/hwmon, thermal zones, lm-sensors,");p.label("power supplies and nvidia-smi.");return + keys(p,[kv(v["label"],v["value"],p.theme.accent) for v in sensors]) + ui.panel(Panel(title="Sensors"),draw) +def logs_panel(ui,s): + def draw(p): + data=s.sample["logs"];pane=s.pane("dashboard.logs",len(data));pane.log=True + def focus(): + if not overlay(s): s.focused[s.screen]="dashboard.logs" + def scroll(d): + if not overlay(s): focus();pane.offset=max(0,min(max(0,len(data)-1),pane.offset-d)) + p.log(w.LogOptions(entries=[w.LogEntry(time=d["time"],level=d["level"],message=d["message"],meta="{"+d["meta"]+"}") for d in data],from_end=pane.offset,scrollbar=True),ScrollHandlers(on_focus=focus,on_scroll=scroll)) + ui.panel(Panel(title="Logs"),draw) +def dashboard(ui,s): + if ui.width>=150: + row(ui,19 if ui.height>=44 else 16,1,lambda r:(col(r,"1fr",lambda c:cpu_panel(c,s,2)),col(r,"0.95fr",lambda c:memory_panel(c,s)),col(r,"0.95fr",lambda c:disks_panel(c,s)),col(r,"1.35fr",lambda c:system_panel(c,s)))) + row(ui,"1fr",1,lambda r:(col(r,"2fr",lambda c:processes_panel(c,s)),col(r,"1.2fr",lambda c:network_panel(c,s)),col(r,"1.2fr",lambda c:disk_usage_panel(c,s)))) + row(ui,12,1,lambda r:(temperatures_panel(r,s),sensors_panel(r,s),col(r,"1.6fr",lambda c:logs_panel(c,s)))) + elif ui.width>=100: + row(ui,14,1,lambda r:(cpu_panel(r,s,2),memory_panel(r,s),system_panel(r,s))) + row(ui,"1fr",1,lambda r:(col(r,"1.6fr",lambda c:processes_panel(c,s)),col(r,"fill",lambda c:network_panel(c,s)))) + row(ui,10,1,lambda r:(temperatures_panel(r,s),logs_panel(r,s))) + else: + row(ui,10,1,lambda r:(cpu_panel(r,s,1),memory_panel(r,s))) + col(ui,"fill",lambda c:processes_panel(c,s));row(ui,8,1,lambda r:network_panel(r,s)) diff --git a/ports/python/hqtui_demo/model.py b/ports/python/hqtui_demo/model.py index 267698d..484ac7d 100644 --- a/ports/python/hqtui_demo/model.py +++ b/ports/python/hqtui_demo/model.py @@ -107,8 +107,11 @@ class Pane: selected: int = 0 offset: int = 0 total: int = 0 + log: bool = False def move(self, delta: int) -> None: + if self.log: + self.offset=max(0,min(max(0,self.total-1),self.offset-delta));return self.selected = max(0, min(max(0, self.total - 1), self.selected + delta)) @@ -144,6 +147,8 @@ class State: render_ms: float = 0 changed_cells: int = 0 output_bytes: int = 0 + slider: float = .7 + clock: str = "12:00:00" def pane(self, name: str, total: int) -> Pane: pane = self.panes.setdefault(name, Pane()) @@ -199,8 +204,8 @@ def key(self, key: str, char: str = "") -> bool: return False if self.select_open: if key == "escape": self.select_open = False - elif key == "up": self.select_index = (self.select_index - 1) % len(THEMES) - elif key == "down": self.select_index = (self.select_index + 1) % len(THEMES) + elif key == "up": self.select_index = (self.select_index - 1) % 4 + elif key == "down": self.select_index = (self.select_index + 1) % 4 elif key == "enter": self.theme_index = self.select_index; self.select_open = False return False if key == "e" and self.screen in ("components", "input"): diff --git a/ports/python/hqtui_demo/showcase.py b/ports/python/hqtui_demo/showcase.py new file mode 100644 index 0000000..602c4d1 --- /dev/null +++ b/ports/python/hqtui_demo/showcase.py @@ -0,0 +1,134 @@ +"""The reference widget, graphics, theme, input and stress showcases in Python.""" +import math +from hqtui import GridSpec, Cell, ScrollHandlers +from hqtui.buffer import Style +from hqtui.color import Gradient +from hqtui.graphics import DonutOptions, DonutSegment +from hqtui.theme import resolve_theme +from .model import THEMES +from .dashboard import (w,Layout,Panel,row,txt,kv,keys,percent,bytes,data_table,dc, + scalar,overlay,PlotOptions,Series,GaugeOptions) + +def graphics(ui,s): + t=ui.theme;time=s.sample["time"] + wave=lambda phase,freq:[math.sin(i/freq+phase)*50+50 for i in range(240)] + a,b,c=wave(time/3,9),wave(time/3+2,5),wave(time/2,17) + def draw(r): + def left(p): + p.panel(Panel(title="Braille (2×4 pixels per cell)"),lambda p:p.graph(w.GraphOptions(values=a,plot=PlotOptions(min=0,max=100,fill=True,color=t.accent,grid=True)))) + p.panel(Panel(title="Block elements"),lambda p:p.graph(w.GraphOptions(values=a,plot=PlotOptions(min=0,max=100,mode="block",colors=t.heat)))) + p.panel(Panel(title="ASCII fallback"),lambda p:p.graph(w.GraphOptions(values=a,plot=PlotOptions(min=0,max=100,mode="ascii",color=t.foreground)))) + r.column(Layout(gap=1),left) + def right(p): + p.panel(Panel(title="Multi-series"),lambda p:p.graph(w.GraphOptions(series=[Series(a,t.primary,"alpha"),Series(b,t.success,"beta"),Series(c,t.secondary,"gamma")],axis=True,legend=True,plot=PlotOptions(min=0,max=100)))) + def gradient(surface): + steps=Gradient(t.heat).steps(surface.width) + for y in range(surface.height): + for x,color in enumerate(steps): surface.char(x,y,"█",Style(fg=color)) + p.panel(Panel(title="Gradients"),lambda p:p.draw(gradient)) + def canvas(c,surface): + cx,cy=c.width/2,c.height/2;radius=min(cx,cy)-2;c.circle(cx,cy,radius) + for i in range(12): + angle=i/12*math.pi*2+time/4;c.line(cx,cy,cx+math.cos(angle)*radius,cy+math.sin(angle)*radius*.9) + p.panel(Panel(title="Raw Braille canvas"),lambda p:p.canvas(canvas,color=t.accent)) + r.column(Layout(gap=1),right) + row(ui,"1fr",1,draw) +def themes(ui,s): + ui.label(f'Theme {s.theme_index+1}/9: {ui.theme.name} ←/→ or F2 to change');ui.spacer(1) + def grid(g): + for i,name in enumerate(THEMES): + e=resolve_theme(name) + def draw(p,e=e): + def badges(r): + for label,color,width in (("primary",e.primary,10),("ok",e.success,5),("warn",e.warning,7),("err",e.danger,6)): + r.badge(w.BadgeOptions(text=label,color=color),Layout(size=width)) + r.spacer() + row(p,1,1,badges);p.meter(w.MeterOptions(value=.72,label="cpu",background=e.background)) + p.graph(w.GraphOptions(values=s.sample["cpu"]["history"],plot=PlotOptions(min=0,max=100,fill=True,color=e.graph[0],background=e.background))) + def ramp(surface): + for ci,color in enumerate(e.graph): + for x in range(3): surface.char(ci*4+x,0,"█",Style(fg=color,bg=e.background)) + p.draw(ramp,Layout(size=1)) + g.panel(Panel(title=e.name,border_color=e.border_focused if i==s.theme_index else e.border,background=e.background),Cell(),draw) + ui.grid(GridSpec(columns=3,rows=3,gap=1),grid) +def input_screen(ui,s): + t=ui.theme + def draw(r): + def events(p): + keys(p,[kv("Key",s.last_key,t.accent),kv("Mouse",s.last_mouse,t.primary)]);p.spacer(1);p.divider(w.DividerOptions(label="history"));p.list(w.ListOptions(items=[w.ListItem(v) for v in reversed(s.key_log[-20:])])) + r.panel(Panel(title="Last Events"),events) + def controls(p): + txt(p,"Press any key — modifiers are normalized.",t.foreground);p.label("Arrows, Function keys, Ctrl/Alt/Shift combinations,");p.label("paste, focus, mouse move, click, drag and scroll.");p.spacer(1);p.divider(w.DividerOptions(label="focusable controls"));p.spacer(1) + def buttons(r): + r.button(w.ButtonOptions(label="Button A",width=12),layout=Layout(size=12));r.button(w.ButtonOptions(label="Button B",width=12,variant="success"),layout=Layout(size=12)) + r.checkbox(w.CheckboxOptions(label="Check",checked=s.checkbox),lambda:toggle(s,"checkbox"),Layout(size=12));r.spacer() + row(p,1,2,buttons);p.spacer(1);p.label("Tab / Shift+Tab moves focus. Enter activates.");p.spacer();keys(p,[kv("Mouse tracking","on"),kv("Bracketed paste","on"),kv("Focus events","on")]) + r.panel(Panel(title="Try it"),controls) + row(ui,"1fr",1,draw) +def stress(ui,s): + t=ui.theme + def stats(r): + for title,value,color in (("Render",f'{s.render_ms:.2f} ms/frame',t.success),("Changed cells",str(s.changed_cells),t.warning),("Bytes/frame",str(s.output_bytes),t.primary),("FPS",f'{s.fps:.1f}',t.accent)): + r.panel(Panel(title=title),lambda p,value=value,color=color:txt(p,value,color)) + row(ui,3,1,stats) + def draw(surface): + ramp=Gradient(t.graph);chars="▖▗▘▙▚▛▜▝▞▟█▓▒░";time=s.sample["time"] + for y in range(surface.height): + for x in range(surface.width): + v=(math.sin(x/6+time)+math.cos(y/4-time))/2;n=(v+1)/2;surface.char(x,y,chars[math.floor(n*(len(chars)-1))],Style(fg=ramp.sample(n))) + ui.panel(Panel(title="Full-screen churn"),lambda p:p.draw(draw)) +def toggle(s,key): + if not overlay(s): setattr(s,key,not getattr(s,key)) +def tree(): + def node(name,cpu,mem,*children): return w.TreeNode(label=name,values=[w.TreeValue(cpu,6),w.TreeValue(mem,6)],children=children) + return [node("systemd","1.3","0.1",node("bash","0.1","0.2"),node("bun","32.8","4.2",node("bun:worker","12.4","1.8"),node("bun:worker","8.7","1.3")),node("node","18.1","2.1",node("node:worker","6.1","0.8")),node("postgres","6.7","1.8"))] +def handlers(s,name,pane): + def focus(): + if not overlay(s): s.focused[s.screen]=name + def scroll(d): + if not overlay(s): focus();pane.move(d) + def select(i): + if not overlay(s): pane.selected=max(0,min(pane.total-1,pane.offset+i)) + return ScrollHandlers(on_focus=focus,on_scroll=scroll,on_select_row=select) +def components(ui,s): + t=ui.theme;c,m,n=s.sample["cpu"],s.sample["memory"],s.sample["network"] + def draw(r): + def left(left): + def controls(p): + def buttons(r): + for label,variant,width in (("Primary","primary",11),("Success","success",11),("Warning","warning",11),("Danger","danger",10)): + r.button(w.ButtonOptions(label=label,variant=variant,width=width),(lambda:toggle(s,"modal")) if label=="Primary" else None,Layout(size=width)) + r.spacer() + row(p,1,1,buttons);p.spacer(1) + def inputs(r): + options=("Dark","Dracula","Nord","Tokyo Night") + r.select(w.SelectOptions(value=options[s.select_index%4],width=20,open=s.select_open,options=options,selected_index=s.select_index),lambda:toggle(s,"select_open"),Layout(size=20)) + r.checkbox(w.CheckboxOptions(label="Toggle",checked=s.toggle,variant="toggle"),lambda:toggle(s,"toggle"),Layout(size=12)) + r.checkbox(w.CheckboxOptions(label="Checkbox",checked=s.checkbox),lambda:toggle(s,"checkbox"),Layout(size=14));r.spacer() + row(p,1,2,inputs);p.spacer(1);p.text_input(w.TextInputOptions(label="Search",value=s.input_value,placeholder="type to filter…",focused=s.editing),Layout(size=1));p.spacer(1) + p.meter(w.MeterOptions(label="Slider",value=s.slider,style="smooth",heat=False,color=t.primary));p.progress(w.ProgressOptions(label="Progress",value=37,max=120,show_count=True)) + left.panel(Panel(title="Buttons & Inputs",size=13),controls) + files=[dict(zip(("name","size","type","modified"),v)) for v in (("src","4.2 KB","dir","2m ago"),("test","1.1 KB","dir","5m ago"),("package.json","1.2 KB","file","10m ago"),("README.md","3.4 KB","file","1h ago"),("bun.lockb","12 KB","file","1h ago"))] + left.panel(Panel(title="Table Widget"),lambda p:data_table(p,s,"components.files",files,[dc("name","Name",minimum=10,color=t.primary),dc("size","Size",9,right=True),dc("type","Type",6),dc("modified","Modified",10,color=t.muted,right=True)],True,scrollbar=False)) + def logs(p): + data=s.sample["logs"];pane=s.pane("components.logs",len(data));pane.log=True;h=handlers(s,"components.logs",pane) + def scroll(d): + if not overlay(s): h.on_focus();pane.offset=max(0,min(max(0,len(data)-1),pane.offset-d)) + p.log(w.LogOptions(entries=[w.LogEntry(time=d["time"],level=d["level"],message=d["message"],meta="{"+d["meta"]+"}") for d in data],from_end=pane.offset,scrollbar=True),ScrollHandlers(on_focus=h.on_focus,on_scroll=scroll)) + left.panel(Panel(title="Log Viewer",size=11),logs) + r.column(Layout(gap=1),left) + def right(right): + def processes(p): + row(p,1,0,lambda r:(r.text("Name",w.TextStyle(fg=t.muted,bold=True)),r.text("CPU% MEM%",w.TextStyle(fg=t.muted,bold=True,align="right")))) + pane=s.pane("components.tree",8);p.tree(w.TreeOptions(nodes=tree(),selected=pane.selected,offset=pane.offset,follow_selection=True),handlers(s,"components.tree",pane)) + right.panel(Panel(title="Process Tree",size=13),processes) + def gauges(p): + p.sparkline(w.SparklineWidgetOptions(label="CPU ",values=c["history"],text=percent(c["total"]),color=t.success));p.sparkline(w.SparklineWidgetOptions(label="Mem ",values=m["history"],text=percent(m["used"]/max(1,m["total"])),color=t.warning));p.sparkline(w.SparklineWidgetOptions(label="Net ",values=n["downHistory"],text=bytes(n["downRate"])+"/s",color=t.primary));p.spacer(1) + row(p,"1fr",2,lambda r:(r.gauge(GaugeOptions(value=c["total"],label=percent(c["total"]))),r.donut(DonutOptions(segments=[DonutSegment(m["used"],t.primary,"Used"),DonutSegment(m["available"],t.warning,"Free")])))) + right.panel(Panel(title="Sparklines & Gauges",size=12),gauges) + def lists(p): + row(p,1,1,lambda r:(r.badge(w.BadgeOptions(text="active",color=t.success),Layout(size=10)),r.badge(w.BadgeOptions(text="idle",color=t.warning,variant="subtle"),Layout(size=8)),r.badge(w.BadgeOptions(text="failed",color=t.danger,variant="outline"),Layout(size=10)),r.spacer())) + p.spacer(1);pane=s.pane("components.list",4);p.list(w.ListOptions(items=[w.ListItem("apps/demo",color=t.primary),w.ListItem("packages/hqtui"),w.ListItem("apps/web"),w.ListItem("docs")],selected=pane.selected,offset=pane.offset,follow_selection=True,bullet="▸",scrollbar=True),handlers(s,"components.list",pane)) + right.panel(Panel(title="Lists & Badges"),lists) + r.column(Layout(gap=1),right) + row(ui,"1fr",1,draw) diff --git a/ports/python/hqtui_demo/telemetry_view.py b/ports/python/hqtui_demo/telemetry_view.py new file mode 100644 index 0000000..ab5517d --- /dev/null +++ b/ports/python/hqtui_demo/telemetry_view.py @@ -0,0 +1,140 @@ +"""Native Traffic, Sessions, Network and Services reference screens.""" +from .dashboard import (w, Layout, Panel, row, col, txt, kv, keys, plot, multi, + scalar, bytes, percent, byte_rate, data_table, dc, PlotOptions) +from hqtui.theme import series_color + +def rate(v): + if v>=1e6: return f'{v/1e6:.1f}M/s' + if v>=1000: return f'{v/1000:.1f}K/s' + return f'{v:.0f}/s' +def status_color(t,c): return {"1xx":t.secondary,"2xx":t.success,"3xx":t.accent,"4xx":t.warning,"5xx":t.danger}.get(c,t.muted) +def traffic(ui,s): + if s.source!="simulated": ui.label("Protocol/direction: port-based estimates; HTTP rate: estimated from log growth") + t=ui.theme;d=s.sample["telemetry"];net=d["net"];rates=net["rates"];http=d["http"] + def top(r): + def protocols(p): + data=d["protocols"] + if not data: p.label("No sockets visible.");return + maximum=max(1,*(x["total"] for x in data)) + p.meters(w.MetersOptions(items=[w.MeterItem(label=b["protocol"],value=b["total"]/maximum,color=series_color(t,i),text=scalar(b["total"])) for i,b in enumerate(data[:9])],label_width=13,value_width=5)) + r.panel(Panel(title="Protocols",subtitle=f'{scalar(d["inboundConnections"])} in / {scalar(d["outboundConnections"])} out',border_color=t.accent),protocols) + def tcp(p): + row(p,1,0,lambda r:(txt(r,'↓ '+rate(rates["inSegs"])+" seg",t.primary),r.text('↑ '+rate(rates["outSegs"])+" seg",w.TextStyle(fg=t.secondary,align="right")))) + multi(p,d["netInHistory"],d["netOutHistory"],t.primary,t.secondary);p.divider() + keys(p,[kv("Established",net["tcpEstablished"],t.success),kv("Opens in/out",rate(rates["passiveOpens"])+" / "+rate(rates["activeOpens"]),t.accent),kv("Resets sent",f'{net["tcpOutRsts"]:,.0f}',t.muted)]) + r.panel(Panel(title="TCP",size="0.9fr",border_color=t.primary),tcp) + color=t.danger if net["retransRatio"]>.02 else t.success + def retrans(p): + p.text(f'{max(0,min(1,net["retransRatio"]))*100:.2f}%',w.TextStyle(fg=color,bold=True));p.label("of outbound segments");plot(p,d["retransHistory"],t.danger) + keys(p,[kv("UDP in/out",rate(rates["udpIn"])+" / "+rate(rates["udpOut"]),t.muted),kv("ICMP",scalar(net["icmpInMsgs"])+" / "+scalar(net["icmpOutMsgs"]),t.muted)]) + r.panel(Panel(title="Retransmits",size="0.7fr",border_color=color),retrans) + row(ui,13,1,top) + def middle(r): + def left(c): + def http_panel(p): + if not http: + p.label("No readable HTTP access log.");p.label("nginx, apache, httpd and caddy logs are");p.label("root/adm readable — run with sudo to track requests.");return + row(p,1,0,lambda r:(txt(r,http["source"],t.muted),r.text(scalar(http["upgrades"])+" upgrades (ws)",w.TextStyle(fg=t.secondary,align="right")))) + p.graph(w.GraphOptions(values=http["history"],plot=PlotOptions(min=0,fill=True,color=t.success)),Layout(size=6));p.divider(w.DividerOptions(label="status")) + maximum=max([1]+[b["count"] for b in http["statusClasses"]]) + p.meters(w.MetersOptions(items=[w.MeterItem(label=b["class"],value=b["count"]/maximum,color=status_color(t,b["class"]),text=scalar(b["count"])) for b in http["statusClasses"]],label_width=5,value_width=7));p.divider(w.DividerOptions(label="top paths")) + data_table(p,s,"traffic.paths",http["topPaths"],[dc("path","Path",minimum=20,color=t.primary),dc("count","Hits",7,color=t.accent,right=True)],header=False) + c.panel(Panel(title="HTTP",subtitle=f'{http["requestsPerSecond"]:.1f} req/s' if http else "no access log",border_color=t.success),http_panel) + r.column(Layout(gap=1),left) + def right(c): + def ssh(p): + if not d["ssh"]: p.label("No sshd events in the journal.");return + data_table(p,s,"traffic.ssh",list(reversed(d["ssh"])),[dc("time","Time",9,color=t.muted),dc("action","Action",11,cell_color=lambda d:t.success if d["action"]=="accepted" else t.muted if d["action"]=="disconnect" else t.danger),dc("user","User",12,color=t.primary),dc("from","From",minimum=14,color=t.accent),dc("method","Method",10,color=t.muted)],True) + c.panel(Panel(title="SSH Activity",subtitle=str(len(d["ssh"])),border_color=t.warning),ssh) + def remotes(p): + if not d["remotes"]: p.label("No remote peers.");return + data_table(p,s,"traffic.remotes",d["remotes"],[dc("host","Host",minimum=16,color=t.accent),dc("connections","Conns",6,color=t.success,right=True),dc("protocols","Protocols",minimum=12,color=t.muted)],True) + c.panel(Panel(title="Top Remote Hosts",size=10,border_color=t.secondary),remotes) + r.column(Layout(size="0.85fr",gap=1),right) + row(ui,"1fr",1,middle) + if http and http["recent"]: + ui.panel(Panel(title="Recent Requests",size=10,border_color=t.primary),lambda p:data_table(p,s,"traffic.requests",http["recent"],[dc("time","Time",9,color=t.muted),dc("method","Method",7,color=t.secondary),dc("path","Path",minimum=24,color=t.primary),dc("status","Status",7,right=True,cell_color=lambda d:status_color(t,str(d["status"])[0]+"xx")),dc("client","Client",16,color=t.accent),dc("bytes","Bytes",9,color=t.muted,right=True)],True)) +def sessions(ui,s): + t=ui.theme;d=s.sample["telemetry"] + def top(r): + def active(p): + if not d["sessions"]: p.label("No interactive sessions.");p.label("(`who` reports nothing on this host)");return + data_table(p,s,"sessions.active",d["sessions"],[dc("user","User",12,color=t.primary),dc("tty","TTY",10),dc("from","From",minimum=12,color=t.accent),dc("loginAt","Login",14,color=t.muted),dc("idle","Idle",8,right=True)]) + r.panel(Panel(title="Active Sessions",subtitle=str(len(d["sessions"])),border_color=t.success),active) + def states(p): + states=d["states"] + for key,label,color in (("running","run ",t.success),("sleeping","slp ",t.primary),("stopped","stop",t.warning),("zombie","zomb",t.danger)): + p.meter(w.MeterOptions(label=label,value=states[key]/max(1,states["total"]),text=scalar(states[key]),heat=False,color=color)) + p.spacer(1);keys(p,[kv("Total",states["total"],t.accent)]) + r.panel(Panel(title="Process States",size=34,border_color=t.primary),states) + row(ui,9,1,top) + def bottom(r): + def logins(p): + if not d["logins"]: p.label("No login history available.");return + data_table(p,s,"sessions.logins",d["logins"],[dc("user","User",12,color=t.primary),dc("tty","TTY",12,color=t.muted),dc("from","From",minimum=14,color=t.accent),dc("when","When",minimum=16,color=t.muted),dc("status","Status",8,cell_color=lambda d:t.success if d["status"]=="still" else t.muted)],True) + r.panel(Panel(title="Recent Logins",subtitle=f'{len(d["logins"])} from wtmp',border_color=t.accent),logins) + def right(c): + def failed(p): + if not d["failedLogins"]: p.label("None recorded.");p.label("(btmp is usually root-only)");return + data_table(p,s,"sessions.failed",d["failedLogins"],[dc("user","User",12,color=t.danger),dc("from","From",minimum=12),dc("when","When",minimum=14,color=t.muted)]) + c.panel(Panel(title="Failed Logins",border_color=t.danger),failed) + c.panel(Panel(title="Session History",size=8,border_color=t.secondary),lambda p:(p.label("concurrent sessions"),plot(p,d["sessionHistory"],t.success))) + r.column(Layout(size="0.8fr",gap=1),right) + row(ui,"1fr",1,bottom) +def network(ui,s): + t=ui.theme;d=s.sample["telemetry"];active=[i for i in d["interfaces"] if i["rxTotal"]>0 or i["state"]=="up"];shown=(active or d["interfaces"])[:3] + def top(r): + if not shown: r.panel(Panel(title="Interfaces"),lambda p:p.label("No interfaces reported."));return + for i,iface in enumerate(shown): + def draw(p,d=iface): + row(p,1,0,lambda r:(txt(r,"↓ "+byte_rate(d["rxRate"]),t.primary),r.text("↑ "+byte_rate(d["txRate"]),w.TextStyle(fg=t.secondary,align="right")))) + multi(p,d["rxHistory"],d["txHistory"],t.primary,t.secondary);p.divider() + keys(p,[kv("RX total",bytes(d["rxTotal"]),t.primary),kv("TX total",bytes(d["txTotal"]),t.secondary),kv("MAC",d["mac"],t.muted),kv("MTU / err / drop"," / ".join(scalar(d[k]) for k in ("mtu","errors","drops")),t.muted)]) + r.panel(Panel(title=f'{iface["name"]} ({iface["state"]})',subtitle=iface["ip"],border_color=(t.primary,t.success,t.secondary)[i]),draw) + row(ui,13,1,top) + def bottom(r): + def connections(p): + if not d["connections"]: p.label("No connections visible (`ss` unavailable).");return + data_table(p,s,"network.connections",d["connections"],[dc("proto","Proto",6,color=t.muted),dc("local","Local",minimum=18),dc("remote","Remote",minimum=18,color=t.accent),dc("state","State",10,color=t.success),dc("process","Process",minimum=12,color=t.primary)],True) + r.panel(Panel(title="Connections",subtitle=f'{len(d["connections"])} open',border_color=t.accent),connections) + def right(c): + c.panel(Panel(title="Listening Ports",subtitle=str(len(d["listeners"])),border_color=t.warning),lambda p:data_table(p,s,"network.listeners",d["listeners"],[dc("proto","Proto",6,color=t.muted),dc("port","Port",7,color=t.warning,right=True),dc("address","Address",minimum=10,color=t.muted),dc("process","Process",minimum=10,color=t.primary)],True)) + c.panel(Panel(title="Open Connections",size=6,border_color=t.secondary),lambda p:plot(p,d["connectionHistory"],t.accent)) + r.column(Layout(size="0.7fr",gap=1),right) + row(ui,"1fr",1,bottom) +def services(ui,s): + t=ui.theme;d=s.sample["telemetry"];failed=sum(x["active"]=="failed" for x in d["services"]) + def top(r): + def units(p): + if not d["services"]: p.label("systemd not available on this host.");return + data_table(p,s,"services.units",d["services"],[dc("name","Unit",minimum=18,color=t.primary),dc("active","Active",10,cell_color=lambda d:t.danger if d["active"]=="failed" else t.success if d["active"]=="active" else t.muted),dc("sub","Sub",10,color=t.muted),dc("description","Description",minimum=16,color=t.muted)],True) + r.panel(Panel(title="Services",subtitle=f'{failed} failed' if failed else f'{len(d["services"])} units',subtitle_color=t.danger if failed else t.muted,border_color=t.danger if failed else t.success),units) + def right(c): + def kernel(p): + k=d["kernel"] + krate=lambda v:f'{v/1000:.1f}K/s' if v>=1000 else f'{v:.0f}/s' + keys(p,[kv("Context switches",krate(k["contextSwitchRate"]),t.accent),kv("Interrupts",krate(k["interruptRate"]),t.accent),kv("Forks",krate(k["forkRate"]),t.accent),kv("Procs running",k["procsRunning"],t.success),kv("Procs blocked",k["procsBlocked"],t.warning if k["procsBlocked"] else t.muted),kv("Open file descriptors",f'{k["openFiles"]:,.0f}',t.primary),kv("Entropy available",k["entropy"],t.warning if k["entropy"]<200 else t.success),kv("Page in / out",f'{k["pageIn"]/1000:.0f}K / {k["pageOut"]/1000:.0f}K',t.muted)]) + c.panel(Panel(title="Kernel",size=11,border_color=t.accent),kernel) + def containers(p): + if not d["containers"]: p.label("No running containers.");p.label("(docker not installed or not reachable)");return + data_table(p,s,"services.containers",d["containers"],[dc("name","Name",minimum=12,color=t.primary),dc("image","Image",minimum=14,color=t.muted),dc("status","Status",minimum=12,color=t.success)],True) + c.panel(Panel(title="Containers",size=9,border_color=t.primary),containers) + def hardware(p): + rows=[];power=d["power"] + if power: + draw=f'{power["powerDraw"]:.1f} W' if power["powerDraw"] is not None else "—" + rows.extend([kv("Battery",f'{scalar(power["battery"])}% ({power["timeRemaining"]})',t.success),kv("AC","connected" if power["acConnected"] else "on battery",t.muted),kv("Draw",draw,t.warning)]) + for g in d["gpus"]: + util=percent(g["utilization"]) if g["utilization"] is not None else "—" + rows.extend([kv(g["name"],f'{util} · {scalar(g["temperature"])}°C',t.accent),kv("GPU memory",bytes(g["memoryUsed"])+" / "+bytes(g["memoryTotal"]),t.muted)]) + if not rows: p.label("No battery or GPU telemetry on this host.");return + keys(p,rows) + c.panel(Panel(title="Hardware",border_color=t.warning),hardware) + r.column(Layout(size="0.85fr",gap=1),right) + row(ui,"1fr",1,top) + def filesystems(p): + if not d["filesystems"]: p.label("No filesystems reported.");return + data_table(p,s,"services.filesystems",d["filesystems"],[dc("mount","Mount",minimum=14,color=t.primary),dc("device","Device",minimum=12,color=t.muted),dc("type","Type",8,color=t.muted),dc("size","Size",10,right=True,fmt=lambda d:bytes(d["size"],0)),dc("used","Used",10,right=True,fmt=lambda d:bytes(d["used"],0)),dc("pct","Use%",6,right=True,fmt=lambda d:percent(d["used"]/d["size"]) if d["size"] else "-",cell_color=lambda d:t.danger if d["size"] and d["used"]/d["size"]>.9 else t.warning),dc("inodes","Inodes",16,color=t.muted,right=True,fmt=lambda d:f'{percent(d["inodesUsed"]/d["inodesTotal"])} of {d["inodesTotal"]/1e6:.1f}M' if d["inodesTotal"] else "-")],True) + ui.panel(Panel(title="Filesystems",size=10,border_color=t.secondary),filesystems) + +def telemetry(ui,s): {"traffic":traffic,"sessions":sessions,"network":network,"services":services}[s.screen](ui,s) diff --git a/ports/python/hqtui_demo/view.py b/ports/python/hqtui_demo/view.py index 2e80cf2..67678b9 100644 --- a/ports/python/hqtui_demo/view.py +++ b/ports/python/hqtui_demo/view.py @@ -1,236 +1,26 @@ -"""The ten-screen reference application, rendered exclusively with Python hqtui.""" -from __future__ import annotations - -import math - +"""Native reference app shell; screens are implemented with Python hqtui.""" +from hqtui import Layout import hqtui.widgets as w -from hqtui import Cell, GridSpec, Layout, Panel, ScrollHandlers -from hqtui.graphics import GaugeOptions, PlotOptions, Series -from hqtui.ui import HitRegion -from hqtui.widgets.table import resolve_offset - -from .model import SCREENS, THEMES, State - - -def size(value: float) -> str: - for unit in ("B", "KiB", "MiB", "GiB", "TiB"): - if abs(value) < 1024 or unit == "TiB": - return f"{value:.1f} {unit}" - value /= 1024 - return "" - - -def display(value) -> str: - if isinstance(value, float): return f"{value:.1f}" - if isinstance(value, (list, dict)): return str(value) - return str(value) - - -def table(ui, state: State, name: str, rows: list, columns: list[tuple[str, str]]) -> None: - """Offset is calculated against the final surface, including mouse mapping.""" - pane = state.pane(name, len(rows)) - def draw(surface): - pane.offset = resolve_offset(pane.offset, pane.selected, max(0, surface.height - 1), len(rows), True) - w.draw_table(surface, w.TableOptions( - rows=[w.TableRow(tuple(display(row.get(key, "—")) for key, _ in columns)) for row in rows], - columns=[w.TableColumn(title) for _, title in columns], - selected=pane.selected if rows else None, offset=pane.offset, zebra=True, scrollbar=True)) - def focus(): state.focused[state.screen] = name - def scroll(delta): focus(); pane.move(delta) - def click(x, y, button): - focus() - if y > 0: pane.selected = min(max(0, len(rows) - 1), pane.offset + y - 1) - ui.ctx.hit(HitRegion(rect=surface.hit_rect(), on_scroll=scroll, on_click=click)) - if not rows: surface.text(0, 2, "No data available") - ui.draw(draw) - - -def graph(ui, values, label="", second=None): - series = [Series(values=values, label=label)] - if second is not None: series.append(Series(values=second, label="out")) - ui.graph(w.GraphOptions(series=series, axis=True, legend=bool(label), plot=PlotOptions(fill=True))) - - -def pairs(ui, values): - for label, value in values: - ui.text(f"{label:<15} {display(value)}") - - -def dashboard(ui, s: State): - data = s.sample - def cpu(p): - c = data["cpu"] - p.label(f'{c["model"]} {c["frequencyGhz"]:.1f} GHz') - graph(p, c["history"], "CPU %") - p.meters(w.MetersOptions(items=[w.MeterItem(label=f"P{i}", value=v) for i, v in enumerate(c["cores"])], columns=2 if p.width > 35 else 1)) - p.label("Load " + " ".join(f"{n:.2f}" for n in c["load"])) - def memory(p): - m = data["memory"] - p.meter(w.MeterOptions(value=m["used"], max=max(1, m["total"]), label="RAM")) - pairs(p, [(name.title(), size(m[name])) for name in ("used", "available", "cached", "buffers", "free")]) - p.meter(w.MeterOptions(value=m["swapUsed"], max=max(1, m["swapTotal"]), label="Swap")) - graph(p, m["history"]) - def disks(p): - for d in data["disks"][:2]: - p.label(d["device"] + " " + d["mount"]) - p.meter(w.MeterOptions(value=d["used"], max=max(1, d["total"]), text=size(d["used"]))) - p.label(f'Read {size(d["readRate"])}/s Write {size(d["writeRate"])}/s') - graph(p, d["readHistory"], "read", d["writeHistory"]) - if not data["disks"]: p.label("No disks reported") - def system(p): - pairs(p, [(k.title(), data["system"][k]) for k in ("hostname", "os", "kernel", "shell", "processCount", "threadCount")]) - pairs(p, [("Uptime", f'{data["system"]["uptime"] / 3600:.1f}h'), ("Source", s.source)]) - graph(p, data["cpu"]["history"], "CPU history") - def procs(p): - p.label(f"F3 filter: {s.filter or 'all'} F6 sort: {s.sort}") - table(p, s, "dashboard.processes", s.processes(), [(k, v) for k,v in (("pid","PID"),("name","Name"),("cpu","CPU%"),("mem","MEM%"),("threads","THR"),("state","S"),("user","User"),("command","Command"))]) - def network(p): - n = data["network"] - p.label(f'Down {size(n["downRate"])}/s Up {size(n["upRate"])}/s') - graph(p, n["downHistory"], "down", n["upHistory"]) - pairs(p, [("Received", size(n["downTotal"])), ("Sent", size(n["upTotal"]))]) - def temperatures(p): - for t in data["temperatures"][:12]: - p.meter(w.MeterOptions(label=t["label"], value=t["value"], max=t.get("max") or 100, text=f'{t["value"]:.0f}°C')) - if not data["temperatures"]: p.label("No thermal sensors available") - def sensors(p): - for sensor in data["sensors"][:14]: p.label(f'{sensor["label"]}: {sensor["value"]}') - if not data["sensors"]: p.label("No sensor readings available") - def logs(p): table(p,s,"dashboard.logs", data["logs"], [("time","Time"),("level","Level"),("message","Message")]) - panels = [("CPU Overview",cpu),("Memory & Swap",memory),("Disks",disks),("System",system),("Processes",procs),("Network",network),("Temperatures",temperatures),("Sensors",sensors),("Logs",logs)] - # Preserve a useful compact dashboard rather than squeezing eight panels - # into a terminal too small to show their contents. - if ui.width < 90 or ui.height < 46: panels = [panels[0],panels[4],panels[1],panels[5]] - grid(ui, panels, 3 if ui.width >= 160 and len(panels)>4 else 2) - - -def grid(ui, panels, columns=2): - if ui.width < 45: columns=1 - def build(g): - for title, draw in panels: g.panel(Panel(title=title), Cell(), draw) - ui.grid(GridSpec(columns=["1fr"]*columns, rows=["1fr"]*math.ceil(len(panels)/columns), gap=1),build) - - -def telemetry(ui, s: State): - t=s.sample["telemetry"] - def tab(title, key, columns): - return (title, lambda p: table(p,s,s.screen+"."+key,t[key],columns)) - def trace(title, key): return (title,lambda p: graph(p,t[key],title)) - if s.screen=="sessions": - panels=[tab("Active Sessions","sessions",[("user","User"),("tty","TTY"),("from","From"),("idle","Idle"),("what","Command")]), - tab("Login History","logins",[("user","User"),("tty","TTY"),("from","From"),("when","When"),("status","Status")]), - tab("SSH Authentication","ssh",[("time","Time"),("action","Action"),("user","User"),("from","From")]),trace("Session History","sessionHistory")] - elif s.screen=="network": - panels=[tab("Interfaces","interfaces",[("name","Name"),("state","State"),("rxRate","RX B/s"),("txRate","TX B/s"),("errors","Errors"),("drops","Drops")]), - tab("Connections","connections",[("proto","Proto"),("state","State"),("local","Local"),("remote","Remote"),("process","Process")]), - tab("Listeners","listeners",[("proto","Proto"),("address","Address"),("port","Port"),("process","Process")]),trace("Connection History","connectionHistory")] - elif s.screen=="services": - panels=[tab("Services","services",[("name","Unit"),("active","State"),("description","Description")]), - tab("Filesystems","filesystems",[("mount","Mount"),("device","Device"),("type","Type"),("used","Used B"),("size","Size B")]), - ("Kernel",lambda p:pairs(p,[(k,v) for k,v in t["kernel"].items()][:15])), - tab("Journal","journal",[("time","Time"),("level","Level"),("unit","Unit"),("message","Message")])] - else: - def http(p): - h=t["http"] - if not h: p.label("HTTP access log unavailable (read-only)"); return - p.label(f'{h["requestsPerSecond"]:.1f} req/s {h["total"]} total') - table(p,s,"traffic.http",h["recent"],[("time","Time"),("method","Method"),("path","Path"),("status","Status"),("client","Client")]) - panels=[tab("Protocols","protocols",[("protocol","Protocol"),("inbound","In"),("outbound","Out"),("total","Total")]), - ("TCP Segments",lambda p:graph(p,t["netInHistory"],"in",t["netOutHistory"])),trace("Retransmits","retransHistory"), - ("HTTP",http),tab("Remote Hosts","remotes",[("host","Host"),("connections","Connections"),("protocols","Protocols")]), - tab("SSH Authentication","ssh",[("time","Time"),("action","Action"),("user","User"),("from","From")])] - if s.screen=="sessions": panels.append(tab("Failed Logins","failedLogins",[("user","User"),("tty","TTY"),("from","From"),("when","When"),("status","Status")])) - if s.screen=="traffic" and s.source=="real": ui.label("Protocol/direction: port-based estimates; HTTP rate: estimated from log growth") - grid(ui,panels,3 if s.screen=="traffic" and ui.width>=120 else 2) - - -def components(ui,s): - def controls(p): - p.label("Click controls; e edits text; Esc finishes editing") - for variant in ("primary","success","warning","danger","ghost"): - p.button(w.ButtonOptions(label=variant.title(),variant=variant),lambda:setattr(s,"modal",True)) - p.checkbox(w.CheckboxOptions(label="Notifications",checked=s.checkbox),lambda:setattr(s,"checkbox",not s.checkbox)) - p.checkbox(w.CheckboxOptions(label="Live updates",checked=s.toggle,variant="toggle"),lambda:setattr(s,"toggle",not s.toggle)) - p.select(w.SelectOptions(value=THEMES[s.select_index],options=THEMES,selected_index=s.select_index,open=s.select_open),lambda:setattr(s,"select_open",not s.select_open)) - p.text_input(w.TextInputOptions(value=s.input_value,placeholder="Type here",focused=s.editing)) - def meters(p): - for i in range(8): p.meter(w.MeterOptions(value=(i+1)/8,label=f"Meter {i+1}",style="segmented" if i%2 else "smooth")) - p.gauge(GaugeOptions(value=s.sample["cpu"]["total"],label="CPU")) - def text(p): - p.heading("Typography & Unicode") - p.text("Readable, clipped and dependency-free") - p.label("日本語 中文 한국어 • café • 🚀") - for label in ("READY","WARNING","LIVE"): p.badge(w.BadgeOptions(text=label)) - p.divider() - p.button(w.ButtonOptions(label="Open command palette"),lambda:setattr(s,"palette",True)) - graph(p,s.sample["cpu"]["history"],"History") - grid(ui,[("Controls",controls),("Meters & Gauge",meters),("Text & Badges",text)],3) - - -def graphics(ui,s): - def canvas(c,surface): - for x in range(c.width): - y=int((.5+.35*math.sin(x/12+s.sample["time"]))*max(0,c.height-1)) - c.pixel(x,y) - grid(ui,[("Braille Canvas",lambda p:p.canvas(canvas)), - ("CPU Plot",lambda p:graph(p,s.sample["cpu"]["history"],"CPU %")), - ("Network Plot",lambda p:graph(p,s.sample["network"]["downHistory"],"down",s.sample["network"]["upHistory"])), - ("Gauge",lambda p:p.gauge(GaugeOptions(value=s.sample["cpu"]["total"],label="CPU")))]) - - -def themes(ui,s): - def picker(p): - p.label("F2 or Left/Right to switch themes") - for i,name in enumerate(THEMES): - p.button(w.ButtonOptions(label=("● " if i==s.theme_index else " ")+name),lambda index=i:setattr(s,"theme_index",index)) - def preview(p): - p.heading(THEMES[s.theme_index]) - for name in ("primary","secondary","accent","success","warning","danger","muted"): - p.meter(w.MeterOptions(value=.7,label=name,color=getattr(p.theme,name))) - graph(p,s.sample["cpu"]["history"],"Preview") - grid(ui,[("Built-in Themes",picker),("Live Preview",preview)]) - - -def input_screen(ui,s): - def events(p): - p.text("Last key: "+s.last_key) - p.text("Mouse: "+s.last_mouse) - p.label("e to edit; Esc exits text input") - p.text_input(w.TextInputOptions(value=s.input_value,focused=s.editing,placeholder="UTF-8 + paste")) - table(p,s,"input.events",[{"event":v} for v in reversed(s.key_log)],[("event","Key Events")]) - def diagnostics(p): - pairs(p,[("Renderer","native Python"),("Source",s.source),("FPS",s.fps),("Render ms",s.render_ms),("Changed cells",s.changed_cells),("Output bytes",s.output_bytes)]) - p.label("Arrows scroll only the focused pane") - p.label("Ctrl+K palette · F1 help · q quit") - grid(ui,[("Input Inspector",events),("Diagnostics",diagnostics)]) - - -def stress(ui,s): - panels=[] - for i in range(12): - def draw(p,index=i): - p.meter(w.MeterOptions(value=(math.sin(s.sample["time"]+index)+1)/2,label=f"Load {index+1}")) - graph(p,s.sample["cpu"]["history"]) - panels.append((f"Stress {i+1:02d}",draw)) - grid(ui,panels,4 if ui.width>=120 else 3) - - -def render(ui,s: State): - ui.row(Layout(size=1),lambda r:(r.heading("hqtui — python"),r.spacer(),r.label(f'{s.source.upper()} {"PAUSED" if s.paused else "LIVE"}'))) - # Split the tab bar at a narrow width so every screen remains reachable. - groups=[SCREENS] if ui.width>=150 else [SCREENS[:5],SCREENS[5:]] - for group in groups: - start=SCREENS.index(group[0]) - ui.tabs(w.TabsOptions(tabs=[f'{(start+i+1)%10} {name}' for i,name in enumerate(group)],active=SCREENS.index(s.screen)-start),lambda i,base=start:setattr(s,"screen",SCREENS[base+i])) - if ui.width<30 or ui.height<12: - ui.text("Terminal too small; resize to 30×12 or larger") - ui.spacer() - else: - fn={"dashboard":dashboard,"components":components,"graphics":graphics,"themes":themes,"input":input_screen,"stress":stress}.get(s.screen,telemetry) - ui.column(Layout(size="fill"),lambda body:fn(body,s)) - if s.unavailable: ui.label("Unavailable: "+", ".join(s.unavailable)) - ui.status_bar(w.StatusBarOptions(items=[w.StatusItem("help","F1"),w.StatusItem("theme","F2"),w.StatusItem("filter","F3"),w.StatusItem("palette","^K"),w.StatusItem("quit","q")],right=[w.StatusItem(f'{s.screen} · {THEMES[s.theme_index]}')])) - if s.filtering: ui.modal(w.ModalOptions(title="Filter Processes",message=s.filter+"▏\nEnter applies · Esc clears",width=60)) - if s.help: ui.modal(w.ModalOptions(title="hqtui — Help",width=66,message="1–9/0 / Tab: screen\nF2: theme F3: filter F6: sort\nCtrl+K: command palette Space: pause\nArrows / PgUp / PgDn / Home / End: scroll\nMouse: tabs, controls, selection and wheel\ne: edit text in Components/Input\nq / Ctrl+C: quit Any key: close help")) - if s.modal: ui.modal(w.ModalOptions(title="Read-only Demo",message="No process will be killed and no service changed.\nPress any key to close.",width=58)) - if s.palette: ui.command_palette(w.CommandPaletteOptions(query=s.palette_query,items=[w.PaletteItem(label,hint) for label,hint in s.commands()],selected=s.palette_index,placeholder="Search commands")) +from .model import SCREENS, THEMES +from .dashboard import dashboard +from .telemetry_view import telemetry +from .showcase import components, graphics, themes, input_screen, stress + +def body(ui,s): + draw={"dashboard":dashboard,"components":components,"graphics":graphics,"themes":themes,"input":input_screen,"stress":stress}.get(s.screen,telemetry) + draw(ui,s) + +def render(ui,s): + t=ui.theme + def header(r): + r.text(" hqtui.com",w.TextStyle(fg=t.title,bold=True),Layout(size=12)) + def select(i): + if not (s.help or s.modal or s.palette or s.filtering): s.screen=SCREENS[i] + r.tabs(w.TabsOptions(tabs=[f'{(i+1)%10} {name}' for i,name in enumerate(SCREENS)],active=SCREENS.index(s.screen)),select) + r.text(f'{"paused" if s.paused else "live"} {"simulated" if s.source=="simulated" else "real"} {s.fps:.0f}fps {s.clock} ',w.TextStyle(fg=t.warning if s.paused else t.success,align="right")) + ui.row(Layout(size=1),header);ui.spacer(1) + ui.column(Layout(size=max(0,ui.height-4)),lambda p:body(p,s));ui.spacer(1) + ui.status_bar(w.StatusBarOptions(items=[w.StatusItem("Help","F1"),w.StatusItem(f"Theme ({t.name})","F2"),w.StatusItem("Filter: "+s.filter+"_" if s.filtering else "Filter","F3",active=s.filtering),w.StatusItem("Sort: "+s.sort,"F6"),w.StatusItem("Palette","^K"),w.StatusItem("Screen","Tab"),w.StatusItem("Quit","q")],right=[w.StatusItem(f'{s.render_ms:.2f}ms {s.changed_cells} cells {s.output_bytes}B')])) + if s.help: ui.modal(w.ModalOptions(title="hqtui — Help",message="1–9/0 / Tab: screen\nF2 theme · F3 filter · F6 sort\nCtrl+K palette · Space pause\nArrows / PgUp / PgDn / Home / End: scroll\nMouse tabs, controls, selection and wheel\ne edits text · Esc finishes\nq / Ctrl+C quit · Any key closes help")) + if s.modal: ui.modal(w.ModalOptions(title="Read-only Demo",message="No process will be killed and no service changed.\nPress any key to close.")) + if s.palette: ui.command_palette(w.CommandPaletteOptions(query=s.palette_query,items=[w.PaletteItem(label) for label,hint in s.commands()],selected=s.palette_index)) diff --git a/ports/python/pyproject.toml b/ports/python/pyproject.toml index a11d063..0c25c98 100644 --- a/ports/python/pyproject.toml +++ b/ports/python/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "hqtui" -version = "0.1.11" +version = "0.1.12" description = "High Quality Terminal UI. btop-grade dashboards with a one-import API, dark by default, zero dependencies." readme = "README.md" license = { text = "MIT" } diff --git a/ports/python/tests/test_demo.py b/ports/python/tests/test_demo.py index 4acfa58..ace0bec 100644 --- a/ports/python/tests/test_demo.py +++ b/ports/python/tests/test_demo.py @@ -29,7 +29,8 @@ def test_all_screens_and_themes(self): state.screen=screen; state.theme_index=i frame=self.snapshot(state) self.assertTrue(frame.contains("hqtui")) - self.assertTrue(frame.contains(screen)) + title=dict(zip(SCREENS,("CPU Overview","Protocols","Active Sessions","Connections","Filesystems","Buttons & Inputs","Braille (2×4","Theme ","Last Events","Full-screen churn")))[screen] + self.assertTrue(frame.contains(title)) self.assertEqual(len(frame.text().splitlines()),40) def test_small_and_large_screens(self): @@ -75,21 +76,32 @@ def test_dropdown(self): self.assertEqual(state.theme_index,1); self.assertFalse(state.select_open) def test_panes_are_independent_and_click_uses_offset(self): - state=self.state(); frame=self.snapshot(state,160,50) + state=self.state(); frame=self.snapshot(state,120,40) process=state.panes["dashboard.processes"]; logs=state.panes["dashboard.logs"] state.focused[state.screen]="dashboard.processes"; state.key("end") - frame=self.snapshot(state,160,50) + frame=self.snapshot(state,120,40) self.assertGreater(process.offset,0); self.assertEqual(logs.selected,0) regions=[r for r in frame.regions if r.on_scroll] proc_region=regions[0]; proc_region.on_click(0,1,"left") self.assertEqual(process.selected,process.offset) - regions[1].on_scroll(3); self.assertEqual(logs.selected,3) + regions[1].on_scroll(-3); self.assertEqual(logs.offset,3);self.assertEqual(logs.selected,0) def test_controls_click(self): state=self.state("components"); frame=self.snapshot(state) - region=next(r for r in frame.regions if r.on_click and r.rect.y>4) + position=frame.find("Primary");self.assertIsNotNone(position) + x,y=position + region=next(r for r in reversed(frame.regions) if r.on_click and r.rect.x<=x= 150) { + try c.row(p, .{ .cells = if (p.height() >= 44) 19 else 16 }, 1, wideTop); + try c.row(p, .{ .fr = 1 }, 1, wideMiddle); + try c.row(p, .{ .cells = 12 }, 1, wideBottom); + } else if (p.width() >= 100) { + try c.row(p, .{ .cells = 14 }, 1, mediumTop); + try c.row(p, .{ .fr = 1 }, 1, mediumMiddle); + try c.row(p, .{ .cells = 10 }, 1, mediumBottom); + } else { + try c.row(p, .{ .cells = 10 }, 1, compactTop); + try c.col(p, .fill, 0, processes); + try c.row(p, .{ .cells = 8 }, 1, network); + } +} +fn wideTop(c: *C, p: *P) !void { + try c.col(p, .{ .fr = 1 }, 0, cpu); + try c.col(p, .{ .fr = 0.95 }, 0, memory); + try c.col(p, .{ .fr = 0.95 }, 0, disks); + try c.col(p, .{ .fr = 1.35 }, 0, system); +} +fn wideMiddle(c: *C, p: *P) !void { + try c.col(p, .{ .fr = 2 }, 0, processes); + try c.col(p, .{ .fr = 1.2 }, 0, network); + try c.col(p, .{ .fr = 1.2 }, 0, diskUsage); +} +fn wideBottom(c: *C, p: *P) !void { + try temperatures(c, p); + try sensors(c, p); + try c.col(p, .{ .fr = 1.6 }, 0, logs); +} +fn mediumTop(c: *C, p: *P) !void { + try cpu(c, p); + try memory(c, p); + try system(c, p); +} +fn mediumMiddle(c: *C, p: *P) !void { + try c.col(p, .{ .fr = 1.6 }, 0, processes); + try c.col(p, .fill, 0, network); +} +fn mediumBottom(c: *C, p: *P) !void { + try temperatures(c, p); + try logs(c, p); +} +fn compactTop(c: *C, p: *P) !void { + const compact = try c.sub(p, c.v, 1); + try cpu(compact, p); + try memory(c, p); +} +fn cpu(c: *C, p: *P) !void { + try c.panel(p, .{ .title = "CPU Overview", .subtitle = try r.pct(p, c.n("cpu.total")) }, cpuBody); +} +fn cpuBody(c: *C, p: *P) !void { + const t = p.theme().*; + try p.label(try p.fmt("{s} {d:.1} GHz", .{ c.str("cpu.model"), c.n("cpu.frequencyGhz") })); + try r.plot(p, c.at("cpu.history"), t.success, 100, false); + const data = m.arr(c.at("cpu.cores")); + const items = try p.ctx.allocator.alloc(h.widgets.MeterItem, data.len); + for (data, 0..) |v, i| items[i] = .{ .label = try p.fmt("P{d}", .{i}), .value = m.num(v) }; + try p.meters(.{ .items = items, .columns = if (c.index == 1) 1 else 2, .label_width = 4, .value_width = 5, .style = .segmented }); + try p.divider(.{}); + const load = m.arr(c.at("cpu.load")); + try r.keys(p, &.{r.kv("Load Avg", try p.fmt("{d:.2} {d:.2} {d:.2}", .{ if (load.len > 0) m.num(load[0]) else 0, if (load.len > 1) m.num(load[1]) else 0, if (load.len > 2) m.num(load[2]) else 0 }), t.warning)}, true); +} +fn memory(c: *C, p: *P) !void { + try c.panel(p, .{ .title = "Memory & Swap" }, memoryBody); +} +fn memoryBody(c: *C, p: *P) !void { + const t = p.theme().*; + const used = c.n("memory.used") / @max(1, c.n("memory.total")); + const swap = c.n("memory.swapUsed") / @max(1, c.n("memory.swapTotal")); + try r.tx(p, try p.fmt("Memory {s} / {s} ({s})", .{ try r.bytes(p, c.n("memory.used"), 2), try r.bytes(p, c.n("memory.total"), 2), try r.pct(p, used) }), t.foreground); + try r.meter(p, used, null); + try p.spacer(.{ .cells = 1 }); + try r.keys(p, &.{ r.kv("Used:", try r.bytes(p, c.n("memory.used"), 2), t.warning), r.kv("Available:", try r.bytes(p, c.n("memory.available"), 2), t.success), r.kv("Cached:", try r.bytes(p, c.n("memory.cached"), 2), t.accent), r.kv("Buffers:", try r.bytes(p, c.n("memory.buffers"), 2), t.secondary), r.kv("Free:", try r.bytes(p, c.n("memory.free"), 2), t.muted) }, true); + try p.spacer(.fill); + try p.divider(.{}); + try r.tx(p, try p.fmt("Swap {s} / {s} ({s})", .{ try r.bytes(p, c.n("memory.swapUsed"), 2), try r.bytes(p, c.n("memory.swapTotal"), 2), try r.pct(p, swap) }), t.foreground); + try r.meter(p, swap, t.secondary); + try r.keys(p, &.{ r.kv("Used:", try r.bytes(p, c.n("memory.swapUsed"), 2), t.secondary), r.kv("Free:", try r.bytes(p, c.n("memory.swapTotal") - c.n("memory.swapUsed"), 2), t.muted) }, true); +} +fn disks(c: *C, p: *P) !void { + try c.panel(p, .{ .title = "Disks" }, disksBody); +} +fn diskRates(c: *C, p: *P) !void { + const t = p.theme().*; + try r.tx(p, try p.fmt("Read: {s}", .{try r.byteRate(p, c.n("readRate"))}), t.success); + try p.text(try p.fmt("Write: {s}", .{try r.byteRate(p, c.n("writeRate"))}), .{ .fg = t.secondary, .alignment = .right }); +} +fn disksBody(c: *C, p: *P) !void { + const t = p.theme().*; + const data = m.arr(c.at("disks")); + if (data.len == 0) { + try p.label("No disks reported"); + return; + } + for (data[0..@min(2, data.len)], 0..) |v, i| { + const d = try c.sub(p, v, 0); + const used = d.n("used") / @max(1, d.n("total")); + try r.tx(p, try p.fmt("{s} — {s} ({s})", .{ d.str("device"), try r.bytes(p, d.n("total"), 2), d.str("type") }), t.foreground); + try r.tx(p, try p.fmt("Used: {s} ({s})", .{ try r.bytes(p, d.n("used"), 2), try r.pct(p, used) }), t.muted); + try r.meter(p, used, null); + try r.tx(p, try p.fmt("Free: {s}", .{try r.bytes(p, d.n("total") - d.n("used"), 2)}), t.muted); + try d.row(p, .{ .cells = 1 }, 0, diskRates); + try r.multi(p, d.at("readHistory"), d.at("writeHistory"), t.success, t.secondary); + if (i == 0 and data.len > 1) try p.divider(.{}); + } +} +fn system(c: *C, p: *P) !void { + try c.panel(p, .{ .title = "System" }, systemBody); +} +fn processCount(c: *C) f64 { + return if (c.n("system.processCount") != 0) c.n("system.processCount") else @floatFromInt(m.arr(c.at("processes")).len); +} +fn systemSummary(c: *C, p: *P) !void { + const t = p.theme().*; + try r.keys(p, &.{ r.kv("OS:", c.str("system.os"), null), r.kv("Kernel:", c.str("system.kernel"), null), r.kv("Uptime:", try r.duration(p, c.n("system.uptime")), null), r.kv("Hostname:", c.str("system.hostname"), null), r.kv("Shell:", c.str("system.shell"), null), r.kv("Source:", if (c.s.real) "linux/proc" else "simulated", t.accent) }, false); + const load = m.arr(c.at("cpu.load")); + try r.keys(p, &.{ r.kv("CPU:", try r.pct(p, c.n("cpu.total")), h.theme.heatColor(t, c.n("cpu.total"))), r.kv("Memory:", try p.fmt("{s} ({s})", .{ try r.pct(p, c.n("memory.used") / @max(1, c.n("memory.total"))), try r.bytes(p, c.n("memory.used"), 2) }), t.warning), r.kv("Swap:", if (c.n("memory.swapTotal") > 0) try r.pct(p, c.n("memory.swapUsed") / c.n("memory.swapTotal")) else "—", t.secondary), r.kv("Load:", try p.fmt("{d:.2} {d:.2} {d:.2}", .{ if (load.len > 0) m.num(load[0]) else 0, if (load.len > 1) m.num(load[1]) else 0, if (load.len > 2) m.num(load[2]) else 0 }), null), r.kv("Processes:", try p.fmt("{d:.0}", .{processCount(c)}), null), r.kv("Threads:", try r.scalar(p, c.at("system.threadCount")), null) }, false); +} +fn cpuHistory(c: *C, p: *P) !void { + try r.plot(p, c.at("cpu.history"), p.theme().success, 100, true); +} +fn quickStats(c: *C, p: *P) !void { + const t = p.theme().*; + try r.keys(p, &.{ r.kv("Uptime", try r.duration(p, c.n("system.uptime")), t.accent), r.kv("Procs", try p.fmt("{d:.0}", .{processCount(c)}), t.accent), r.kv("Threads", if (c.n("system.threadCount") > 0) try r.scalar(p, c.at("system.threadCount")) else "—", t.accent), r.kv("Ctx/s", try p.fmt("{d:.1}K", .{c.n("telemetry.kernel.contextSwitchRate") / 1000}), t.accent) }, true); +} +fn memoryGraph(c: *C, p: *P) !void { + try r.tx(p, try r.pct(p, c.n("memory.used") / @max(1, c.n("memory.total"))), p.theme().warning); + try r.plot(p, c.at("memory.history"), p.theme().primary, 100, false); +} +fn tempGauge(c: *C, p: *P) !void { + const temps = m.arr(c.at("temperatures")); + var v = c.n("cpu.total"); + var label = try r.pct(p, v); + if (temps.len > 0) { + const temp = temps[0]; + const maximum = m.num(m.get(temp, "max")); + v = @min(1, m.num(m.get(temp, "value")) / (if (maximum == 0) @as(f64, 100) else maximum)); + label = try p.fmt("{d:.0}°C", .{@floor(m.num(m.get(temp, "value")) + 0.5)}); + } + try p.gauge(.{ .value = v, .label = label }); +} +fn systemStats(c: *C, p: *P) !void { + try c.panel(p, .{ .title = "Quick Stats" }, quickStats); + try c.panel(p, .{ .title = "Memory" }, memoryGraph); + try c.panel(p, .{ .title = "Temp", .layout = .{ .size = .{ .cells = 14 } } }, tempGauge); +} +fn systemBody(c: *C, p: *P) !void { + const t = p.theme().*; + try p.row(.{ .layout = .{ .size = .{ .cells = 6 }, .min = 6, .gap = 2 } }, h.Body.with(c, systemSummary)); + try c.panel(p, .{ .title = "CPU History", .layout = .{ .min = 5 } }, cpuHistory); + if (p.width() >= 46 and p.height() >= 16) { + try c.row(p, .{ .cells = 6 }, 1, systemStats); + } else { + try p.divider(.{}); + try r.keys(p, &.{ r.kv("Threads", try r.scalar(p, c.at("system.threadCount")), t.accent), r.kv("Ctx switches", try p.fmt("{d:.1}K", .{c.n("system.contextSwitches") / 1000}), t.accent) }, true); + } +} +fn processes(c: *C, p: *P) !void { + try c.panel(p, .{ .title = try p.fmt("Processes (sorted by {s})", .{([_][]const u8{ "CPU", "MEM", "PID", "NAME" })[c.s.sort]}), .subtitle = if (c.s.filter.len > 0) try p.fmt("filter: {s}", .{c.s.filter.slice()}) else "", .focus_id = "dashboard.processes" }, processesBody); +} +fn processesBody(c: *C, p: *P) !void { + const t = p.theme().*; + const data = try @import("view.zig").sortedProcesses(c.s, p.ctx.allocator); + var cols = [_]r.DC{ r.dc("pid", "PID", 7, 0, null, true), r.dc("name", "Name", 0, 8, t.primary, false), r.dc("cpu", "CPU%", 6, 0, null, true), r.dc("mem", "MEM%", 6, 0, t.warning, true), r.dc("rss", "RSS", 9, 0, null, true), r.dc("threads", "Threads", 7, 0, null, true), r.dc("state", "S", 2, 0, null, false), r.dc("user", "User", 10, 0, t.muted, false), r.dc("command", "Command", 0, 10, t.muted, false) }; + cols[2].format = .one; + cols[2].tint = .cpu; + cols[3].format = .one; + cols[4].format = .bytes0; + cols[6].tint = .state; + try r.table(c, p, 1, data, &cols, false, true, true); +} +fn network(c: *C, p: *P) !void { + try c.panel(p, .{ .title = "Network" }, networkBody); +} +fn networkRates(c: *C, p: *P) !void { + const t = p.theme().*; + try r.tx(p, try p.fmt("Download: {s}", .{try r.bitRate(p, c.n("network.downRate"))}), t.primary); + try p.text(try p.fmt("Upload: {s}", .{try r.bitRate(p, c.n("network.upRate"))}), .{ .fg = t.secondary, .alignment = .right }); +} +fn axisBits(buf: []u8, v: f64) []const u8 { + const b = @max(0, v) * 8; + for ([_]f64{ 1e9, 1e6, 1e3 }, [_][]const u8{ "Gb/s", "Mb/s", "Kb/s" }) |scale, unit| { + if (b >= scale) return std.fmt.bufPrint(buf, "{d:.1}{s}", .{ b / scale, unit }) catch ""; + } + return std.fmt.bufPrint(buf, "{d:.0}b/s", .{b}) catch ""; +} +fn networkTotals(c: *C, p: *P) !void { + const t = p.theme().*; + inline for (.{ "down", "up" }, 0..) |prefix, i| { + const color = if (i == 0) t.primary else t.secondary; + try r.keys(p, &.{ r.kv("Total:", try r.bytes(p, c.n("network." ++ prefix ++ "Total"), 2), color), r.kv("Current:", try r.bitRate(p, c.n("network." ++ prefix ++ "Rate")), color), r.kv("Peak:", try r.bitRate(p, c.n("network." ++ prefix ++ "Peak")), color) }, false); + } +} +fn networkBody(c: *C, p: *P) !void { + const t = p.theme().*; + try c.row(p, .{ .cells = 1 }, 0, networkRates); + inline for (.{ "downHistory", "upHistory" }, 0..) |key, i| try p.graph(.{ .values = try m.numbers(p.ctx.allocator, c.at("network." ++ key)), .axis = true, .axis_format = axisBits, .plot = .{ .min = 0, .fill = true, .color = if (i == 0) t.primary else t.secondary } }); + try p.divider(.{}); + try c.row(p, .{ .cells = 3 }, 2, networkTotals); +} +fn diskUsage(c: *C, p: *P) !void { + const disks_ = m.arr(c.at("disks")); + try c.panel(p, .{ .title = "Disk Usage", .subtitle = if (disks_.len > 0) m.string(m.get(disks_[0], "device")) else "" }, diskUsageBody); +} +fn ioGraph(c: *C, p: *P) !void { + const read = c.index == 0; + const color = if (read) p.theme().success else p.theme().secondary; + try r.tx(p, try p.fmt("{s}: {s}", .{ if (read) "Read" else "Write", try r.byteRate(p, c.n(if (read) "readRate" else "writeRate")) }), color); + try r.plot(p, c.at(if (read) "readHistory" else "writeHistory"), color, null, false); +} +fn ioColumns(c: *C, p: *P) !void { + const read = try c.sub(p, c.v, 0); + const write = try c.sub(p, c.v, 1); + try read.col(p, .fill, 0, ioGraph); + try write.col(p, .fill, 0, ioGraph); +} +fn ioBody(c: *C, p: *P) !void { + try c.row(p, .fill, 2, ioColumns); +} +fn diskUsageBody(c: *C, p: *P) !void { + const data = m.arr(c.at("disks")); + for (data) |v| { + const used = m.num(m.get(v, "used")) / @max(1, m.num(m.get(v, "total"))); + try p.meter(.{ .value = used, .text = try p.fmt("{s} {s} / {s}", .{ try r.pct(p, used), try r.bytes(p, m.num(m.get(v, "used")), 0), try r.bytes(p, m.num(m.get(v, "total")), 0) }), .style = .segmented }); + try p.label(try p.fmt("{s} ({s})", .{ m.string(m.get(v, "mount")), m.string(m.get(v, "device")) })); + } + try p.spacer(.{ .cells = 1 }); + const first = try c.sub(p, if (data.len > 0) data[0] else .null, 0); + try first.panel(p, .{ .title = "I/O Summary" }, ioBody); +} +fn temperatures(c: *C, p: *P) !void { + try c.panel(p, .{ .title = "Temperatures" }, temperaturesBody); +} +fn tempRow(c: *C, p: *P) !void { + const t = p.theme().*; + const maximum = c.n("max"); + const v = @min(1, c.n("value") / (if (maximum == 0) @as(f64, 100) else maximum)); + try r.sizedText(p, c.str("label"), .{ .fg = t.muted }, 16); + try p.heatBar(.{ .value = v }); + try r.sizedText(p, try p.fmt("{d:.0}°C", .{@floor(c.n("value") + 0.5)}), .{ .fg = h.theme.heatColor(t, v), .alignment = .right }, 6); +} +fn temperaturesBody(c: *C, p: *P) !void { + const data = m.arr(c.at("temperatures")); + if (data.len == 0) { + try p.label("No thermal sensors on this host."); + try p.spacer(.{ .cells = 1 }); + try p.label("Run with --sim to see this panel populated."); + return; + } + for (data[0..@min(10, data.len)]) |v| { + const d = try c.sub(p, v, 0); + try d.row(p, .{ .cells = 1 }, 0, tempRow); + } +} +fn sensors(c: *C, p: *P) !void { + try c.panel(p, .{ .title = "Sensors" }, sensorsBody); +} +fn sensorsBody(c: *C, p: *P) !void { + const data = m.arr(c.at("sensors")); + if (data.len == 0) { + try p.label("No hardware sensors on this host."); + try p.spacer(.{ .cells = 1 }); + try p.label("Probed: /sys/class/hwmon, thermal zones, lm-sensors,"); + try p.label("power supplies and nvidia-smi."); + return; + } + const rows = try p.ctx.allocator.alloc(h.widgets.KeyValueRow, data.len); + for (data, 0..) |v, i| rows[i] = r.kv(m.string(m.get(v, "label")), m.string(m.get(v, "value")), p.theme().accent); + try r.keys(p, rows, true); +} +fn logs(c: *C, p: *P) !void { + try c.panel(p, .{ .title = "Logs" }, logsBody); +} +fn logsBody(c: *C, p: *P) !void { + try r.log(c, p, 7); +} diff --git a/ports/zig/demo/main.zig b/ports/zig/demo/main.zig index 47647bf..e695bf3 100644 --- a/ports/zig/demo/main.zig +++ b/ports/zig/demo/main.zig @@ -68,9 +68,7 @@ fn interact(app: *h.App, s: *m.State) void { if (app.pressed("toggle")) s.toggle = !s.toggle; if (app.pressed("select")) s.select_open = !s.select_open; if (app.pressed("palette")) s.palette = true; - for ([_][]const u8{ "Primary", "Success", "Warning", "Danger", "Ghost" }) |name| { - if (app.pressed(std.fmt.bufPrint(&buf, "button:{s}", .{name}) catch continue)) s.modal = true; - } + if (app.pressed("button:Primary")) s.modal = true; for (&s.panes, 0..) |*p, i| { const id = std.fmt.bufPrint(&buf, "pane:{d}", .{i}) catch continue; const delta = app.scrolled(id); @@ -115,7 +113,7 @@ pub fn main(init: std.process.Init) !void { return; } if (o.version) { - try stdout.writeStreamingAll(init.io, "0.1.11\n"); + try stdout.writeStreamingAll(init.io, "0.1.12\n"); return; } if (!o.snapshot and (!(try std.Io.File.stdin().isTty(init.io)) or !(try stdout.isTty(init.io)))) { @@ -147,6 +145,7 @@ pub fn main(init: std.process.Init) !void { var app = try h.App.init(init.gpa, .{ .terminal = .{ .env = .fromEnviron(&init.minimal.environ) }, .theme = m.themes[state.theme], .fps = o.fps, .quit_keys = &.{}, .focus_navigation = false }); defer app.deinit(); var next: f64 = 0; + var last_frame = now(init.io); while (app.running()) { for (try app.poll()) |event| switch (event) { .key => |key| { @@ -190,7 +189,15 @@ pub fn main(init: std.process.Init) !void { } } if (!m.eq(app.theme.name, m.themes[state.theme])) app.setTheme(m.themes[state.theme]); - _ = try app.draw(h.Body.with(&state, view.render)); + const current = now(init.io); + state.fps = 1 / @max(0.001, current - last_frame); + last_frame = current; + const seconds: u64 = @intCast(@divTrunc(std.Io.Timestamp.now(init.io, .real).nanoseconds, 1_000_000_000)); + _ = try std.fmt.bufPrint(&state.clock, "{d:0>2}:{d:0>2}:{d:0>2}", .{ seconds / 3600 % 24, seconds / 60 % 60, seconds % 60 }); + const stats = try app.draw(h.Body.with(&state, view.render)); + state.render_ms = @as(f64, @floatFromInt(stats.render_ns)) / 1e6; + state.changed_cells = stats.changed_cells; + state.output_bytes = stats.bytes; } app.restore(); } diff --git a/ports/zig/demo/model.zig b/ports/zig/demo/model.zig index 4dcac15..4f3da6f 100644 --- a/ports/zig/demo/model.zig +++ b/ports/zig/demo/model.zig @@ -99,7 +99,12 @@ pub const Pane = struct { selected: usize = 0, offset: usize = 0, total: usize = 0, + log: bool = false, pub fn move(self: *Pane, d: i64) void { + if (self.log) { + self.offset = @intCast(std.math.clamp(@as(i64, @intCast(self.offset)) - d, 0, @as(i64, @intCast(self.total -| 1)))); + return; + } self.selected = @intCast(std.math.clamp(@as(i64, @intCast(self.selected)) + d, 0, @as(i64, @intCast(self.total -| 1)))); } }; @@ -132,8 +137,14 @@ pub const State = struct { key_log: [100][64]u8 = undefined, key_lens: [100]usize = [_]usize{0} ** 100, key_count: usize = 0, + fps: f64 = 0, + render_ms: f64 = 0, + changed_cells: usize = 0, + output_bytes: usize = 0, + slider: f64 = 0.7, + clock: [8]u8 = "12:00:00".*, panes: [80]Pane = [_]Pane{.{}} ** 80, - focused: [10]usize = [_]usize{0} ** 10, + focused: [10]usize = .{ 1, 0, 0, 0, 0, 0, 0, 0, 0, 0 }, missing: []const u8 = "", cpu_previous: [257][2]f64 = [_][2]f64{.{ 0, 0 }} ** 257, net_previous: [2]f64 = .{ 0, 0 }, @@ -308,9 +319,9 @@ pub const State = struct { if (eq(k, "escape")) { self.select_open = false; } else if (eq(k, "up")) { - self.select_index = (self.select_index + themes.len - 1) % themes.len; + self.select_index = (self.select_index + 3) % 4; } else if (eq(k, "down")) { - self.select_index = (self.select_index + 1) % themes.len; + self.select_index = (self.select_index + 1) % 4; } else if (eq(k, "enter")) { self.theme = self.select_index; self.select_open = false; diff --git a/ports/zig/demo/reference.zig b/ports/zig/demo/reference.zig new file mode 100644 index 0000000..75cbb32 --- /dev/null +++ b/ports/zig/demo/reference.zig @@ -0,0 +1,195 @@ +//! Native reference screen helpers. All temporary data is owned by the frame +//! arena; no callback retains a pointer to a stack-local loop variable. +pub const std = @import("std"); +pub const h = @import("hqtui"); +pub const m = @import("model.zig"); +pub const P = h.Container; +pub const V = m.Value; +pub const Color = h.color.Color; +pub const Size = h.layout.Size; +pub const C = struct { + s: *m.State, + v: V, + index: usize = 0, + color: ?Color = null, + pub fn sub(c: *C, p: *P, v: V, index: usize) !*C { + const child = try p.ctx.allocator.create(C); + child.* = .{ .s = c.s, .v = v, .index = index, .color = c.color }; + return child; + } + pub fn at(c: *C, path: []const u8) V { + return m.at(c.v, path); + } + pub fn n(c: *C, path: []const u8) f64 { + return m.num(c.at(path)); + } + pub fn str(c: *C, path: []const u8) []const u8 { + return m.string(c.at(path)); + } + pub fn row(c: *C, p: *P, size: Size, gap: usize, comptime f: fn (*C, *P) anyerror!void) !void { + try p.row(.{ .layout = .{ .size = size, .gap = gap } }, h.Body.with(c, f)); + } + pub fn col(c: *C, p: *P, size: Size, gap: usize, comptime f: fn (*C, *P) anyerror!void) !void { + try p.column(.{ .layout = .{ .size = size, .gap = gap } }, h.Body.with(c, f)); + } + pub fn panel(c: *C, p: *P, o: h.ui.PanelOptions, comptime f: fn (*C, *P) anyerror!void) !void { + try p.panel(o, h.Body.with(c, f)); + } +}; +pub fn root(s: *m.State, p: *P) !*C { + const c = try p.ctx.allocator.create(C); + c.* = .{ .s = s, .v = s.data() }; + return c; +} +pub fn tx(p: *P, text: []const u8, color: Color) !void { + try p.text(text, .{ .fg = color }); +} +pub fn scalar(p: *P, v: V) ![]const u8 { + return switch (v) { + .integer => |n| p.fmt("{d}", .{n}), + .float => |n| p.fmt("{d}", .{n}), + .string => |s| s, + .bool => |b| if (b) "true" else "false", + else => "—", + }; +} +pub fn pct(p: *P, v: f64) ![]const u8 { + return p.fmt("{d:.0}%", .{@floor(std.math.clamp(v, 0, 1) * 100 + 0.5)}); +} +pub fn bytes(p: *P, value: f64, digits: usize) ![]const u8 { + if (!std.math.isFinite(value)) return "—"; + var v = @max(0, value); + for ([_][]const u8{ "B", "KiB", "MiB", "GiB", "TiB", "PiB" }, 0..) |unit, i| { + if (v < 1024 or i == 5) { + return switch (if (i == 0) @as(usize, 0) else digits) { + 0 => p.fmt("{d:.0} {s}", .{ v, unit }), + 1 => p.fmt("{d:.1} {s}", .{ v, unit }), + else => p.fmt("{d:.2} {s}", .{ v, unit }), + }; + } + v /= 1024; + } + return ""; +} +pub fn bitRate(p: *P, v: f64) ![]const u8 { + const b = @max(0, v) * 8; + for ([_]f64{ 1e9, 1e6, 1e3 }, [_][]const u8{ "Gb/s", "Mb/s", "Kb/s" }) |scale, unit| { + if (b >= scale) return p.fmt("{d:.1} {s}", .{ b / scale, unit }); + } + return p.fmt("{d:.0} b/s", .{b}); +} +pub fn byteRate(p: *P, v: f64) ![]const u8 { + for ([_]f64{ 1e9, 1e6, 1e3 }, [_][]const u8{ "GB/s", "MB/s", "KB/s" }) |scale, unit| { + if (v >= scale) return p.fmt("{d:.1} {s}", .{ v / scale, unit }); + } + return p.fmt("{d:.0} B/s", .{@max(0, v)}); +} +pub fn duration(p: *P, v: f64) ![]const u8 { + const n: u64 = @intFromFloat(@max(0, v)); + const d = n / 86400; + const hours = n % 86400 / 3600; + const mins = n % 3600 / 60; + if (d > 0) return p.fmt("{d}d {d}h {d}m", .{ d, hours, mins }); + if (hours > 0) return p.fmt("{d}h {d}m", .{ hours, mins }); + return p.fmt("{d}m {d}s", .{ mins, n % 60 }); +} +pub fn kv(label: []const u8, value: []const u8, color: ?Color) h.widgets.KeyValueRow { + return .{ .label = label, .value = value, .color = color }; +} +pub fn keys(p: *P, rows: []const h.widgets.KeyValueRow, spread: bool) !void { + try p.keyValues(.{ .rows = try p.ctx.allocator.dupe(h.widgets.KeyValueRow, rows), .spread = spread }); +} +pub fn plot(p: *P, v: V, color: Color, maximum: ?f64, axis: bool) !void { + try p.graph(.{ .values = try m.numbers(p.ctx.allocator, v), .axis = axis, .plot = .{ .min = 0, .max = maximum, .fill = true, .color = color } }); +} +pub fn multi(p: *P, a: V, b: V, ca: Color, cb: Color) !void { + const sr = try p.ctx.allocator.alloc(h.graphics.Series, 2); + sr[0] = .{ .values = try m.numbers(p.ctx.allocator, a), .color = ca, .fill = true }; + sr[1] = .{ .values = try m.numbers(p.ctx.allocator, b), .color = cb, .fill = true }; + try p.graph(.{ .series = sr, .plot = .{ .min = 0 } }); +} +pub fn meter(p: *P, v: f64, color: ?Color) !void { + try p.meter(.{ .value = v, .color = color, .show_value = false, .style = .segmented }); +} +pub fn sizedText(p: *P, value: []const u8, style: h.widgets.TextStyle, size: usize) !void { + const T = struct { + v: []const u8, + style: h.widgets.TextStyle, + fn draw(c: *@This(), q: *P) !void { + try q.text(c.v, c.style); + } + }; + const c = try p.ctx.allocator.create(T); + c.* = .{ .v = value, .style = style }; + try p.sized(.{ .cells = @intCast(size) }, h.Body.with(c, T.draw)); +} +pub const DC = struct { key: []const u8, col: h.widgets.TableColumn, format: enum { raw, one, bytes0, pct, inodes } = .raw, tint: enum { none, cpu, state, login, ssh, status, service, usage } = .none }; +pub fn dc(key: []const u8, title: []const u8, width: usize, minimum: usize, color: ?Color, right: bool) DC { + return .{ .key = key, .col = .{ .title = title, .width = if (width > 0) .{ .cells = @intCast(width) } else null, .min = if (minimum > 0) minimum else null, .color = color, .alignment = if (right) .right else .left } }; +} +pub fn statusColor(t: h.theme.Theme, name: []const u8) Color { + return if (m.eq(name, "1xx")) t.secondary else if (m.eq(name, "2xx")) t.success else if (m.eq(name, "3xx")) t.accent else if (m.eq(name, "4xx")) t.warning else if (m.eq(name, "5xx")) t.danger else t.muted; +} +pub fn table(c: *C, p: *P, index: usize, data: []const V, columns: []const DC, zebra: bool, header: bool, scrollbar: bool) !void { + const Table = struct { + c: *C, + index: usize, + data: []const V, + columns: []const DC, + zebra: bool, + header: bool, + scrollbar: bool, + fn draw(ctx: *@This(), surface: *P) anyerror!void { + try drawTable(ctx.c, surface, ctx.index, ctx.data, ctx.columns, ctx.zebra, ctx.header, ctx.scrollbar); + } + }; + const ctx = try p.ctx.allocator.create(Table); + ctx.* = .{ .c = c, .index = index, .data = data, .columns = try p.ctx.allocator.dupe(DC, columns), .zebra = zebra, .header = header, .scrollbar = scrollbar }; + try p.column(.{}, h.Body.with(ctx, Table.draw)); +} +fn drawTable(c: *C, p: *P, index: usize, data: []const V, columns: []const DC, zebra: bool, header: bool, scrollbar: bool) !void { + const a = p.ctx.allocator; + const t = p.theme().*; + const pane = &c.s.panes[c.s.screen * 8 + index]; + pane.total = data.len; + pane.move(0); + pane.offset = h.widgets.resolveOffset(pane.offset, pane.selected, p.height() -| @as(usize, if (header) 1 else 0), data.len, true); + const cols = try a.alloc(h.widgets.TableColumn, columns.len); + for (columns, 0..) |d, i| cols[i] = d.col; + const rows = try a.alloc(h.widgets.TableRow, data.len); + for (data, 0..) |d, i| { + const cells = try a.alloc([]const u8, columns.len); + const colors = try a.alloc(?Color, columns.len); + for (columns, 0..) |co, j| { + const v = m.get(d, co.key); + cells[j] = switch (co.format) { + .raw => try scalar(p, v), + .one => try p.fmt("{d:.1}", .{m.num(v)}), + .bytes0 => try bytes(p, m.num(v), 0), + .pct => if (m.num(m.get(d, "size")) > 0) try pct(p, m.num(m.get(d, "used")) / m.num(m.get(d, "size"))) else "-", + .inodes => if (m.num(m.get(d, "inodesTotal")) > 0) try p.fmt("{s} of {d:.1}M", .{ try pct(p, m.num(m.get(d, "inodesUsed")) / m.num(m.get(d, "inodesTotal"))), m.num(m.get(d, "inodesTotal")) / 1e6 }) else "-", + }; + colors[j] = switch (co.tint) { + .none => co.col.color, + .cpu => h.theme.heatColor(t, @min(1, m.num(v) / 100)), + .state => if (m.eq(m.string(v), "R")) t.success else t.muted, + .login => if (m.eq(m.string(v), "still")) t.success else t.muted, + .ssh => if (m.eq(m.string(v), "accepted")) t.success else if (m.eq(m.string(v), "disconnect")) t.muted else t.danger, + .status => if (cells[j].len > 0) statusColor(t, try p.fmt("{c}xx", .{cells[j][0]})) else t.muted, + .service => if (m.eq(m.string(v), "failed")) t.danger else if (m.eq(m.string(v), "active")) t.success else t.muted, + .usage => if (m.num(m.get(d, "size")) > 0 and m.num(m.get(d, "used")) / m.num(m.get(d, "size")) > 0.9) t.danger else t.warning, + }; + } + rows[i] = .{ .cells = cells, .cell_colors = colors }; + } + try p.table(.{ .rows = rows, .columns = cols, .selected = pane.selected, .offset = pane.offset, .follow_selection = true, .zebra = zebra, .header = header, .scrollbar = scrollbar }, try p.fmt("pane:{d}", .{c.s.screen * 8 + index})); +} +pub fn log(c: *C, p: *P, index: usize) !void { + const data = m.arr(m.at(c.s.data(), "logs")); + const pane = &c.s.panes[c.s.screen * 8 + index]; + pane.log = true; + pane.total = data.len; + const entries = try p.ctx.allocator.alloc(h.widgets.LogEntry, data.len); + for (data, 0..) |v, i| entries[i] = .{ .time = m.string(m.get(v, "time")), .level = m.string(m.get(v, "level")), .message = m.string(m.get(v, "message")), .meta = try p.fmt("{{{s}}}", .{m.string(m.get(v, "meta"))}) }; + try p.log(.{ .entries = entries, .from_end = pane.offset, .scrollbar = true }, try p.fmt("pane:{d}", .{c.s.screen * 8 + index})); +} diff --git a/ports/zig/demo/showcase.zig b/ports/zig/demo/showcase.zig new file mode 100644 index 0000000..a5fa543 --- /dev/null +++ b/ports/zig/demo/showcase.zig @@ -0,0 +1,350 @@ +//! Native translations of the reference's five widget/rendering showcases. +const r = @import("reference.zig"); +const h = r.h; +const m = r.m; +const std = r.std; +const C = r.C; +const P = r.P; +pub fn render(s: *m.State, p: *P) !void { + const c = try r.root(s, p); + switch (s.screen) { + 5 => try components(c, p), + 6 => try graphics(c, p), + 7 => try themes(c, p), + 8 => try input(c, p), + 9 => try stress(c, p), + else => {}, + } +} +fn wave(p: *P, phase: f64, freq: f64) ![]f64 { + const values = try p.ctx.allocator.alloc(f64, 240); + for (values, 0..) |*v, i| v.* = @sin(@as(f64, @floatFromInt(i)) / freq + phase) * 50 + 50; + return values; +} +fn graphics(c: *C, p: *P) !void { + try c.row(p, .{ .fr = 1 }, 1, graphicsColumns); +} +fn graphicsColumns(c: *C, p: *P) !void { + try c.col(p, .fill, 1, graphicsLeft); + try c.col(p, .fill, 1, graphicsRight); +} +fn graphicsLeft(c: *C, p: *P) !void { + for ([_][]const u8{ "Braille (2×4 pixels per cell)", "Block elements", "ASCII fallback" }, 0..) |title, i| { + const d = try c.sub(p, c.v, i); + try d.panel(p, .{ .title = title }, modeGraph); + } +} +fn modeGraph(c: *C, p: *P) !void { + const t = p.theme().*; + var o: h.graphics.PlotOptions = .{ .min = 0, .max = 100 }; + switch (c.index) { + 0 => { + o.fill = true; + o.color = t.accent; + o.grid = true; + }, + 1 => { + o.mode = .block; + o.colors = t.heat; + }, + else => { + o.mode = .ascii; + o.color = t.foreground; + }, + } + try p.graph(.{ .values = try wave(p, c.n("time") / 3, 9), .plot = o }); +} +fn graphicsRight(c: *C, p: *P) !void { + try c.panel(p, .{ .title = "Multi-series" }, multiSeries); + try c.panel(p, .{ .title = "Gradients" }, gradients); + try c.panel(p, .{ .title = "Raw Braille canvas" }, canvasPanel); +} +fn multiSeries(c: *C, p: *P) !void { + const t = p.theme().*; + const time = c.n("time"); + const series = try p.ctx.allocator.alloc(h.graphics.Series, 3); + series[0] = .{ .values = try wave(p, time / 3, 9), .color = t.primary, .label = "alpha" }; + series[1] = .{ .values = try wave(p, time / 3 + 2, 5), .color = t.success, .label = "beta" }; + series[2] = .{ .values = try wave(p, time / 2, 17), .color = t.secondary, .label = "gamma" }; + try p.graph(.{ .series = series, .axis = true, .legend = true, .plot = .{ .min = 0, .max = 100 } }); +} +fn gradients(c: *C, p: *P) !void { + try p.draw(h.ui.DrawBody.with(c, gradientDraw)); +} +fn gradientDraw(_: *C, s: h.Surface) !void { + const ramp = h.color.Gradient.init(s.theme.heat); + for (0..s.height()) |y| { + for (0..s.width()) |x| { + const value = if (s.width() > 1) @as(f64, @floatFromInt(x)) / @as(f64, @floatFromInt(s.width() - 1)) else 0; + s.char(@intCast(x), @intCast(y), '█', .{ .fg = ramp.sample(value) }); + } + } +} +fn canvasPanel(c: *C, p: *P) !void { + try p.canvas(p.theme().accent, h.ui.CanvasBody.with(c, canvasDraw)); +} +fn canvasDraw(c: *C, canvas: *h.graphics.BrailleCanvas) !void { + const cx = @as(f64, @floatFromInt(canvas.width)) / 2; + const cy = @as(f64, @floatFromInt(canvas.height)) / 2; + const radius = @min(cx, cy) - 2; + canvas.circle(cx, cy, radius); + for (0..12) |i| { + const angle = @as(f64, @floatFromInt(i)) / 12 * std.math.pi * 2 + c.n("time") / 4; + canvas.line(cx, cy, cx + @cos(angle) * radius, cy + @sin(angle) * radius * 0.9); + } +} +fn themes(c: *C, p: *P) !void { + try p.label(try p.fmt("Theme {d}/9: {s} ←/→ or F2 to change", .{ c.s.theme + 1, p.theme().name })); + try p.spacer(.{ .cells = 1 }); + try p.grid(.{ .column_count = 3, .row_count = 3, .layout = .{ .gap = 1 } }, h.GridBody.with(c, themeGrid)); +} +fn themeGrid(c: *C, g: *h.Grid) !void { + for (m.themes, 0..) |name, i| { + const e = h.theme.resolve(name); + const d = try g.ctx.allocator.create(C); + d.* = .{ .s = c.s, .v = c.v, .index = i }; + try g.panel(.{ .title = e.name, .border_color = if (i == c.s.theme) e.border_focused else e.border, .layout = .{ .background = e.background } }, .{}, h.Body.with(d, themePanel)); + } +} +fn themePanel(c: *C, p: *P) !void { + const e = h.theme.resolve(m.themes[c.index]); + try c.row(p, .{ .cells = 1 }, 1, themeBadges); + try p.meter(.{ .value = 0.72, .label = "cpu", .background = e.background }); + try p.graph(.{ .values = try m.numbers(p.ctx.allocator, c.at("cpu.history")), .plot = .{ .min = 0, .max = 100, .fill = true, .color = e.graph[0], .background = e.background } }); + try p.sized(.{ .cells = 1 }, h.Body.with(c, themeRamp)); +} +fn themeBadges(c: *C, p: *P) !void { + const e = h.theme.resolve(m.themes[c.index]); + for ([_][]const u8{ "primary", "ok", "warn", "err" }, [_]r.Color{ e.primary, e.success, e.warning, e.danger }, [_]usize{ 10, 5, 7, 6 }) |label, color, width| { + const B = struct { + label: []const u8, + color: r.Color, + fn draw(b: *@This(), q: *P) !void { + try q.badge(.{ .text = b.label, .color = b.color }); + } + }; + const b = try p.ctx.allocator.create(B); + b.* = .{ .label = label, .color = color }; + try p.sized(.{ .cells = @intCast(width) }, h.Body.with(b, B.draw)); + } + try p.spacer(.fill); +} +fn themeRamp(c: *C, p: *P) !void { + try p.draw(h.ui.DrawBody.with(c, themeRampDraw)); +} +fn themeRampDraw(c: *C, s: h.Surface) !void { + const e = h.theme.resolve(m.themes[c.index]); + for (e.graph, 0..) |color, ci| { + for (0..3) |x| s.char(@intCast(ci * 4 + x), 0, '█', .{ .fg = color, .bg = e.background }); + } +} +fn input(c: *C, p: *P) !void { + try c.row(p, .{ .fr = 1 }, 1, inputColumns); +} +fn inputColumns(c: *C, p: *P) !void { + try c.panel(p, .{ .title = "Last Events" }, lastEvents); + try c.panel(p, .{ .title = "Try it" }, tryInput); +} +fn lastEvents(c: *C, p: *P) !void { + const t = p.theme().*; + try r.keys(p, &.{ r.kv("Key", if (c.s.last_key.len > 0) c.s.last_key.slice() else "—", t.accent), r.kv("Mouse", if (c.s.last_mouse.len > 0) c.s.last_mouse.slice() else "—", t.primary) }, true); + try p.spacer(.{ .cells = 1 }); + try p.divider(.{ .label = "history" }); + const count = @min(20, c.s.key_count); + const items = try p.ctx.allocator.alloc(h.widgets.ListItem, count); + for (items, 0..) |*item, i| { + const index = c.s.key_count - 1 - i; + item.* = .{ .label = c.s.key_log[index][0..c.s.key_lens[index]] }; + } + try p.list(.{ .items = items }, "input.history"); +} +fn tryInput(c: *C, p: *P) !void { + try r.tx(p, "Press any key — modifiers are normalized.", p.theme().foreground); + try p.label("Arrows, Function keys, Ctrl/Alt/Shift combinations,"); + try p.label("paste, focus, mouse move, click, drag and scroll."); + try p.spacer(.{ .cells = 1 }); + try p.divider(.{ .label = "focusable controls" }); + try p.spacer(.{ .cells = 1 }); + try c.row(p, .{ .cells = 1 }, 2, inputButtons); + try p.spacer(.{ .cells = 1 }); + try p.label("Tab / Shift+Tab moves focus. Enter activates."); + try p.spacer(.fill); + try r.keys(p, &.{ r.kv("Mouse tracking", "on", null), r.kv("Bracketed paste", "on", null), r.kv("Focus events", "on", null) }, true); +} +fn inputButtons(c: *C, p: *P) !void { + for (0..3) |i| { + const d = try c.sub(p, c.v, i); + try p.sized(.{ .cells = 12 }, h.Body.with(d, inputButton)); + } + try p.spacer(.fill); +} +fn inputButton(c: *C, p: *P) !void { + switch (c.index) { + 0 => try p.button(.{ .label = "Button A", .width = 12 }, "input.a"), + 1 => try p.button(.{ .label = "Button B", .width = 12, .variant = .success }, "input.b"), + else => try p.checkbox(.{ .label = "Check", .checked = c.s.checked }, "check"), + } +} +fn stress(c: *C, p: *P) !void { + try c.row(p, .{ .cells = 3 }, 1, stressStats); + try c.panel(p, .{ .title = "Full-screen churn" }, churn); +} +fn stressStats(c: *C, p: *P) !void { + for ([_][]const u8{ "Render", "Changed cells", "Bytes/frame", "FPS" }, 0..) |title, i| { + const d = try c.sub(p, c.v, i); + try d.panel(p, .{ .title = title }, stressStat); + } +} +fn stressStat(c: *C, p: *P) !void { + const t = p.theme().*; + const text = switch (c.index) { + 0 => try p.fmt("{d:.2} ms/frame", .{c.s.render_ms}), + 1 => try p.fmt("{d}", .{c.s.changed_cells}), + 2 => try p.fmt("{d}", .{c.s.output_bytes}), + else => try p.fmt("{d:.1}", .{c.s.fps}), + }; + try r.tx(p, text, ([_]r.Color{ t.success, t.warning, t.primary, t.accent })[c.index]); +} +fn churn(c: *C, p: *P) !void { + try p.draw(h.ui.DrawBody.with(c, churnDraw)); +} +fn churnDraw(c: *C, s: h.Surface) !void { + const ramp = h.color.Gradient.init(s.theme.graph); + const chars = [_]u21{ '▖', '▗', '▘', '▙', '▚', '▛', '▜', '▝', '▞', '▟', '█', '▓', '▒', '░' }; + const time = c.n("time"); + for (0..s.height()) |y| { + for (0..s.width()) |x| { + const v = (@sin(@as(f64, @floatFromInt(x)) / 6 + time) + @cos(@as(f64, @floatFromInt(y)) / 4 - time)) / 2; + const n = (v + 1) / 2; + const i: usize = @intFromFloat(@floor(n * @as(f64, @floatFromInt(chars.len - 1)))); + s.char(@intCast(x), @intCast(y), chars[i], .{ .fg = ramp.sample(n) }); + } + } +} +fn components(c: *C, p: *P) !void { + try c.row(p, .{ .fr = 1 }, 1, componentColumns); +} +fn componentColumns(c: *C, p: *P) !void { + try c.col(p, .fill, 1, componentLeft); + try c.col(p, .fill, 1, componentRight); +} +fn componentLeft(c: *C, p: *P) !void { + try c.panel(p, .{ .title = "Buttons & Inputs", .layout = .{ .size = .{ .cells = 13 } } }, controls); + try c.panel(p, .{ .title = "Table Widget" }, files); + try c.panel(p, .{ .title = "Log Viewer", .layout = .{ .size = .{ .cells = 11 } } }, logs); +} +fn controls(c: *C, p: *P) !void { + try c.row(p, .{ .cells = 1 }, 1, buttons); + try p.spacer(.{ .cells = 1 }); + try c.row(p, .{ .cells = 1 }, 2, inputs); + try p.spacer(.{ .cells = 1 }); + try p.textInput(.{ .label = "Search", .value = c.s.input.slice(), .placeholder = "type to filter…", .focused = c.s.editing }, "input"); + try p.spacer(.{ .cells = 1 }); + try p.meter(.{ .label = "Slider", .value = c.s.slider, .style = .smooth, .heat = false, .color = p.theme().primary }); + try p.progress(.{ .label = "Progress", .value = 37, .max = 120, .show_count = true }); +} +fn buttons(c: *C, p: *P) !void { + for (0..4) |i| { + const d = try c.sub(p, c.v, i); + try p.sized(.{ .cells = if (i == 3) 10 else 11 }, h.Body.with(d, button)); + } + try p.spacer(.fill); +} +fn button(c: *C, p: *P) !void { + const name = ([_][]const u8{ "Primary", "Success", "Warning", "Danger" })[c.index]; + try p.button(.{ .label = name, .width = if (c.index == 3) 10 else 11, .variant = ([_]h.widgets.ButtonVariant{ .primary, .success, .warning, .danger })[c.index] }, try p.fmt("button:{s}", .{name})); +} +fn inputs(c: *C, p: *P) !void { + for ([_]i64{ 20, 12, 14 }, 0..) |width, i| { + const d = try c.sub(p, c.v, i); + try p.sized(.{ .cells = width }, h.Body.with(d, inputControl)); + } + try p.spacer(.fill); +} +fn inputControl(c: *C, p: *P) !void { + const options = &[_][]const u8{ "Dark", "Dracula", "Nord", "Tokyo Night" }; + switch (c.index) { + 0 => try p.select(.{ .value = options[c.s.select_index % 4], .width = 20, .open = c.s.select_open, .options = options, .selected_index = c.s.select_index }, "select"), + 1 => try p.checkbox(.{ .label = "Toggle", .checked = c.s.toggle, .variant = .toggle }, "toggle"), + else => try p.checkbox(.{ .label = "Checkbox", .checked = c.s.checked }, "check"), + } +} +fn files(c: *C, p: *P) !void { + const t = p.theme().*; + // Static widget examples need no JSON parsing or per-row allocations. + const rows = &[_]h.widgets.TableRow{ + .{ .cells = &.{ "src", "4.2 KB", "dir", "2m ago" } }, .{ .cells = &.{ "test", "1.1 KB", "dir", "5m ago" } }, + .{ .cells = &.{ "package.json", "1.2 KB", "file", "10m ago" } }, .{ .cells = &.{ "README.md", "3.4 KB", "file", "1h ago" } }, + .{ .cells = &.{ "bun.lockb", "12 KB", "file", "1h ago" } }, + }; + const pane = &c.s.panes[40]; + pane.total = rows.len; + pane.move(0); + pane.offset = h.widgets.resolveOffset(pane.offset, pane.selected, p.height() -| 1, rows.len, true); + const columns = try p.ctx.allocator.dupe(h.widgets.TableColumn, &.{ + .{ .title = "Name", .min = 10, .color = t.primary }, .{ .title = "Size", .width = .{ .cells = 9 }, .alignment = .right }, + .{ .title = "Type", .width = .{ .cells = 6 } }, .{ .title = "Modified", .width = .{ .cells = 10 }, .alignment = .right, .color = t.muted }, + }); + try p.table(.{ .rows = rows, .columns = columns, .selected = pane.selected, .offset = pane.offset, .follow_selection = true, .zebra = true }, "pane:40"); +} +fn logs(c: *C, p: *P) !void { + try r.log(c, p, 1); +} +fn componentRight(c: *C, p: *P) !void { + try c.panel(p, .{ .title = "Process Tree", .layout = .{ .size = .{ .cells = 13 } } }, tree); + try c.panel(p, .{ .title = "Sparklines & Gauges", .layout = .{ .size = .{ .cells = 12 } } }, gauges); + try c.panel(p, .{ .title = "Lists & Badges" }, lists); +} +const Tree = h.widgets.TreeNode; +const TV = h.widgets.TreeValue; +fn tv(comptime cpu: []const u8, comptime mem: []const u8) []const TV { + return &.{ .{ .text = cpu, .width = 6 }, .{ .text = mem, .width = 6 } }; +} +const nodes = &[_]Tree{.{ .label = "systemd", .values = tv("1.3", "0.1"), .children = &.{ .{ .label = "bash", .values = tv("0.1", "0.2") }, .{ .label = "bun", .values = tv("32.8", "4.2"), .children = &.{ .{ .label = "bun:worker", .values = tv("12.4", "1.8") }, .{ .label = "bun:worker", .values = tv("8.7", "1.3") } } }, .{ .label = "node", .values = tv("18.1", "2.1"), .children = &.{.{ .label = "node:worker", .values = tv("6.1", "0.8") }} }, .{ .label = "postgres", .values = tv("6.7", "1.8") } } }}; +fn treeHeader(_: *C, p: *P) !void { + try p.text("Name", .{ .fg = p.theme().muted, .bold = true }); + try p.text("CPU% MEM%", .{ .fg = p.theme().muted, .bold = true, .alignment = .right }); +} +fn tree(c: *C, p: *P) !void { + try c.row(p, .{ .cells = 1 }, 0, treeHeader); + const pane = &c.s.panes[42]; + pane.total = 8; + pane.move(0); + try p.tree(.{ .nodes = nodes, .selected = pane.selected, .offset = pane.offset, .follow_selection = true }, "pane:42"); +} +fn gauges(c: *C, p: *P) !void { + const t = p.theme().*; + try p.sparkline(.{ .label = "CPU ", .values = try m.numbers(p.ctx.allocator, c.at("cpu.history")), .text = try r.pct(p, c.n("cpu.total")), .color = t.success }); + try p.sparkline(.{ .label = "Mem ", .values = try m.numbers(p.ctx.allocator, c.at("memory.history")), .text = try r.pct(p, c.n("memory.used") / @max(1, c.n("memory.total"))), .color = t.warning }); + try p.sparkline(.{ .label = "Net ", .values = try m.numbers(p.ctx.allocator, c.at("network.downHistory")), .text = try p.fmt("{s}/s", .{try r.bytes(p, c.n("network.downRate"), 2)}), .color = t.primary }); + try p.spacer(.{ .cells = 1 }); + try c.row(p, .{ .fr = 1 }, 2, dials); +} +fn dials(c: *C, p: *P) !void { + const t = p.theme().*; + try p.gauge(.{ .value = c.n("cpu.total"), .label = try r.pct(p, c.n("cpu.total")) }); + const segments = try p.ctx.allocator.alloc(h.graphics.DonutSegment, 2); + segments[0] = .{ .value = c.n("memory.used"), .color = t.primary, .label = "Used" }; + segments[1] = .{ .value = c.n("memory.available"), .color = t.warning, .label = "Free" }; + try p.donut(.{ .segments = segments }); +} +fn lists(c: *C, p: *P) !void { + try c.row(p, .{ .cells = 1 }, 1, badges); + try p.spacer(.{ .cells = 1 }); + const pane = &c.s.panes[43]; + pane.total = 4; + pane.move(0); + const items = try p.ctx.allocator.dupe(h.widgets.ListItem, &.{ .{ .label = "apps/demo", .color = p.theme().primary }, .{ .label = "packages/hqtui" }, .{ .label = "apps/web" }, .{ .label = "docs" } }); + try p.list(.{ .items = items, .selected = pane.selected, .offset = pane.offset, .follow_selection = true, .bullet = "▸", .scrollbar = true }, "pane:43"); +} +fn badges(c: *C, p: *P) !void { + for ([_]i64{ 10, 8, 10 }, 0..) |width, i| { + const d = try c.sub(p, c.v, i); + try p.sized(.{ .cells = width }, h.Body.with(d, badge)); + } + try p.spacer(.fill); +} +fn badge(c: *C, p: *P) !void { + const t = p.theme().*; + try p.badge(.{ .text = ([_][]const u8{ "active", "idle", "failed" })[c.index], .color = ([_]r.Color{ t.success, t.warning, t.danger })[c.index], .variant = ([_]h.widgets.BadgeVariant{ .filled, .subtle, .outline })[c.index] }); +} diff --git a/ports/zig/demo/telemetry_view.zig b/ports/zig/demo/telemetry_view.zig new file mode 100644 index 0000000..71e8651 --- /dev/null +++ b/ports/zig/demo/telemetry_view.zig @@ -0,0 +1,335 @@ +//! Native Traffic/Sessions/Network/Services reference layouts and formatting. +const r = @import("reference.zig"); +const h = r.h; +const m = r.m; +const std = r.std; +const C = r.C; +const P = r.P; +pub fn render(s: *m.State, p: *P) !void { + const c = try r.root(s, p); + switch (s.screen) { + 1 => try traffic(c, p), + 2 => try sessions(c, p), + 3 => try network(c, p), + 4 => try services(c, p), + else => {}, + } +} +fn rate(p: *P, v: f64) ![]const u8 { + if (v >= 1e6) return p.fmt("{d:.1}M/s", .{v / 1e6}); + if (v >= 1000) return p.fmt("{d:.1}K/s", .{v / 1000}); + return p.fmt("{d:.0}/s", .{v}); +} +fn commas(p: *P, v: f64) ![]const u8 { + const text = try p.fmt("{d:.0}", .{v}); + var out: std.ArrayList(u8) = .empty; + for (text, 0..) |ch, i| { + if (i > 0 and (text.len - i) % 3 == 0) try out.append(p.ctx.allocator, ','); + try out.append(p.ctx.allocator, ch); + } + return out.toOwnedSlice(p.ctx.allocator); +} +fn traffic(c: *C, p: *P) !void { + if (c.s.real) try p.label("Protocol/direction: port-based estimates; HTTP rate: estimated from log growth"); + try c.row(p, .{ .cells = 13 }, 1, trafficTop); + try c.row(p, .{ .fr = 1 }, 1, trafficMiddle); + if (m.arr(c.at("telemetry.http.recent")).len > 0) try c.panel(p, .{ .title = "Recent Requests", .layout = .{ .size = .{ .cells = 10 } }, .border_color = p.theme().primary }, requests); +} +fn trafficTop(c: *C, p: *P) !void { + const t = p.theme().*; + try c.panel(p, .{ .title = "Protocols", .subtitle = try p.fmt("{d:.0} in / {d:.0} out", .{ c.n("telemetry.inboundConnections"), c.n("telemetry.outboundConnections") }), .border_color = t.accent }, protocols); + try c.panel(p, .{ .title = "TCP", .layout = .{ .size = .{ .fr = 0.9 } }, .border_color = t.primary }, tcp); + try c.panel(p, .{ .title = "Retransmits", .layout = .{ .size = .{ .fr = 0.7 } }, .border_color = if (c.n("telemetry.net.retransRatio") > 0.02) t.danger else t.success }, retrans); +} +fn protocols(c: *C, p: *P) !void { + const data = m.arr(c.at("telemetry.protocols")); + if (data.len == 0) { + try p.label("No sockets visible."); + return; + } + var maximum: f64 = 1; + for (data) |v| maximum = @max(maximum, m.num(m.get(v, "total"))); + const items = try p.ctx.allocator.alloc(h.widgets.MeterItem, @min(9, data.len)); + for (items, data[0..items.len], 0..) |*item, v, i| item.* = .{ .label = m.string(m.get(v, "protocol")), .value = m.num(m.get(v, "total")) / maximum, .text = try r.scalar(p, m.get(v, "total")), .color = h.theme.seriesColor(p.theme().*, @intCast(i)) }; + try p.meters(.{ .items = items, .label_width = 13, .value_width = 5 }); +} +fn tcpRates(c: *C, p: *P) !void { + const t = p.theme().*; + try r.tx(p, try p.fmt("↓ {s} seg", .{try rate(p, c.n("telemetry.net.rates.inSegs"))}), t.primary); + try p.text(try p.fmt("↑ {s} seg", .{try rate(p, c.n("telemetry.net.rates.outSegs"))}), .{ .fg = t.secondary, .alignment = .right }); +} +fn tcp(c: *C, p: *P) !void { + const t = p.theme().*; + try c.row(p, .{ .cells = 1 }, 0, tcpRates); + try r.multi(p, c.at("telemetry.netInHistory"), c.at("telemetry.netOutHistory"), t.primary, t.secondary); + try p.divider(.{}); + try r.keys(p, &.{ r.kv("Established", try r.scalar(p, c.at("telemetry.net.tcpEstablished")), t.success), r.kv("Opens in/out", try p.fmt("{s} / {s}", .{ try rate(p, c.n("telemetry.net.rates.passiveOpens")), try rate(p, c.n("telemetry.net.rates.activeOpens")) }), t.accent), r.kv("Resets sent", try commas(p, c.n("telemetry.net.tcpOutRsts")), t.muted) }, true); +} +fn retrans(c: *C, p: *P) !void { + const t = p.theme().*; + try p.text(try p.fmt("{d:.2}%", .{std.math.clamp(c.n("telemetry.net.retransRatio"), 0, 1) * 100}), .{ .fg = if (c.n("telemetry.net.retransRatio") > 0.02) t.danger else t.success, .bold = true }); + try p.label("of outbound segments"); + try r.plot(p, c.at("telemetry.retransHistory"), t.danger, null, false); + try r.keys(p, &.{ r.kv("UDP in/out", try p.fmt("{s} / {s}", .{ try rate(p, c.n("telemetry.net.rates.udpIn")), try rate(p, c.n("telemetry.net.rates.udpOut")) }), t.muted), r.kv("ICMP", try p.fmt("{d:.0} / {d:.0}", .{ c.n("telemetry.net.icmpInMsgs"), c.n("telemetry.net.icmpOutMsgs") }), t.muted) }, true); +} +fn trafficMiddle(c: *C, p: *P) !void { + try c.col(p, .fill, 1, httpColumn); + try c.col(p, .{ .fr = 0.85 }, 1, sshColumn); +} +fn httpColumn(c: *C, p: *P) !void { + try c.panel(p, .{ .title = "HTTP", .subtitle = if (c.at("telemetry.http") == .null) "no access log" else try p.fmt("{d:.1} req/s", .{c.n("telemetry.http.requestsPerSecond")}), .border_color = p.theme().success }, httpBody); +} +fn httpSource(c: *C, p: *P) !void { + const t = p.theme().*; + try r.tx(p, c.str("telemetry.http.source"), t.muted); + try p.text(try p.fmt("{d:.0} upgrades (ws)", .{c.n("telemetry.http.upgrades")}), .{ .fg = t.secondary, .alignment = .right }); +} +fn httpGraph(c: *C, p: *P) !void { + try r.plot(p, c.at("telemetry.http.history"), p.theme().success, null, false); +} +fn httpBody(c: *C, p: *P) !void { + const t = p.theme().*; + if (c.at("telemetry.http") == .null) { + try p.label("No readable HTTP access log."); + try p.label("nginx, apache, httpd and caddy logs are"); + try p.label("root/adm readable — run with sudo to track requests."); + return; + } + try c.row(p, .{ .cells = 1 }, 0, httpSource); + try p.sized(.{ .cells = 6 }, h.Body.with(c, httpGraph)); + try p.divider(.{ .label = "status" }); + const data = m.arr(c.at("telemetry.http.statusClasses")); + var maximum: f64 = 1; + for (data) |v| maximum = @max(maximum, m.num(m.get(v, "count"))); + const items = try p.ctx.allocator.alloc(h.widgets.MeterItem, data.len); + for (data, 0..) |v, i| items[i] = .{ .label = m.string(m.get(v, "class")), .value = m.num(m.get(v, "count")) / maximum, .text = try r.scalar(p, m.get(v, "count")), .color = r.statusColor(t, m.string(m.get(v, "class"))) }; + try p.meters(.{ .items = items, .label_width = 5, .value_width = 7 }); + try p.divider(.{ .label = "top paths" }); + try r.table(c, p, 0, m.arr(c.at("telemetry.http.topPaths")), &.{ r.dc("path", "Path", 0, 20, t.primary, false), r.dc("count", "Hits", 7, 0, t.accent, true) }, false, false, true); +} +fn sshColumn(c: *C, p: *P) !void { + try c.panel(p, .{ .title = "SSH Activity", .subtitle = try p.fmt("{d}", .{m.arr(c.at("telemetry.ssh")).len}), .border_color = p.theme().warning }, ssh); + try c.panel(p, .{ .title = "Top Remote Hosts", .layout = .{ .size = .{ .cells = 10 } }, .border_color = p.theme().secondary }, remotes); +} +fn ssh(c: *C, p: *P) !void { + const t = p.theme().*; + const data = try p.ctx.allocator.dupe(m.Value, m.arr(c.at("telemetry.ssh"))); + if (data.len == 0) { + try p.label("No sshd events in the journal."); + return; + } + std.mem.reverse(m.Value, data); + var cols = [_]r.DC{ r.dc("time", "Time", 9, 0, t.muted, false), r.dc("action", "Action", 11, 0, null, false), r.dc("user", "User", 12, 0, t.primary, false), r.dc("from", "From", 0, 14, t.accent, false), r.dc("method", "Method", 10, 0, t.muted, false) }; + cols[1].tint = .ssh; + try r.table(c, p, 1, data, &cols, true, true, true); +} +fn remotes(c: *C, p: *P) !void { + const t = p.theme().*; + const data = m.arr(c.at("telemetry.remotes")); + if (data.len == 0) { + try p.label("No remote peers."); + return; + } + try r.table(c, p, 2, data, &.{ r.dc("host", "Host", 0, 16, t.accent, false), r.dc("connections", "Conns", 6, 0, t.success, true), r.dc("protocols", "Protocols", 0, 12, t.muted, false) }, true, true, true); +} +fn requests(c: *C, p: *P) !void { + const t = p.theme().*; + var cols = [_]r.DC{ r.dc("time", "Time", 9, 0, t.muted, false), r.dc("method", "Method", 7, 0, t.secondary, false), r.dc("path", "Path", 0, 24, t.primary, false), r.dc("status", "Status", 7, 0, null, true), r.dc("client", "Client", 16, 0, t.accent, false), r.dc("bytes", "Bytes", 9, 0, t.muted, true) }; + cols[3].tint = .status; + try r.table(c, p, 3, m.arr(c.at("telemetry.http.recent")), &cols, true, true, true); +} +fn sessions(c: *C, p: *P) !void { + try c.row(p, .{ .cells = 9 }, 1, sessionsTop); + try c.row(p, .{ .fr = 1 }, 1, sessionsBottom); +} +fn sessionsTop(c: *C, p: *P) !void { + try c.panel(p, .{ .title = "Active Sessions", .subtitle = try p.fmt("{d}", .{m.arr(c.at("telemetry.sessions")).len}), .border_color = p.theme().success }, activeSessions); + try c.panel(p, .{ .title = "Process States", .layout = .{ .size = .{ .cells = 34 } }, .border_color = p.theme().primary }, processStates); +} +fn activeSessions(c: *C, p: *P) !void { + const t = p.theme().*; + const data = m.arr(c.at("telemetry.sessions")); + if (data.len == 0) { + try p.label("No interactive sessions."); + try p.label("(`who` reports nothing on this host)"); + return; + } + try r.table(c, p, 0, data, &.{ r.dc("user", "User", 12, 0, t.primary, false), r.dc("tty", "TTY", 10, 0, null, false), r.dc("from", "From", 0, 12, t.accent, false), r.dc("loginAt", "Login", 14, 0, t.muted, false), r.dc("idle", "Idle", 8, 0, null, true) }, false, true, true); +} +fn processStates(c: *C, p: *P) !void { + const t = p.theme().*; + inline for (.{ "running", "sleeping", "stopped", "zombie" }, .{ "run ", "slp ", "stop", "zomb" }, 0..) |key, label, i| try p.meter(.{ .label = label, .value = c.n("telemetry.states." ++ key) / @max(1, c.n("telemetry.states.total")), .text = try r.scalar(p, c.at("telemetry.states." ++ key)), .heat = false, .color = ([_]r.Color{ t.success, t.primary, t.warning, t.danger })[i] }); + try p.spacer(.{ .cells = 1 }); + try r.keys(p, &.{r.kv("Total", try r.scalar(p, c.at("telemetry.states.total")), t.accent)}, true); +} +fn sessionsBottom(c: *C, p: *P) !void { + try c.panel(p, .{ .title = "Recent Logins", .subtitle = try p.fmt("{d} from wtmp", .{m.arr(c.at("telemetry.logins")).len}), .border_color = p.theme().accent }, logins); + try c.col(p, .{ .fr = 0.8 }, 1, failedColumn); +} +fn logins(c: *C, p: *P) !void { + const t = p.theme().*; + const data = m.arr(c.at("telemetry.logins")); + if (data.len == 0) { + try p.label("No login history available."); + return; + } + var cols = [_]r.DC{ r.dc("user", "User", 12, 0, t.primary, false), r.dc("tty", "TTY", 12, 0, t.muted, false), r.dc("from", "From", 0, 14, t.accent, false), r.dc("when", "When", 0, 16, t.muted, false), r.dc("status", "Status", 8, 0, null, false) }; + cols[4].tint = .login; + try r.table(c, p, 1, data, &cols, true, true, true); +} +fn failedColumn(c: *C, p: *P) !void { + try c.panel(p, .{ .title = "Failed Logins", .border_color = p.theme().danger }, failed); + try c.panel(p, .{ .title = "Session History", .layout = .{ .size = .{ .cells = 8 } }, .border_color = p.theme().secondary }, sessionHistory); +} +fn failed(c: *C, p: *P) !void { + const t = p.theme().*; + const data = m.arr(c.at("telemetry.failedLogins")); + if (data.len == 0) { + try p.label("None recorded."); + try p.label("(btmp is usually root-only)"); + return; + } + try r.table(c, p, 2, data, &.{ r.dc("user", "User", 12, 0, t.danger, false), r.dc("from", "From", 0, 12, null, false), r.dc("when", "When", 0, 14, t.muted, false) }, false, true, true); +} +fn sessionHistory(c: *C, p: *P) !void { + try p.label("concurrent sessions"); + try r.plot(p, c.at("telemetry.sessionHistory"), p.theme().success, null, false); +} +fn network(c: *C, p: *P) !void { + try c.row(p, .{ .cells = 13 }, 1, interfaces); + try c.row(p, .{ .fr = 1 }, 1, networkBottom); +} +fn interfaces(c: *C, p: *P) !void { + const t = p.theme().*; + const data = m.arr(c.at("telemetry.interfaces")); + var active: std.ArrayList(m.Value) = .empty; + for (data) |v| { + if (m.num(m.get(v, "rxTotal")) > 0 or m.eq(m.string(m.get(v, "state")), "up")) try active.append(p.ctx.allocator, v); + } + const shown = if (active.items.len > 0) active.items else data; + if (shown.len == 0) { + try c.panel(p, .{ .title = "Interfaces" }, noInterfaces); + return; + } + for (shown[0..@min(3, shown.len)], 0..) |v, i| { + const d = try c.sub(p, v, i); + try d.panel(p, .{ .title = try p.fmt("{s} ({s})", .{ d.str("name"), d.str("state") }), .subtitle = d.str("ip"), .border_color = ([_]r.Color{ t.primary, t.success, t.secondary })[i] }, interfaceBody); + } +} +fn noInterfaces(_: *C, p: *P) !void { + try p.label("No interfaces reported."); +} +fn interfaceRates(c: *C, p: *P) !void { + try r.tx(p, try p.fmt("↓ {s}", .{try r.byteRate(p, c.n("rxRate"))}), p.theme().primary); + try p.text(try p.fmt("↑ {s}", .{try r.byteRate(p, c.n("txRate"))}), .{ .fg = p.theme().secondary, .alignment = .right }); +} +fn interfaceBody(c: *C, p: *P) !void { + const t = p.theme().*; + try c.row(p, .{ .cells = 1 }, 0, interfaceRates); + try r.multi(p, c.at("rxHistory"), c.at("txHistory"), t.primary, t.secondary); + try p.divider(.{}); + try r.keys(p, &.{ r.kv("RX total", try r.bytes(p, c.n("rxTotal"), 2), t.primary), r.kv("TX total", try r.bytes(p, c.n("txTotal"), 2), t.secondary), r.kv("MAC", c.str("mac"), t.muted), r.kv("MTU / err / drop", try p.fmt("{d:.0} / {d:.0} / {d:.0}", .{ c.n("mtu"), c.n("errors"), c.n("drops") }), t.muted) }, true); +} +fn networkBottom(c: *C, p: *P) !void { + try c.panel(p, .{ .title = "Connections", .subtitle = try p.fmt("{d} open", .{m.arr(c.at("telemetry.connections")).len}), .border_color = p.theme().accent }, connections); + try c.col(p, .{ .fr = 0.7 }, 1, listenersColumn); +} +fn connections(c: *C, p: *P) !void { + const t = p.theme().*; + const data = m.arr(c.at("telemetry.connections")); + if (data.len == 0) { + try p.label("No connections visible (`ss` unavailable)."); + return; + } + try r.table(c, p, 0, data, &.{ r.dc("proto", "Proto", 6, 0, t.muted, false), r.dc("local", "Local", 0, 18, null, false), r.dc("remote", "Remote", 0, 18, t.accent, false), r.dc("state", "State", 10, 0, t.success, false), r.dc("process", "Process", 0, 12, t.primary, false) }, true, true, true); +} +fn listenersColumn(c: *C, p: *P) !void { + try c.panel(p, .{ .title = "Listening Ports", .subtitle = try p.fmt("{d}", .{m.arr(c.at("telemetry.listeners")).len}), .border_color = p.theme().warning }, listeners); + try c.panel(p, .{ .title = "Open Connections", .layout = .{ .size = .{ .cells = 6 } }, .border_color = p.theme().secondary }, connectionHistory); +} +fn listeners(c: *C, p: *P) !void { + const t = p.theme().*; + try r.table(c, p, 1, m.arr(c.at("telemetry.listeners")), &.{ r.dc("proto", "Proto", 6, 0, t.muted, false), r.dc("port", "Port", 7, 0, t.warning, true), r.dc("address", "Address", 0, 10, t.muted, false), r.dc("process", "Process", 0, 10, t.primary, false) }, true, true, true); +} +fn connectionHistory(c: *C, p: *P) !void { + try r.plot(p, c.at("telemetry.connectionHistory"), p.theme().accent, null, false); +} +fn services(c: *C, p: *P) !void { + try c.row(p, .{ .fr = 1 }, 1, servicesTop); + try c.panel(p, .{ .title = "Filesystems", .layout = .{ .size = .{ .cells = 10 } }, .border_color = p.theme().secondary }, filesystems); +} +fn servicesTop(c: *C, p: *P) !void { + const t = p.theme().*; + const data = m.arr(c.at("telemetry.services")); + var count: usize = 0; + for (data) |v| { + if (m.eq(m.string(m.get(v, "active")), "failed")) count += 1; + } + try c.panel(p, .{ .title = "Services", .subtitle = if (count > 0) try p.fmt("{d} failed", .{count}) else try p.fmt("{d} units", .{data.len}), .subtitle_color = if (count > 0) t.danger else t.muted, .border_color = if (count > 0) t.danger else t.success }, units); + try c.col(p, .{ .fr = 0.85 }, 1, hardwareColumn); +} +fn units(c: *C, p: *P) !void { + const t = p.theme().*; + const data = m.arr(c.at("telemetry.services")); + if (data.len == 0) { + try p.label("systemd not available on this host."); + return; + } + var cols = [_]r.DC{ r.dc("name", "Unit", 0, 18, t.primary, false), r.dc("active", "Active", 10, 0, null, false), r.dc("sub", "Sub", 10, 0, t.muted, false), r.dc("description", "Description", 0, 16, t.muted, false) }; + cols[1].tint = .service; + try r.table(c, p, 0, data, &cols, true, true, true); +} +fn hardwareColumn(c: *C, p: *P) !void { + const t = p.theme().*; + try c.panel(p, .{ .title = "Kernel", .layout = .{ .size = .{ .cells = 11 } }, .border_color = t.accent }, kernel); + try c.panel(p, .{ .title = "Containers", .layout = .{ .size = .{ .cells = 9 } }, .border_color = t.primary }, containers); + try c.panel(p, .{ .title = "Hardware", .border_color = t.warning }, hardware); +} +fn krate(p: *P, v: f64) ![]const u8 { + return if (v >= 1000) p.fmt("{d:.1}K/s", .{v / 1000}) else p.fmt("{d:.0}/s", .{v}); +} +fn kernel(c: *C, p: *P) !void { + const t = p.theme().*; + try r.keys(p, &.{ r.kv("Context switches", try krate(p, c.n("telemetry.kernel.contextSwitchRate")), t.accent), r.kv("Interrupts", try krate(p, c.n("telemetry.kernel.interruptRate")), t.accent), r.kv("Forks", try krate(p, c.n("telemetry.kernel.forkRate")), t.accent), r.kv("Procs running", try r.scalar(p, c.at("telemetry.kernel.procsRunning")), t.success), r.kv("Procs blocked", try r.scalar(p, c.at("telemetry.kernel.procsBlocked")), if (c.n("telemetry.kernel.procsBlocked") != 0) t.warning else t.muted), r.kv("Open file descriptors", try commas(p, c.n("telemetry.kernel.openFiles")), t.primary), r.kv("Entropy available", try r.scalar(p, c.at("telemetry.kernel.entropy")), if (c.n("telemetry.kernel.entropy") < 200) t.warning else t.success), r.kv("Page in / out", try p.fmt("{d:.0}K / {d:.0}K", .{ c.n("telemetry.kernel.pageIn") / 1000, c.n("telemetry.kernel.pageOut") / 1000 }), t.muted) }, true); +} +fn containers(c: *C, p: *P) !void { + const t = p.theme().*; + const data = m.arr(c.at("telemetry.containers")); + if (data.len == 0) { + try p.label("No running containers."); + try p.label("(docker not installed or not reachable)"); + return; + } + try r.table(c, p, 1, data, &.{ r.dc("name", "Name", 0, 12, t.primary, false), r.dc("image", "Image", 0, 14, t.muted, false), r.dc("status", "Status", 0, 12, t.success, false) }, true, true, true); +} +fn hardware(c: *C, p: *P) !void { + const t = p.theme().*; + var rows: std.ArrayList(h.widgets.KeyValueRow) = .empty; + const a = p.ctx.allocator; + if (c.at("telemetry.power") != .null) { + try rows.appendSlice(a, &.{ r.kv("Battery", try p.fmt("{s}% ({s})", .{ try r.scalar(p, c.at("telemetry.power.battery")), c.str("telemetry.power.timeRemaining") }), t.success), r.kv("AC", if (c.at("telemetry.power.acConnected") == .bool and c.at("telemetry.power.acConnected").bool) "connected" else "on battery", t.muted), r.kv("Draw", if (c.at("telemetry.power.powerDraw") == .null) "—" else try p.fmt("{d:.1} W", .{c.n("telemetry.power.powerDraw")}), t.warning) }); + } + for (m.arr(c.at("telemetry.gpus"))) |v| { + try rows.appendSlice(a, &.{ r.kv(m.string(m.get(v, "name")), try p.fmt("{s} · {s}°C", .{ if (m.get(v, "utilization") == .null) "—" else try r.pct(p, m.num(m.get(v, "utilization"))), try r.scalar(p, m.get(v, "temperature")) }), t.accent), r.kv("GPU memory", try p.fmt("{s} / {s}", .{ try r.bytes(p, m.num(m.get(v, "memoryUsed")), 2), try r.bytes(p, m.num(m.get(v, "memoryTotal")), 2) }), t.muted) }); + } + if (rows.items.len == 0) { + try p.label("No battery or GPU telemetry on this host."); + return; + } + try r.keys(p, rows.items, true); +} +fn filesystems(c: *C, p: *P) !void { + const t = p.theme().*; + const data = m.arr(c.at("telemetry.filesystems")); + if (data.len == 0) { + try p.label("No filesystems reported."); + return; + } + var cols = [_]r.DC{ r.dc("mount", "Mount", 0, 14, t.primary, false), r.dc("device", "Device", 0, 12, t.muted, false), r.dc("type", "Type", 8, 0, t.muted, false), r.dc("size", "Size", 10, 0, null, true), r.dc("used", "Used", 10, 0, null, true), r.dc("pct", "Use%", 6, 0, null, true), r.dc("inodes", "Inodes", 16, 0, t.muted, true) }; + cols[3].format = .bytes0; + cols[4].format = .bytes0; + cols[5].format = .pct; + cols[5].tint = .usage; + cols[6].format = .inodes; + try r.table(c, p, 2, data, &cols, true, true, true); +} diff --git a/ports/zig/demo/tests.zig b/ports/zig/demo/tests.zig index bd9a751..1a69629 100644 --- a/ports/zig/demo/tests.zig +++ b/ports/zig/demo/tests.zig @@ -6,6 +6,45 @@ const cli = @import("main.zig"); const collect = @import("collect.zig"); const sensors = @import("sensors.zig"); const traffic = @import("traffic.zig"); +test "native screens match TypeScript reference cells" { + var arena = std.heap.ArenaAllocator.init(std.testing.allocator); + defer arena.deinit(); + const a = arena.allocator(); + const raw = try std.Io.Dir.cwd().readFileAlloc(std.testing.io, @import("build_options").demo_parity, a, .limited(8 * 1024 * 1024)); + const cases = try std.json.parseFromSliceLeaky(m.Value, a, raw, .{}); + for (m.arr(cases)) |case| { + const screen = m.index(&m.screens, m.string(m.get(case, "screen"))) orelse 0; + var state = try m.State.init(std.testing.allocator, false, 1337); + defer state.deinit(); + state.parsed.value = try std.json.parseFromSliceLeaky(m.Value, state.allocator(), @embedFile("sample.json"), .{}); + const width: usize = @intFromFloat(m.num(m.get(case, "width"))); + const height: usize = @intFromFloat(m.num(m.get(case, "height"))); + const theme = m.string(m.get(case, "theme")); + state.theme = m.index(&m.themes, theme) orelse 0; + state.screen = screen; + var frame = try h.renderToScreen(std.testing.allocator, width, height, theme, if (screen == 0) h.Body.with(&state, @import("dashboard.zig").render) else if (screen <= 4) h.Body.with(&state, @import("telemetry_view.zig").render) else h.Body.with(&state, @import("showcase.zig").render)); + defer frame.deinit(); + for (0..height) |y| { + var hash: u32 = 2166136261; + for (0..width) |x| { + const cell = frame.cell(x, y); + for (cell.text) |b| hash = (hash ^ b) *% 16777619; + hash = (hash ^ 0) *% 16777619; + for ([_]u32{ @intFromEnum(cell.fg), @intFromEnum(cell.bg), cell.attrs.bits() }) |v| { + for (0..4) |i| { + const shift: u5 = @intCast(i * 8); + hash = (hash ^ @as(u8, @truncate(v >> shift))) *% 16777619; + } + } + } + const expected: u32 = @intFromFloat(m.num(m.arr(m.get(case, "hashes"))[y])); + if (hash != expected) { + std.debug.print("dashboard {d}x{d}/{s} row {d}: {s}\n", .{ width, height, theme, y, try frame.line(y) }); + try std.testing.expectEqual(expected, hash); + } + } + } +} test "Linux procfs readers do not treat zero stat size as empty" { if (@import("builtin").os.tag != .linux) return; var arena = std.heap.ArenaAllocator.init(std.testing.allocator); @@ -14,6 +53,35 @@ test "Linux procfs readers do not treat zero stat size as empty" { try std.testing.expect(std.mem.indexOf(u8, raw, "MemTotal:") != null); } +test "HTTP paths use final viewport and logs scroll backwards from tail" { + var state = try m.State.init(std.testing.allocator, false, 1337); + defer state.deinit(); + state.screen = 1; + const paths = m.ptr(&state.parsed.value, "telemetry.http.topPaths"); + paths.array.clearRetainingCapacity(); + for (0..50) |i| { + const text = try std.fmt.allocPrint(state.allocator(), "{{\"path\":\"/route-{d:0>3}\",\"count\":{d}}}", .{ i, i }); + try paths.array.append(try std.json.parseFromSliceLeaky(m.Value, state.allocator(), text, .{})); + } + state.panes[8].selected = 49; + var frame = try h.renderToScreen(std.testing.allocator, 200, 60, "dark", h.Body.with(&state, view.render)); + defer frame.deinit(); + try std.testing.expect(frame.contains("/route-049")); + var found = false; + for (frame.regions) |region| { + if (m.eq(region.id, "pane:8")) { + found = true; + try std.testing.expectEqual(@as(usize, 50) - region.rect.height, state.panes[8].offset); + } + } + try std.testing.expect(found); + var pane = m.Pane{ .log = true, .total = 50 }; + pane.move(-3); + try std.testing.expectEqual(@as(usize, 3), pane.offset); + pane.move(100); + try std.testing.expectEqual(@as(usize, 0), pane.offset); +} + test "Traffic and Sessions use shared real-source parsers and render rows" { var arena = std.heap.ArenaAllocator.init(std.testing.allocator); defer arena.deinit(); @@ -141,14 +209,15 @@ test "shared sensor sources refresh and render" { test "all ten screens, all themes, small and large terminals" { var state = try m.State.init(std.testing.allocator, false, 1337); defer state.deinit(); - for (m.screens, 0..) |name, i| { + for (m.screens, 0..) |_, i| { state.screen = i; for (m.themes, 0..) |theme, j| { state.theme = j; var screen = try h.renderToScreen(std.testing.allocator, 120, 40, theme, h.Body.with(&state, view.render)); defer screen.deinit(); try std.testing.expect(screen.contains("hqtui")); - try std.testing.expect(screen.contains(name)); + const title = ([_][]const u8{ "CPU Overview", "Protocols", "Active Sessions", "Connections", "Filesystems", "Buttons & Inputs", "Braille (2×4", "Theme ", "Last Events", "Full-screen churn" })[i]; + try std.testing.expect(screen.contains(title)); } for ([_][2]usize{ .{ 1, 1 }, .{ 20, 5 }, .{ 40, 12 }, .{ 80, 24 }, .{ 160, 55 } }) |size| { var screen = try h.renderToScreen(std.testing.allocator, size[0], size[1], "dark", h.Body.with(&state, view.render)); diff --git a/ports/zig/demo/view.zig b/ports/zig/demo/view.zig index b605d70..6fb0ee1 100644 --- a/ports/zig/demo/view.zig +++ b/ports/zig/demo/view.zig @@ -1,46 +1,14 @@ const std = @import("std"); const h = @import("hqtui"); const m = @import("model.zig"); +const ref = @import("reference.zig"); const State = m.State; const Value = m.Value; const Container = h.Container; -const Col = struct { []const u8, []const u8 }; -const Context = struct { state: *State, index: usize }; fn val(s: *State, path: []const u8) Value { return m.at(s.data(), path); } -fn graph(p: *Container, v: Value, label: []const u8, other: ?Value) !void { - const a = p.ctx.allocator; - const series = try a.alloc(h.graphics.Series, if (other != null) 2 else 1); - series[0] = .{ .values = try m.numbers(a, v), .label = label }; - if (other) |v2| series[1] = .{ .values = try m.numbers(a, v2), .label = "out" }; - try p.graph(.{ .series = series, .axis = true, .legend = label.len > 0, .plot = .{ .fill = true } }); -} -fn bytes(p: *Container, value: f64) ![]const u8 { - var v = value; - for ([_][]const u8{ "B", "KiB", "MiB", "GiB", "TiB" }) |unit| { - if (@abs(v) < 1024 or m.eq(unit, "TiB")) return p.fmt("{d:.1} {s}", .{ v, unit }); - v /= 1024; - } - return ""; -} -fn table(ctx: *Context, p: *Container, data: []const Value, columns: []const Col, fixed: usize) !void { - const a = p.ctx.allocator; - const pane = &ctx.state.panes[ctx.state.screen * 8 + ctx.index]; - pane.total = data.len; - pane.move(0); - pane.offset = h.widgets.resolveOffset(pane.offset, pane.selected, p.height() -| (fixed + 1), data.len, true); - const rows = try a.alloc(h.widgets.TableRow, data.len); - const cols = try a.alloc(h.widgets.TableColumn, columns.len); - for (columns, 0..) |c, i| cols[i] = .{ .title = c[1] }; - for (data, 0..) |row, i| { - const cells = try a.alloc([]const u8, columns.len); - for (columns, 0..) |c, j| cells[j] = try m.display(a, m.get(row, c[0])); - rows[i] = .{ .cells = cells }; - } - try p.table(.{ .rows = rows, .columns = cols, .selected = pane.selected, .offset = pane.offset, .follow_selection = true, .zebra = true, .scrollbar = true }, try p.fmt("pane:{d}", .{ctx.state.screen * 8 + ctx.index})); -} -fn sortedProcesses(s: *State, a: std.mem.Allocator) ![]Value { +pub fn sortedProcesses(s: *State, a: std.mem.Allocator) ![]Value { var list: std.ArrayList(Value) = .empty; for (m.arr(val(s, "processes"))) |row| { if (m.contains(m.string(m.get(row, "name")), s.filter.slice()) or m.contains(m.string(m.get(row, "command")), s.filter.slice())) try list.append(a, row); @@ -55,233 +23,36 @@ fn sortedProcesses(s: *State, a: std.mem.Allocator) ![]Value { std.mem.sort(Value, list.items, s.sort, Sort.less); return list.toOwnedSlice(a); } -fn dashboard(ctx: *Context, p: *Container) !void { - const s = ctx.state; - const a = p.ctx.allocator; - switch (ctx.index) { - 0 => { - try p.label(try p.fmt("{s} {d:.1} GHz", .{ m.string(val(s, "cpu.model")), m.num(val(s, "cpu.frequencyGhz")) })); - try graph(p, val(s, "cpu.history"), "CPU %", null); - const items = try a.alloc(h.widgets.MeterItem, m.arr(val(s, "cpu.cores")).len); - for (m.arr(val(s, "cpu.cores")), 0..) |v, i| items[i] = .{ .label = try p.fmt("P{d}", .{i}), .value = m.num(v) }; - try p.meters(.{ .items = items, .columns = if (p.width() > 35) 2 else 1 }); - }, - 1 => { - try p.label(try p.fmt("F3 filter: {s} F6 sort: {s}", .{ s.filter.slice(), ([_][]const u8{ "cpu", "mem", "pid", "name" })[s.sort] })); - try table(ctx, p, try sortedProcesses(s, a), &.{ .{ "pid", "PID" }, .{ "name", "Name" }, .{ "cpu", "CPU%" }, .{ "mem", "MEM%" }, .{ "threads", "THR" }, .{ "state", "S" }, .{ "user", "User" }, .{ "command", "Command" } }, 1); - }, - 2 => { - try p.meter(.{ .label = "RAM", .value = m.num(val(s, "memory.used")), .max = @max(1, m.num(val(s, "memory.total"))) }); - inline for (.{ "used", "available", "cached", "buffers", "free" }) |key| try p.label(try p.fmt("{s: <12} {s}", .{ key, try bytes(p, m.num(val(s, "memory." ++ key))) })); - try p.meter(.{ .label = "Swap", .value = m.num(val(s, "memory.swapUsed")), .max = @max(1, m.num(val(s, "memory.swapTotal"))) }); - try graph(p, val(s, "memory.history"), "", null); - }, - 3 => { - try p.label(try p.fmt("Down {s}/s Up {s}/s", .{ try bytes(p, m.num(val(s, "network.downRate"))), try bytes(p, m.num(val(s, "network.upRate"))) })); - try graph(p, val(s, "network.downHistory"), "down", val(s, "network.upHistory")); - try p.label(try p.fmt("Received {s}", .{try bytes(p, m.num(val(s, "network.downTotal")))})); - try p.label(try p.fmt("Sent {s}", .{try bytes(p, m.num(val(s, "network.upTotal")))})); - }, - 4 => { - const disks = m.arr(val(s, "disks")); - for (disks[0..@min(2, disks.len)]) |d| { - try p.label(try p.fmt("{s} {s}", .{ m.string(m.get(d, "device")), m.string(m.get(d, "mount")) })); - try p.meter(.{ .label = "Used", .value = m.num(m.get(d, "used")), .max = @max(1, m.num(m.get(d, "total"))) }); - try graph(p, m.get(d, "readHistory"), "read", m.get(d, "writeHistory")); - } - }, - 5 => { - inline for (.{ "hostname", "os", "kernel", "shell", "processCount", "threadCount", "uptime" }) |key| try p.label(try p.fmt("{s: <12} {s}", .{ key, try m.display(a, val(s, "system." ++ key)) })); - try graph(p, val(s, "cpu.history"), "CPU History", null); - }, - 6 => { - const temps = m.arr(val(s, "temperatures")); - if (temps.len == 0) try p.label("No thermal sensors available"); - for (temps) |t| try p.meter(.{ .label = m.string(m.get(t, "label")), .value = m.num(m.get(t, "value")), .max = 100, .text = try p.fmt("{d:.0}°C", .{m.num(m.get(t, "value"))}) }); - }, - 8 => { - const readings = m.arr(val(s, "sensors")); - if (readings.len == 0) try p.label("No sensor readings available"); - for (readings) |sensor| try p.label(try p.fmt("{s}: {s}", .{ m.string(m.get(sensor, "label")), m.string(m.get(sensor, "value")) })); - }, - else => try table(ctx, p, m.arr(val(s, "logs")), &.{ .{ "time", "Time" }, .{ "level", "Level" }, .{ "message", "Message" } }, 0), - } -} -fn telemetry(ctx: *Context, p: *Container) !void { - const s = ctx.state; + +pub fn body(s: *State, p: *Container) anyerror!void { switch (s.screen) { - 1 => switch (ctx.index) { - 0 => try table(ctx, p, m.arr(val(s, "telemetry.protocols")), &.{ .{ "protocol", "Protocol" }, .{ "inbound", "In" }, .{ "outbound", "Out" }, .{ "total", "Total" } }, 0), - 1 => try graph(p, val(s, "telemetry.netInHistory"), "in", val(s, "telemetry.netOutHistory")), - 2 => try graph(p, val(s, "telemetry.retransHistory"), "Retransmits", null), - 3 => { - if (val(s, "telemetry.http") == .null) { - try p.label("HTTP access log unavailable (read-only)"); - } else { - try table(ctx, p, m.arr(val(s, "telemetry.http.recent")), &.{ .{ "time", "Time" }, .{ "method", "Method" }, .{ "path", "Path" }, .{ "status", "Status" }, .{ "client", "Client" } }, 0); - } - }, - 4 => try table(ctx, p, m.arr(val(s, "telemetry.remotes")), &.{ .{ "host", "Host" }, .{ "connections", "Connections" }, .{ "protocols", "Protocols" } }, 0), - else => try table(ctx, p, m.arr(val(s, "telemetry.ssh")), &.{ .{ "time", "Time" }, .{ "action", "Action" }, .{ "user", "User" }, .{ "from", "From" } }, 0), - }, - 2 => switch (ctx.index) { - 0 => try table(ctx, p, m.arr(val(s, "telemetry.sessions")), &.{ .{ "user", "User" }, .{ "tty", "TTY" }, .{ "from", "From" }, .{ "idle", "Idle" }, .{ "what", "Command" } }, 0), - 1 => try table(ctx, p, m.arr(val(s, "telemetry.logins")), &.{ .{ "user", "User" }, .{ "tty", "TTY" }, .{ "from", "From" }, .{ "when", "When" }, .{ "status", "Status" } }, 0), - 2 => try table(ctx, p, m.arr(val(s, "telemetry.ssh")), &.{ .{ "time", "Time" }, .{ "action", "Action" }, .{ "user", "User" }, .{ "from", "From" } }, 0), - 4 => try table(ctx, p, m.arr(val(s, "telemetry.failedLogins")), &.{ .{ "user", "User" }, .{ "tty", "TTY" }, .{ "from", "From" }, .{ "when", "When" }, .{ "status", "Status" } }, 0), - else => try graph(p, val(s, "telemetry.sessionHistory"), "Sessions", null), - }, - 3 => switch (ctx.index) { - 0 => try table(ctx, p, m.arr(val(s, "telemetry.interfaces")), &.{ .{ "name", "Name" }, .{ "state", "State" }, .{ "rxRate", "RX B/s" }, .{ "txRate", "TX B/s" }, .{ "errors", "Errors" }, .{ "drops", "Drops" } }, 0), - 1 => try table(ctx, p, m.arr(val(s, "telemetry.connections")), &.{ .{ "proto", "Proto" }, .{ "state", "State" }, .{ "local", "Local" }, .{ "remote", "Remote" }, .{ "process", "Process" } }, 0), - 2 => try table(ctx, p, m.arr(val(s, "telemetry.listeners")), &.{ .{ "proto", "Proto" }, .{ "address", "Address" }, .{ "port", "Port" }, .{ "process", "Process" } }, 0), - else => try graph(p, val(s, "telemetry.connectionHistory"), "Connections", null), - }, - else => switch (ctx.index) { - 0 => try table(ctx, p, m.arr(val(s, "telemetry.services")), &.{ .{ "name", "Unit" }, .{ "active", "State" }, .{ "description", "Description" } }, 0), - 1 => try table(ctx, p, m.arr(val(s, "telemetry.filesystems")), &.{ .{ "mount", "Mount" }, .{ "device", "Device" }, .{ "type", "Type" }, .{ "used", "Used B" }, .{ "size", "Size B" } }, 0), - 2 => { - inline for (.{ "contextSwitchRate", "interruptRate", "forkRate", "procsRunning", "procsBlocked", "entropy", "openFiles" }) |key| try p.label(try p.fmt("{s} {s}", .{ key, try m.display(p.ctx.allocator, val(s, "telemetry.kernel." ++ key)) })); - }, - else => try table(ctx, p, m.arr(val(s, "telemetry.journal")), &.{ .{ "time", "Time" }, .{ "level", "Level" }, .{ "unit", "Unit" }, .{ "message", "Message" } }, 0), - }, - } -} -fn canvas(ctx: *Context, c: *h.graphics.BrailleCanvas) !void { - for (0..c.width) |x| { - c.pixel(@floatFromInt(x), (0.5 + 0.35 * @sin(@as(f64, @floatFromInt(x)) / 12 + m.num(val(ctx.state, "time")))) * @as(f64, @floatFromInt(c.height -| 1))); - } -} -fn body(ctx: *Context, p: *Container) anyerror!void { - const s = ctx.state; - switch (s.screen) { - 0 => try dashboard(ctx, p), - 1...4 => try telemetry(ctx, p), - 5 => switch (ctx.index) { - 0 => { - try p.label("Click controls; e edits text; Esc finishes"); - for ([_][]const u8{ "Primary", "Success", "Warning", "Danger", "Ghost" }) |name| try p.button(.{ .label = name }, try p.fmt("button:{s}", .{name})); - try p.checkbox(.{ .label = "Notifications", .checked = s.checked }, "check"); - try p.checkbox(.{ .label = "Live updates", .checked = s.toggle, .variant = .toggle }, "toggle"); - try p.select(.{ .value = m.themes[s.select_index], .options = &m.themes, .selected_index = s.select_index, .open = s.select_open }, "select"); - try p.textInput(.{ .value = s.input.slice(), .focused = s.editing, .placeholder = "Type here" }, "input"); - }, - 1 => { - for (0..8) |i| try p.meter(.{ .value = @as(f64, @floatFromInt(i + 1)) / 8, .label = try p.fmt("Meter {d}", .{i + 1}) }); - try p.gauge(.{ .value = m.num(val(s, "cpu.total")), .label = "CPU" }); - }, - else => { - try p.heading("Typography & Unicode"); - try p.label("日本語 中文 한국어 • café • 🚀"); - for ([_][]const u8{ "READY", "WARNING", "LIVE" }) |name| try p.badge(.{ .text = name }); - try p.button(.{ .label = "Command palette" }, "palette"); - try graph(p, val(s, "cpu.history"), "History", null); - }, - }, - 6 => switch (ctx.index) { - 0 => try p.canvas(null, h.ui.CanvasBody.with(ctx, canvas)), - 1 => try graph(p, val(s, "cpu.history"), "CPU %", null), - 2 => try graph(p, val(s, "network.downHistory"), "down", val(s, "network.upHistory")), - else => try p.gauge(.{ .value = m.num(val(s, "cpu.total")), .label = "CPU" }), - }, - 7 => { - if (ctx.index == 0) { - try p.label("F2 or Left/Right to switch themes"); - for (m.themes, 0..) |name, i| try p.button(.{ .label = name }, try p.fmt("theme:{d}", .{i})); - } else { - try p.heading(m.themes[s.theme]); - inline for (.{ "primary", "secondary", "accent", "success", "warning", "danger", "muted" }) |name| try p.meter(.{ .value = 0.7, .label = name, .color = @field(p.theme(), name) }); - try graph(p, val(s, "cpu.history"), "Preview", null); - } - }, - 8 => { - if (ctx.index == 0) { - try p.label(try p.fmt("Last key: {s}", .{s.last_key.slice()})); - try p.label(try p.fmt("Mouse: {s}", .{s.last_mouse.slice()})); - try p.label("e to edit; Esc exits text input"); - try p.textInput(.{ .value = s.input.slice(), .focused = s.editing }, "input"); - const rows = try p.ctx.allocator.alloc(h.widgets.TableRow, s.key_count); - for (rows, 0..) |*row, i| { - const cells = try p.ctx.allocator.alloc([]const u8, 1); - cells[0] = s.key_log[i][0..s.key_lens[i]]; - row.* = .{ .cells = cells }; - } - try p.table(.{ .rows = rows, .columns = &.{.{ .title = "Key Events" }}, .scrollbar = true }, "input.events"); - } else { - try p.label("Renderer: native Zig"); - try p.label("Arrows scroll the focused pane"); - try p.label("Ctrl+K palette · F1 help · q quit"); - } - }, - else => { - try p.meter(.{ .label = "Load", .value = (@sin(m.num(val(s, "time")) + @as(f64, @floatFromInt(ctx.index))) + 1) / 2 }); - try graph(p, val(s, "cpu.history"), "", null); - }, - } -} -fn titles(s: *State, wide: bool) []const []const u8 { - return switch (s.screen) { - 0 => if (wide) &.{ "CPU Overview", "Processes", "Memory & Swap", "Network", "Disks", "System", "Temperatures", "Logs", "Sensors" } else &.{ "CPU Overview", "Processes", "Memory & Swap", "Network" }, - 1 => &.{ "Protocols", "TCP Segments", "Retransmits", "HTTP", "Remote Hosts", "SSH Authentication" }, - 2 => &.{ "Active Sessions", "Login History", "SSH Authentication", "Session History", "Failed Logins" }, - 3 => &.{ "Interfaces", "Connections", "Listeners", "Connection History" }, - 4 => &.{ "Services", "Filesystems", "Kernel", "Journal" }, - 5 => &.{ "Controls", "Meters & Gauge", "Text & Badges" }, - 6 => &.{ "Braille Canvas", "CPU Plot", "Network Plot", "Gauge" }, - 7 => &.{ "Built-in Themes", "Live Preview" }, - 8 => &.{ "Input Inspector", "Diagnostics" }, - else => &.{ "Stress 01", "Stress 02", "Stress 03", "Stress 04", "Stress 05", "Stress 06", "Stress 07", "Stress 08", "Stress 09", "Stress 10", "Stress 11", "Stress 12" }, - }; -} -const GridContext = struct { state: *State, names: []const []const u8, allocator: std.mem.Allocator }; -fn gridCells(ctx: *GridContext, g: *h.Grid) anyerror!void { - for (ctx.names, 0..) |name, i| { - const c = try ctx.allocator.create(Context); - c.* = .{ .state = ctx.state, .index = i }; - try g.panel(.{ .title = name }, .{}, h.Body.with(c, body)); - } -} -fn header(s: *State, r: *Container) anyerror!void { - try r.heading("hqtui — zig"); - try r.spacer(.fill); - try r.label(try r.fmt("{s} {s}", .{ if (s.real) "REAL" else "SIMULATED", if (s.paused) "PAUSED" else "LIVE" })); -} -pub fn render(s: *State, ui: *Container) anyerror!void { - try ui.row(.{ .layout = .{ .size = .{ .cells = 1 } } }, h.Body.with(s, header)); - const groups: usize = if (ui.width() < 150) 2 else 1; - for (0..groups) |group| { - const base = group * 5; - const count: usize = if (groups == 2) 5 else 10; - const tabs = try ui.ctx.allocator.alloc([]const u8, count); - for (tabs, 0..) |*tab, i| tab.* = try ui.fmt("{d} {s}", .{ (base + i + 1) % 10, m.screens[base + i] }); - try ui.tabs(.{ .tabs = tabs, .active = if (s.screen >= base and s.screen < base + count) s.screen - base else std.math.maxInt(usize) }, try ui.fmt("tabs{d}", .{base})); - } - if (ui.width() < 30 or ui.height() < 12) { - try ui.label("Terminal too small; resize to 30×12"); - try ui.spacer(.fill); - } else { - if (s.real and s.screen == 1) try ui.label("Protocol/direction: port-based estimates; HTTP rate: estimated from log growth"); - const names = titles(s, ui.width() >= 90 and ui.height() >= 50); - const cols: usize = if (ui.width() < 45) 1 else if (s.screen == 9 and ui.width() >= 120) 4 else if (s.screen == 5 or s.screen == 9 or (s.screen == 1 and ui.width() >= 120) or (s.screen == 0 and ui.width() >= 160)) 3 else 2; - const columns = try ui.ctx.allocator.alloc(h.layout.Size, cols); - @memset(columns, .{ .fr = 1 }); - const rows = try ui.ctx.allocator.alloc(h.layout.Size, (names.len + cols - 1) / cols); - @memset(rows, .{ .fr = 1 }); - const ctx = try ui.ctx.allocator.create(GridContext); - ctx.* = .{ .state = s, .names = names, .allocator = ui.ctx.allocator }; - try ui.grid(.{ .columns = columns, .rows = rows, .layout = .{ .gap = 1 } }, h.GridBody.with(ctx, gridCells)); - } - if (s.missing.len > 0) try ui.label(try ui.fmt("Unavailable: {s}", .{s.missing})); - const right = try ui.ctx.allocator.alloc(h.widgets.StatusItem, 1); - right[0] = .{ .label = try ui.fmt("{s} · {s}", .{ m.screens[s.screen], m.themes[s.theme] }) }; - try ui.statusBar(.{ .items = &.{.{ .label = "F1 help F2 theme F3 filter ^K palette q quit" }}, .right = right }); - if (s.filtering) ui.modal(.{ .title = "Filter Processes", .message = try ui.fmt("{s}▏\nEnter applies · Esc clears", .{s.filter.slice()}), .width = 60 }); - if (s.help) ui.modal(.{ .title = "hqtui — Help", .width = 66, .message = "1–9/0 / Tab: screen\nF2 theme · F3 filter · F6 sort\nCtrl+K palette · Space pause\nArrows / PgUp / PgDn / Home / End scroll\nMouse tabs, controls, selection and wheel\ne edits text · Esc finishes\nq / Ctrl+C quit · Any key closes help" }); - if (s.modal) ui.modal(.{ .title = "Read-only Demo", .message = "No process will be killed and no service changed.\nPress any key to close.", .width = 58 }); + 0 => try @import("dashboard.zig").render(s, p), + 1...4 => try @import("telemetry_view.zig").render(s, p), + else => try @import("showcase.zig").render(s, p), + } +} +fn header(s: *State, p: *Container) anyerror!void { + const t = p.theme().*; + try ref.sizedText(p, " hqtui.com", .{ .fg = t.title, .bold = true }, 12); + const tabs = try p.ctx.allocator.alloc([]const u8, 10); + for (tabs, 0..) |*tab, i| tab.* = try p.fmt("{d} {s}", .{ (i + 1) % 10, m.screens[i] }); + try p.tabs(.{ .tabs = tabs, .active = s.screen }, "tabs0"); + try p.text(try p.fmt("{s} {s} {d:.0}fps {s} ", .{ if (s.paused) "paused" else "live", if (s.real) "real" else "simulated", s.fps, s.clock }), .{ .fg = if (s.paused) t.warning else t.success, .alignment = .right }); +} +pub fn render(s: *State, p: *Container) anyerror!void { + try p.row(.{ .layout = .{ .size = .{ .cells = 1 } } }, h.Body.with(s, header)); + try p.spacer(.{ .cells = 1 }); + try p.column(.{ .layout = .{ .size = .{ .cells = @intCast(p.height() -| 4) } } }, h.Body.with(s, body)); + try p.spacer(.{ .cells = 1 }); + const items = try p.ctx.allocator.dupe(h.widgets.StatusItem, &.{ .{ .key = "F1", .label = "Help" }, .{ .key = "F2", .label = try p.fmt("Theme ({s})", .{p.theme().name}) }, .{ .key = "F3", .label = if (s.filtering) try p.fmt("Filter: {s}_", .{s.filter.slice()}) else "Filter", .active = s.filtering }, .{ .key = "F6", .label = try p.fmt("Sort: {s}", .{([_][]const u8{ "cpu", "mem", "pid", "name" })[s.sort]}) }, .{ .key = "^K", .label = "Palette" }, .{ .key = "Tab", .label = "Screen" }, .{ .key = "q", .label = "Quit" } }); + const right = try p.ctx.allocator.dupe(h.widgets.StatusItem, &.{.{ .label = try p.fmt("{d:.2}ms {d} cells {d}B", .{ s.render_ms, s.changed_cells, s.output_bytes }) }}); + try p.statusBar(.{ .items = items, .right = right }); + if (s.help) p.modal(.{ .title = "hqtui — Help", .message = "1–9/0 / Tab: screen\nF2 theme · F3 filter · F6 sort\nCtrl+K palette · Space pause\nArrows / PgUp / PgDn / Home / End: scroll\nMouse tabs, controls, selection and wheel\ne edits text · Esc finishes\nq / Ctrl+C quit · Any key closes help" }); + if (s.modal) p.modal(.{ .title = "Read-only Demo", .message = "No process will be killed and no service changed.\nPress any key to close." }); if (s.palette) { - const matches = try s.commands(ui.ctx.allocator); - const items = try ui.ctx.allocator.alloc(h.widgets.PaletteItem, matches.len); - for (matches, 0..) |name, i| items[i] = .{ .label = name }; - ui.commandPalette(.{ .query = s.query.slice(), .items = items, .selected = s.palette_index, .placeholder = "Search commands" }); + const matches = try s.commands(p.ctx.allocator); + const options = try p.ctx.allocator.alloc(h.widgets.PaletteItem, matches.len); + for (matches, 0..) |name, i| options[i] = .{ .label = name }; + p.commandPalette(.{ .query = s.query.slice(), .items = options, .selected = s.palette_index }); } } diff --git a/ports/zig/src/widgets/meters.zig b/ports/zig/src/widgets/meters.zig index 6a82023..8abbe1e 100644 --- a/ports/zig/src/widgets/meters.zig +++ b/ports/zig/src/widgets/meters.zig @@ -246,6 +246,7 @@ pub const GraphOptions = struct { plot: PlotOptions = .{}, /// Draw min/max labels down the left edge. axis: bool = false, + axis_format: ?*const fn ([]u8, f64) []const u8 = null, axis_color: ?Color = null, /// Time labels along the bottom, e.g. `.{ "60s", "30s", "0s" }`. time_axis: []const []const u8 = &.{}, @@ -286,8 +287,8 @@ pub fn drawGraph(allocator: std.mem.Allocator, s: Surface, options: GraphOptions var max_buf: [32]u8 = undefined; var min_buf: [32]u8 = undefined; - const max_label = niceLabel(&max_buf, maximum); - const min_label = niceLabel(&min_buf, minimum); + const max_label = if (options.axis_format) |format| format(&max_buf, maximum) else niceLabel(&max_buf, maximum); + const min_label = if (options.axis_format) |format| format(&min_buf, minimum) else niceLabel(&min_buf, minimum); const label_width = @max( unicode.stringWidth(max_label), unicode.stringWidth(min_label),