diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..d006c4e --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,60 @@ +# Working in Systemdoc + +This is a Go terminal application. Start with the files for the feature being +changed; the dashboard package is large, but a task rarely needs all of it. +Use `rg` to follow specific symbols and read individual functions. Avoid dumping +the entire package, all tests, screenshots, or the changelog into context. + +## Where to look + +All paths below are relative to `internal/dashboard/` unless stated otherwise. + +| Task | Starting points | +| --- | --- | +| CLI flags and startup | `cmd/systemdoc/main.go` (repository root), `ui.go` | +| Inventory and refresh | `data.go`, `refresh.go`, `detail_cache.go`, `metrics.go` | +| Navigation and layout | `dashboard.go`, `ui.go`, `control_deck.go`, `chooser.go` | +| Service backends | `data.go`, `unit_names.go`, `service_states_test.go`, `launchd*.go` | +| Docker inspection | `internal/dockerinspect/` (repository root); `data.go` routes inspector tabs | +| Compose | `compose.go`, `compose_draft.go`, `data.go` | +| Kubernetes | `kube.go`, `kube_overview.go`, `kube*_test.go`, `testdata/kube-*.json` | +| Host processes and storage | `host_data.go`, `host_ui.go`, `host_mounts_*.go` | +| Host CPU and memory | `host_metrics.go`, `host_cpu*.go`, `host_cores_ui.go` | +| Network collection, parsing and filtering | `internal/hostnet/` (repository root) | +| Network UI | `network_ui.go`, `network_test.go` | +| Structured command capture | `internal/commandcapture/` (repository root) | +| Plain-text sanitization and formatting | `internal/textutil/` (repository root) | +| Process activity and tracing | `host_activity*.go`, `sysdig*.go` | +| Logs | `logs.go`, `log_*.go` | +| Reviewed actions and commands | `authorization.go`, `operations.go`, `commands.go`, `sudo.go` | +| Settings and themes | `settings.go`, `preferences.go`, `themes.go`, `style.go`, `icons.go` | +| SSH | `remote.go`, `internal/remote/` (repository root) | + +Read related `*_test.go` files as needed. `helpers_test.go` contains adapters +used only by tests; production uses staged inventory and tolerant parsers. +Platform-specific files use `_linux.go` / `_other.go` suffixes or build tags; +check both implementations when changing shared contracts. + +The extracted packages do not import `dashboard`, tview or tcell. Keep that +boundary: collection and parsing belong outside the UI. Their tests run with +`go test ./internal/hostnet ./internal/dockerinspect ./internal/commandcapture`; +they do not require loading dashboard fixtures or starting terminal screens. + +## Documentation and assets + +Read `SPEC.md` for architecture or the relevant guide in `docs/` for behaviour. +`VISUAL-DESIGN.md` and `THEMES.md` matter for visual changes. `CHANGELOG.md`, +`docs/visual-roadmap.md`, and `docs/assets/` are not prerequisites for ordinary +code changes. Assets are documentation screenshots and design references. +`playground/` provides optional live Docker/Kubernetes environments. + +## Checks and constraints + +- Use `gofmt` on changed Go files and run focused tests while developing. +- Before finishing code changes, run `go test ./...`; use `make test` for race + detection, `make check` for shell/installer/vet checks, and `make cross` when + changing platform-dependent code. See `CONTRIBUTING.md` for the full workflow. +- Keep backend calls off the UI thread, preserve cancellation and explicit + action review, and clean/escape external output. +- Do not delete code just because it lacks direct textual callers: tview uses + interface methods, and some helpers are test-only or platform-specific. diff --git a/CHANGELOG.md b/CHANGELOG.md index e957566..47fd424 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,8 +4,18 @@ Changes are recorded here before being assigned a release tag. ## Unreleased +## v0.3.0 - 2026-10-26 + +Live GPU telemetry and per-process GPU readings, CPU core inspection, and independently testable backend collectors. + +### Changed + +- Extracted host network collection, Docker inspection formatting, bounded command capture and plain-text helpers from the dashboard into independently testable packages. Component tests and the Docker fixture now live beside their implementations; the agent guide maps features to their owning files and packages. + ### Added +- A permanent HOST GPU dashboard card beside CPU and memory shows busy percentage, VRAM and NVIDIA temperature. The card matches the other telemetry cards’ fixed height and two-row history graphs, including block/braille/ASCII rendering and gaps for missing readings. Additional devices scroll inside the card. Process Explorer includes the GPU card and per-process GPU percentage and memory columns, using NVIDIA pmon and Linux DRM client counters. Supports NVIDIA through `nvidia-smi` and Linux DRM drivers exposing busy/VRAM counters; missing readings stay unknown and the card remains visible when telemetry is unavailable. + - CPU Cores view. `c` in Process Explorer, `C` on the dashboard or a click on the HOST CPU card opens a per-CPU table: for every logical CPU its core and socket, busy meter and share on the severity ramp, the interval split into user, system, iowait and steal time (iowait from 10% and steal from 5% highlighted), current clock, its own trend and the busiest process last scheduled on it with PID and share; idle CPUs say how many processes were last placed there instead of naming a sleeper. The header carries the whole-machine share, three load averages, stall reading, host trend and the CPU inventory. `S` orders by busiest or by number. The view redraws on the existing two-second host sample and adds no polling; a narrow terminal drops the trend and process columns but keeps every CPU. - Process Explorer gains an `ON` column: the CPU each process was last scheduled on, read from `/proc//stat` in the same pass as the interval rate. The record view and card titles point at `c` for the cores view, and the HOST CPU card titles name the CPU count from 100 columns even when the clock does not fit. - CPU count and clock speed beside every host CPU figure. The masthead readout and the dashboard's HOST CPU card title name the machine's logical CPUs and the clock they are running at (`8 CPUs · 3.2 GHz`, or the spread when cores disagree) from 120 columns. The Process Explorer CPU card carries the same title from 100 columns and draws one bar per logical CPU at its own busy share beside the trend, coloured by the shared severity ramp, with the clock beneath; more CPUs than fit become a count of CPUs above 70%. The selected process's CPU gauge says how many logical CPUs its 100% is one of and what that share is of the whole machine. diff --git a/SPEC.md b/SPEC.md index 570e2bf..9400af7 100644 --- a/SPEC.md +++ b/SPEC.md @@ -18,6 +18,7 @@ A Linux and macOS terminal workspace with two modes: native services (systemd on | Docker | Installed CLI, pinned endpoint, `ps`, `stats`, container-typed `inspect`, streaming logs | | Docker overview | Typed projection of inspect JSON into identity, ports, mounts, networks, runtime, labels; raw JSON remains available | | Kubernetes | Detected kubectl invocation (`kubectl`, `k0s kubectl`, `k3s kubectl`, `microk8s kubectl`, kind contexts, or `--kubectl`/`SYSTEMDOC_KUBECTL`); node list names the distribution; `get pods -A -o json`, `top pods`, pod JSON/YAML, events, Services, prefixed multi-container logs; `rollout restart` and `delete pod` as reviewed actions | +| Host GPU | NVIDIA/DRM device telemetry with bounded per-device history; NVIDIA pmon and DRM fdinfo process usage/memory | | Host network | TCP/UDP socket ownership, interfaces, and successive-counter download/upload rates from `/proc/net/dev` or `netstat -ibn` | | Compose | Installed `docker compose`; project files, profiles, environment files, workdir, endpoint | | Settings | JSON under XDG config, owner-only atomic saves | @@ -27,6 +28,22 @@ A Linux and macOS terminal workspace with two modes: native services (systemd on Versions are pinned in [go.mod](go.mod). Release binaries use `CGO_ENABLED=0` for Linux amd64/arm64 and macOS arm64. +### Package boundaries + +`internal/dashboard` owns terminal widgets, navigation, refresh scheduling and +action review. It calls `internal/hostnet` for network collection, parsing, +filtering and rate calculation, and `internal/dockerinspect` for readable +container inspection output. `internal/hostgpu` owns GPU device and process +collection, parsing and interval calculation. These packages own their component tests and +have no dependency on dashboard state, tview or tcell. Dashboard tests cover +their integration with the UI. + +`internal/commandcapture` supplies bounded structured CLI output with separate +stderr, cancellation and timeouts; host collectors share it. `internal/textutil` +owns external-output sanitization and plain-text helpers. Dependencies point +from the UI toward these packages, never back toward the UI. Other service, +host and Kubernetes backends still reside in `dashboard`. + ## Data flow and responsiveness Systemd and container inventory jobs start independently. Within the container job, Docker and Kubernetes are listed concurrently and either alone is sufficient; a failed kubectl probe backs off (30 s doubling to 10 min) so a host with kubectl but no cluster is not asked on every refresh, and a stack that stops answering is dropped and re-probed. Identity/state inventory is published before slower resource enrichment. Each backend has one in-flight job; cached inventory makes mode switches immediate while stale data refreshes in the background. Changed scopes discard obsolete results. diff --git a/docs/development.md b/docs/development.md index 9b240f3..7badce1 100644 --- a/docs/development.md +++ b/docs/development.md @@ -39,6 +39,10 @@ Review all themes and both modes. Screenshots published in documentation must sa - `cmd/systemdoc`: flags, version, local/remote dispatch. - `internal/dashboard`: UI, systemd/launchd/Docker backends, workflows, state, themes. +- `internal/hostnet`: socket collection/parsing, interface counters, rates and filters; no UI dependencies. +- `internal/dockerinspect`: readable Docker inspection output and its JSON fixture; no UI dependencies. +- `internal/commandcapture`: bounded structured command output, separate diagnostics and cancellation. +- `internal/textutil`: shared plain-text sanitization and formatting. - `internal/remote`: OpenSSH launch, keys, compatibility checks. - `scripts`: installer tests, release build, preview rendering. - `docs`: user and maintainer guides, generated visual assets. @@ -46,4 +50,10 @@ Review all themes and both modes. Screenshots published in documentation must sa See [architecture](../SPEC.md) for data flow and [the release guide](releasing.md) for packaging. +For network collection or Docker inspection changes, start in their packages +and run `go test ./internal/hostnet ./internal/dockerinspect ./internal/commandcapture`. +The dashboard retains UI and routing integration tests. The root [agent guide](../AGENTS.md) +maps other features to their starting files; reading the entire dashboard is +unnecessary for a focused change. + The existing fake-systemd suite pins its service platform before workers start, including on macOS CI. Launchd adapters are tested directly, and Darwin routing runs in an isolated test process. On a Mac, `SYSTEMDOC_LIVE_LAUNCHD=1 go test ./internal/dashboard -run TestLiveLaunchdReadOnly` exercises read-only system-domain inventory. diff --git a/docs/host-panels.md b/docs/host-panels.md index b6ae2cd..af6e83a 100644 --- a/docs/host-panels.md +++ b/docs/host-panels.md @@ -1,5 +1,13 @@ # Process Explorer and Disk & Storage +## GPU usage + +The **HOST GPU** card appears on the dashboard and in Process Explorer alongside CPU and memory. Each device has a compact usage/VRAM/temperature headline and a two-row history chart, drawn with the same fixed 0–100% scale and block/braille/ASCII setting as CPU and memory. History retains the latest 64 host samples; missing readings leave gaps. The card keeps the other cards’ five-line height. Scroll within it for additional devices. Memory suffixes use binary units (for example `42% 512M/8G 63°C`). No detail popup or shortcut is required. + +Readings refresh with host metrics; unavailable values remain unknown. NVIDIA requires a working `nvidia-smi` and driver. Linux drivers exposing `gpu_busy_percent` and `mem_info_vram_*` under DRM sysfs (including AMD) are also supported. Intel drivers without those counters and macOS GPU telemetry are currently unsupported. This monitors GPU workloads; it does not configure GPU access for containers or accelerate Systemdoc itself. + +Process Explorer adds **GPU** and **GPU MEM** columns, also shown in the selected process band and exported records. GPU readings follow the process snapshot cadence (at least 15 seconds); the GPU card uses the independent host sample cadence. NVIDIA uses [`nvidia-smi pmon`](https://docs.nvidia.com/deploy/nvidia-smi/#process-monitoring) for compute and graphics clients. Linux drivers exposing [DRM fdinfo counters](https://www.kernel.org/doc/html/latest/gpu/drm-usage-stats.html) supply per-process engine usage and resident VRAM or system GPU memory. DRM usage needs two process snapshots. The percentage is the busiest engine/device, while memory includes the process’s observed GPU buffers and may include shared allocations. Unsupported or inaccessible readings show `—`, including processes absent from the driver’s snapshot. + Press **4** for Process Explorer or **5** for Disk & Storage. Both are also available in the top navigation and Actions menu. They inspect the host running Systemdoc, independently of the Docker context, and share the dashboard header, gradient cards, selection styling and keyboard navigation. Each panel carries its own view rail beneath the cards, naming the two views that suite actually has: **Flat** and **Tree** for processes, **Filesystems** and **Deleted but open** for storage. The rail is clickable and mirrors the keyboard. Where the terminal is at least 100 columns wide the header also carries the host CPU and memory readout described in [the dashboard guide](usage.md). diff --git a/docs/usage.md b/docs/usage.md index db9a85f..493625d 100644 --- a/docs/usage.md +++ b/docs/usage.md @@ -54,6 +54,8 @@ Unavailable backends produce a visible error without disabling the other mode. E ## Live workspace +The **HOST GPU** card sits beside the CPU and memory cards and shows live GPU usage, VRAM and NVIDIA temperature directly on the dashboard. The card keeps the same height as the other telemetry cards and uses their two-row history graph on a fixed 0–100% scale. Each device has a compact usage/VRAM/temperature headline and its own history; additional devices scroll inside the card. Process Explorer also shows the GPU card and per-process GPU and GPU memory columns. When driver metrics are unavailable, the card says so. See [GPU usage](host-panels.md#gpu-usage) for supported sources. Like the other cards, it collapses on compact terminals. + While the same workload remains selected, background updates preserve the focused pane and your current inspector scroll position, including scrolling performed while a refresh is still loading. Live logs retain their separate pause/follow controls. Four Pulse cards show active workloads, those needing attention, host CPU and host memory. After a second sample, all four become real history charts with the latest direction shown beside the value. Click Active or Attention to filter the list; click again to clear. `A` cycles the same state filters from the keyboard. The always-visible **i Active only** button directly toggles active/running workloads. Each mode keeps its own filter when you switch. Counts describe the current backend inventory. The two host cards read the whole machine on a fixed 0–100% scale and name the tracked workload sum beside it, which includes only workloads reporting counters. The CPU card also carries the one-minute load average and the memory card names swap in use. From 120 columns the CPU card's title names how many logical CPUs the machine has and the clock they are running at (`HOST CPU · 8 CPUs · 3.2 GHz`, or a spread such as `800 MHz–4.2 GHz` when cores disagree), and the masthead repeats the same count and clock after the CPU share. The masthead also repeats memory and load, and adds a `STALL` reading from the kernel's pressure accounting when tasks have been waiting for CPU, memory or I/O for more than 5% of the last ten seconds. On Linux the clock is cpufreq's current frequency where the kernel exposes it and otherwise `/proc/cpuinfo`'s figure, which most virtual machines hold fixed; on Apple Silicon no clock is available and only the count is shown. Press `C` or click the HOST CPU card for **CPU Cores**, a per-CPU table with each logical CPU's busy share, user/system/iowait/steal split, clock, core and socket and trend; from Process Explorer (`4`, then `c`) it also names the busiest process on each CPU. [Host panels](host-panels.md#process-explorer) describes the view, the per-CPU strip on the Process Explorer card and the sources in detail. CPU uses one logical CPU as 100% and may exceed it. Missing values stay `—`; a non-zero reading is always drawn as at least one level. When an accounting command fails (for example `docker stats` without permission) the summary line says `accounting unavailable` and names the command, so blank columns are never mistaken for an idle machine. diff --git a/internal/commandcapture/README.md b/internal/commandcapture/README.md new file mode 100644 index 0000000..b56008a --- /dev/null +++ b/internal/commandcapture/README.md @@ -0,0 +1,10 @@ +# Structured command capture + +`Capture(ctx, name, args...)` returns stdout, sanitized stderr and an error. +It preserves the beginning of each stream, rejects overflow above 4 MiB, +sets `LC_ALL=C`, and limits execution to eight seconds or the caller's deadline. +This is for structured snapshots; live streams and action output use separate +tail buffers in the dashboard. + +Run `go test ./internal/commandcapture`. The package depends only on the standard +library and `internal/textutil`. diff --git a/internal/commandcapture/capture.go b/internal/commandcapture/capture.go new file mode 100644 index 0000000..e1b7577 --- /dev/null +++ b/internal/commandcapture/capture.go @@ -0,0 +1,56 @@ +// Package commandcapture captures bounded structured CLI output with cancellation. +package commandcapture + +import ( + "bytes" + "context" + "fmt" + "os" + "os/exec" + "time" + + "systemdoc/internal/textutil" +) + +// Preserve the beginning of structured output, and fail visibly on overflow. +// A tail buffer could drop a process header and misattribute subsequent sockets. +type prefixBuffer struct { + bytes.Buffer + overflow bool +} + +func (b *prefixBuffer) Write(p []byte) (int, error) { + n := len(p) + space := 4*1024*1024 - b.Len() + if n > space { + b.overflow = true + p = p[:space] + } + _, _ = b.Buffer.Write(p) + return n, nil +} + +// Capture returns stdout, sanitized stderr and the command error. Each stream +// is capped at 4 MiB; overflow invalidates the entire structured snapshot. +// Commands use the C locale and an eight-second timeout bounded by ctx. +func Capture(ctx context.Context, name string, args ...string) (string, string, error) { + ctx, cancel := context.WithTimeout(ctx, 8*time.Second) + defer cancel() + var output, diagnostic prefixBuffer + cmd := exec.CommandContext(ctx, name, args...) + cmd.Env = append(os.Environ(), "LC_ALL=C") + cmd.Stdout = &output + cmd.Stderr = &diagnostic + // lsof forks a helper child; if it keeps the pipe open after the parent is + // killed on timeout, Wait must still return or the page's busy flag never + // clears and it stops refreshing for the rest of the session. + cmd.WaitDelay = 2 * time.Second + err := cmd.Run() + if output.overflow || diagnostic.overflow { + return "", "", fmt.Errorf("%s output exceeded 4 MiB; snapshot was not used", name) + } + if ctx.Err() != nil { + return "", "", fmt.Errorf("%s: %w", name, ctx.Err()) + } + return output.String(), textutil.Clean(diagnostic.String()), err +} diff --git a/internal/commandcapture/capture_test.go b/internal/commandcapture/capture_test.go new file mode 100644 index 0000000..5336f6f --- /dev/null +++ b/internal/commandcapture/capture_test.go @@ -0,0 +1,15 @@ +package commandcapture + +import ( + "strings" + "testing" +) + +func TestNetworkOutputOverflowCannotLoseHeaders(t *testing.T) { + var b prefixBuffer + b.Write([]byte("p42\x00")) + b.Write([]byte(strings.Repeat("x", 4*1024*1024))) + if !b.overflow || b.Len() != 4*1024*1024 || !strings.HasPrefix(b.String(), "p42\x00") { + t.Fatal("bounded output lost record origin") + } +} diff --git a/internal/dashboard/activity.go b/internal/dashboard/activity.go index 039f4cd..fd2aec7 100644 --- a/internal/dashboard/activity.go +++ b/internal/dashboard/activity.go @@ -7,6 +7,8 @@ import ( "github.com/gdamore/tcell/v2" "github.com/rivo/tview" + + "systemdoc/internal/textutil" ) func shortWorkload(value string) string { @@ -97,7 +99,7 @@ func (w *workspace) updateStoryline() { if strings.Contains(event.text, "failed") || strings.Contains(event.text, "unhealthy") || strings.Contains(event.text, "no longer") { colour = p.error } - parts = append(parts, fmt.Sprintf("[%s]%s[-] [%s::b]%s[-::-] %s", p.muted, event.at.Format("15:04"), colour, tview.Escape(shortWorkload(event.id)), tview.Escape(clean(event.text)))) + parts = append(parts, fmt.Sprintf("[%s]%s[-] [%s::b]%s[-::-] %s", p.muted, event.at.Format("15:04"), colour, tview.Escape(shortWorkload(event.id)), tview.Escape(textutil.Clean(event.text)))) } if len(parts) == 0 { w.storyline.SetText(fmt.Sprintf(" [%s::b]%s STORYLINE[-::-] [%s]quiet · changes observed during this session will appear here · click or I opens history[-]", p.accent, w.icon(iconStoryline), p.muted)) @@ -140,7 +142,7 @@ func (w *workspace) storylineView() { if strings.Contains(event.text, "failed") || strings.Contains(event.text, "unhealthy") || strings.Contains(event.text, "no longer") { colour = p.error } - fmt.Fprintf(&content, "[%s]%s[-] [%s::b]◆ %-24s[-::-] %s\n", p.muted, event.at.Format("15:04:05"), colour, tview.Escape(shortWorkload(event.id)), tview.Escape(clean(event.text))) + fmt.Fprintf(&content, "[%s]%s[-] [%s::b]◆ %-24s[-::-] %s\n", p.muted, event.at.Format("15:04:05"), colour, tview.Escape(shortWorkload(event.id)), tview.Escape(textutil.Clean(event.text))) count++ } if count == 0 { diff --git a/internal/dashboard/ai.go b/internal/dashboard/ai.go index e1af225..77e0373 100644 --- a/internal/dashboard/ai.go +++ b/internal/dashboard/ai.go @@ -10,6 +10,8 @@ import ( "github.com/gdamore/tcell/v2" "github.com/rivo/tview" + + "systemdoc/internal/textutil" ) // Provider invocations intentionally disable execution tools. Unsupported flags fail closed. @@ -91,9 +93,9 @@ func (w *workspace) askAI(provider, prompt string) { cmd.Stdout = &output cmd.Stderr = &diagnostics err = cmd.Run() - result := clean(output.String()) + result := textutil.Clean(output.String()) if err != nil { - result += "\nProvider failed: " + err.Error() + "\n" + clean(diagnostics.String()) + result += "\nProvider failed: " + err.Error() + "\n" + textutil.Clean(diagnostics.String()) } if result == "" { result = "Provider returned no text." diff --git a/internal/dashboard/constellation.go b/internal/dashboard/constellation.go index f87aafb..553d6cd 100644 --- a/internal/dashboard/constellation.go +++ b/internal/dashboard/constellation.go @@ -9,6 +9,8 @@ import ( "github.com/gdamore/tcell/v2" "github.com/rivo/tview" + + "systemdoc/internal/textutil" ) // constellationGroupCap bounds each unit-kind group so a huge target such as @@ -110,7 +112,7 @@ func constellationText(item workload, mode int, raw string, nerd bool, states ma // collected text, keeping the collector's order. func relationshipLines(item workload, raw string) []string { var lines []string - for _, line := range strings.Split(clean(raw), "\n") { + for _, line := range strings.Split(textutil.Clean(raw), "\n") { line = strings.TrimSpace(line) if line == "" || line == item.ID || line == item.Name || strings.HasPrefix(line, "SYSTEM CONSTELLATION") { continue @@ -218,7 +220,7 @@ func (w *workspace) constellation() { view := textView().SetDynamicColors(true).SetScrollable(true).SetWrap(false) view.SetBorder(true).SetTitle(" " + w.iconLabel(iconConstellation, "SYSTEM CONSTELLATION · x · Esc returns ")) p := w.palette() - view.SetText(fmt.Sprintf("[%s::b]%s %s[-::-]\n\n[%s]Mapping relationships on demand…[-]", p.accent, w.icon(iconConstellation), tview.Escape(clean(item.Name)), p.muted)) + view.SetText(fmt.Sprintf("[%s::b]%s %s[-::-]\n\n[%s]Mapping relationships on demand…[-]", p.accent, w.icon(iconConstellation), tview.Escape(textutil.Clean(item.Name)), p.muted)) close := func() { cancel() w.pages.RemovePage("constellation") diff --git a/internal/dashboard/dashboard.go b/internal/dashboard/dashboard.go index ea6db8d..f7880f1 100644 --- a/internal/dashboard/dashboard.go +++ b/internal/dashboard/dashboard.go @@ -9,6 +9,8 @@ import ( "github.com/gdamore/tcell/v2" "github.com/rivo/tview" + + "systemdoc/internal/textutil" ) var tabNames = []string{"Overview", "Logs", "Config", "Metrics", "Dependencies"} @@ -415,7 +417,7 @@ func (w *workspace) buildDashboard() { w.illuminate(w.selectionCard.Box, nil, accent) for i, card := range w.cards { w.illuminate(card.Box, func() bool { return i < 2 && w.quickFilter == i+1 }, func(p palette) string { - return []string{p.success, p.error, p.accent, p.warning}[i] + return []string{p.success, p.error, p.accent, p.warning, p.accent}[i] }) } w.layoutDashboard(120, 30) @@ -576,10 +578,10 @@ func (w *workspace) updateDashboard() { if totals.memoryCount > 0 { memory = fmt.Sprintf("%.1f MiB", totals.memory) } - // Each card takes a quarter of the width and its rail frame keeps two - // cells on either side, so the trail must fit width/4-4 or the newest + // Each card shares the width and its rail frame keeps two + // cells on either side, so the trail must fit or the newest // samples, the ones on the right, are clipped off the card. - chartWidth := max(1, w.lastWidth/4-4) + chartWidth := max(1, w.lastWidth/len(w.cards)-4) if w.lastWidth == 0 { chartWidth = 24 } @@ -623,6 +625,7 @@ func (w *workspace) updateDashboard() { } card.SetBorderColor(tcell.GetColor(border)) } + w.renderGPUCard() shortStatus := "waiting" if w.limitedColours { shortStatus = "256 colours · see troubleshooting" @@ -647,7 +650,7 @@ func (w *workspace) updateDashboard() { if note := w.inventoryNote[w.mode]; note != "" { // A failed accounting call is said out loud; blank resource // columns must never look like an idle machine. - line += " · " + ellipsize("accounting unavailable · "+clean(note), max(20, w.lastWidth-displayWidth(line)-3)) + line += " · " + ellipsize("accounting unavailable · "+textutil.Clean(note), max(20, w.lastWidth-displayWidth(line)-3)) } w.summary.SetText(line) } @@ -686,7 +689,7 @@ func (w *workspace) updateSelectionCard() { } } } - w.selectionCard.SetTitle(" " + tview.Escape(clean(item.Name)) + " ").SetTitleColor(tcell.GetColor(p.accent)) + w.selectionCard.SetTitle(" " + tview.Escape(textutil.Clean(item.Name)) + " ").SetTitleColor(tcell.GetColor(p.accent)) state := item.State if w.mode == 0 && !item.UnitFileOnly && item.Detail != "" { state += " / " + item.Detail @@ -709,7 +712,7 @@ func (w *workspace) updateSelectionCard() { if cpuShare, ok := cpuValue(item.CPU); ok { cpuHue = pressureHue(p, int(cpuShare)) } - w.selectionCard.SetText(fmt.Sprintf(" [%s::b]%s %s[-::-] [%s]%s[-] [%s]CPU[-] [%s::b]%s[-::-] [%s]MEM[-] %s\n [%s]%s[-]", stateColour(item, p), stateSymbol(item, w.settings.NerdIcons), tview.Escape(clean(state)), p.muted, tview.Escape(clean(context)), p.muted, cpuHue, tview.Escape(available(item.CPU)), p.muted, tview.Escape(available(item.Memory)), p.muted, tview.Escape(trend))) + w.selectionCard.SetText(fmt.Sprintf(" [%s::b]%s %s[-::-] [%s]%s[-] [%s]CPU[-] [%s::b]%s[-::-] [%s]MEM[-] %s\n [%s]%s[-]", stateColour(item, p), stateSymbol(item, w.settings.NerdIcons), tview.Escape(textutil.Clean(state)), p.muted, tview.Escape(textutil.Clean(context)), p.muted, cpuHue, tview.Escape(available(item.CPU)), p.muted, tview.Escape(available(item.Memory)), p.muted, tview.Escape(trend))) } // selectionChartWidth is how many samples fit on the identity band's trend diff --git a/internal/dashboard/data.go b/internal/dashboard/data.go index a092986..29d1358 100644 --- a/internal/dashboard/data.go +++ b/internal/dashboard/data.go @@ -10,6 +10,8 @@ import ( "strings" "sync" "time" + + "systemdoc/internal/dockerinspect" ) type workload struct { @@ -69,15 +71,6 @@ func runBounded(ctx context.Context, timeout time.Duration, limit int, name stri return output, nil } -func inventory(ctx context.Context, mode int, user bool) ([]workload, error) { - items, err := listWorkloads(ctx, mode, user) - if err != nil { - return nil, err - } - items, _ = enrichInventory(ctx, mode, user, items) - return items, nil -} - // List identities and states without waiting for resource sampling. func listWorkloads(ctx context.Context, mode int, user bool) ([]workload, error) { if mode == 1 { @@ -317,7 +310,7 @@ func inspect(ctx context.Context, mode int, user bool, item workload, tab int) ( return output, err } if tab == 0 || tab == 4 { - return dockerOverview(output, tab == 4) + return dockerinspect.Overview(output, tab == 4) } return output, nil } diff --git a/internal/dashboard/docker_overview_test.go b/internal/dashboard/docker_overview_test.go index 69242da..1d68bb1 100644 --- a/internal/dashboard/docker_overview_test.go +++ b/internal/dashboard/docker_overview_test.go @@ -8,52 +8,13 @@ import ( "testing" "github.com/rivo/tview" -) - -func TestDockerOverviewDescribesContainerAndConnections(t *testing.T) { - raw, err := os.ReadFile("testdata/docker-inspect.json") - if err != nil { - t.Fatal(err) - } - out, err := dockerOverview(string(raw), false) - if err != nil { - t.Fatal(err) - } - for _, want := range []string{"website-web-1", "nginx:alpine", "healthy", "unless-stopped", "127.0.0.1:8080 → 80/tcp", "[::1]:8080 → 80/tcp", "exposed only", "website_data → /data", "/srv/website/nginx.conf → /etc/nginx/nginx.conf", "read-only", "read-write", "tmpfs → /tmp", "172.20.0.4", "fd00::4", "website_default", "256.0 MiB", "1.00 CPUs", "app.owner"} { - if !strings.Contains(out, want) { - t.Errorf("missing %q", want) - } - } - if strings.Contains(out, "not-for-overview") || strings.Contains(out, "0001-01-01") { - t.Fatal("overview leaked env value or zero timestamp") - } - connections, err := dockerOverview(string(raw), true) - if err != nil || !strings.Contains(connections, "VOLUMES & MOUNTS") || strings.Contains(connections, "── RUNTIME") { - t.Fatal(connections, err) - } -} -func TestDockerOverviewHandlesStoppedAndMissingData(t *testing.T) { - raw := `[{"Id":"abc","Name":"/stopped","State":{"Status":"exited","ExitCode":137,"OOMKilled":true},"HostConfig":{"PortBindings":{"80/tcp":[{"HostIp":"","HostPort":"8080"}]}},"NetworkSettings":{"Ports":{"80/tcp":null},"Networks":{"missing":null}}}]` - out, err := dockerOverview(raw, false) - if err != nil { - t.Fatal(err) - } - for _, want := range []string{"exited", "137", "OOM killed yes", "configured; no live binding reported", "not configured", "none reported", "unavailable"} { - if !strings.Contains(out, want) { - t.Errorf("missing %q in %s", want, out) - } - } - for _, raw := range []string{"bad", "[]", "[{}]", "[{\"Id\":\"one\"},{\"Id\":\"two\"}]"} { - if _, err := dockerOverview(raw, false); err == nil { - t.Fatal("accepted invalid inspection", raw) - } - } -} + "systemdoc/internal/dockerinspect" +) func TestDockerInspectionRoutesOverviewAndRawConfig(t *testing.T) { dir := t.TempDir() - fixture, err := filepath.Abs("testdata/docker-inspect.json") + fixture, err := filepath.Abs("../dockerinspect/testdata/docker-inspect.json") if err != nil { t.Fatal(err) } @@ -79,7 +40,7 @@ func TestDockerInspectionRoutesOverviewAndRawConfig(t *testing.T) { } } raw := `[{"Id":"abc","Name":"/[red]name","Config":{"Labels":{"label":"[blue]value"}}}]` - out, err := dockerOverview(raw, false) + out, err := dockerinspect.Overview(raw, false) if err != nil { t.Fatal(err) } diff --git a/internal/dashboard/format.go b/internal/dashboard/format.go index 1760dac..5f843c5 100644 --- a/internal/dashboard/format.go +++ b/internal/dashboard/format.go @@ -2,14 +2,15 @@ package dashboard import ( "regexp" + "sort" "strings" "sync/atomic" "github.com/mattn/go-runewidth" "github.com/rivo/tview" -) -var ansiSequence = regexp.MustCompile(`\x1b(?:\[[0-?]*[ -/]*[@-~]|\][^\x07\x1b]*(?:\x07|\x1b\\)|[@-_])`) + "systemdoc/internal/textutil" +) // timestamp recognises ISO, syslog ("Sep 14 16:17:12") and bare clock stamps, // so a journal tail inside a status block is dimmed like a Logs tab line. @@ -68,7 +69,7 @@ func (w *workspace) palette() palette { // Rich output is built exclusively from escaped text, never trusted input markup. func richOutput(raw string, tab int, p palette) string { - lines := strings.Split(clean(raw), "\n") + lines := strings.Split(textutil.Clean(raw), "\n") paint := func(colour, text string) string { return "[" + colour + "]" + tview.Escape(text) + "[-]" } for i, line := range lines { trim := strings.TrimSpace(line) @@ -101,3 +102,12 @@ func richOutput(raw string, tab int, p palette) string { } return strings.Join(lines, "\n") } + +func sortedKeys[V any](values map[string]V) []string { + keys := make([]string, 0, len(values)) + for key := range values { + keys = append(keys, key) + } + sort.Strings(keys) + return keys +} diff --git a/internal/dashboard/helpers_test.go b/internal/dashboard/helpers_test.go new file mode 100644 index 0000000..6c4b21e --- /dev/null +++ b/internal/dashboard/helpers_test.go @@ -0,0 +1,77 @@ +package dashboard + +import ( + "context" + "time" + + "systemdoc/internal/hostnet" +) + +// These adapters are used only by tests. Production callers use the staged +// inventory, tolerant parsers, filtered logs, and generic command streamer. + +func inventory(ctx context.Context, mode int, user bool) ([]workload, error) { + items, err := listWorkloads(ctx, mode, user) + if err != nil { + return nil, err + } + items, _ = enrichInventory(ctx, mode, user, items) + return items, nil +} + +// parseProcesses keeps the historical signature: it fails only when the +// snapshot had rows and none of them parsed. +func parseProcesses(raw string) ([]hostProcess, error) { + rows, _, err := parseProcessesTolerant(raw) + return rows, err +} + +// parseMounts keeps the historical signature: a missing header, or a table +// whose every row is unreadable, is an error; a single odd row is skipped. +func parseMounts(raw, platform string, inodeOnly bool) ([]hostMount, error) { + rows, _, err := parseMountsTolerant(raw, platform, inodeOnly) + return rows, err +} + +// parseDeletedFiles keeps the historical signature: output that is not lsof +// field format, or whose every record is unreadable, is an error. +func parseDeletedFiles(raw string) ([]deletedFile, error) { + rows, _, err := parseDeletedFilesTolerant(raw) + return rows, err +} + +func resetKubeState() { + kube.mu.Lock() + defer kube.mu.Unlock() + kube.stack, kube.err, kube.dockerErr, kube.probed, kube.failures = nil, "", "", time.Time{}, 0 +} + +// followLogs batches bounded CLI output; callers choose how to display it. +func followLogs(ctx context.Context, mode int, user bool, item workload, update func(string, string)) { + followLogsWith(ctx, mode, user, item, logFilter{}, update) +} + +// parseSS keeps the historical signature: it fails only when the output had +// rows and none of them was a socket. +func parseSS(raw string) ([]hostnet.Socket, error) { + rows, _, err := hostnet.ParseSS(raw) + return rows, err +} + +// streamSysdig runs a live probe and publishes the bounded buffer every 400 ms +// until the context is cancelled or sysdig exits. +func streamSysdig(ctx context.Context, runner sysdigRunner, args []string, update func(text, ending string)) { + streamCommand(ctx, sysdigCommand(ctx, runner, args), "sysdig", update) +} + +// Host utilisation sits beside the workload counts in the masthead so the +// machine's own load is visible without opening a panel. Missing readings stay +// missing rather than rendering as zero. +// hostReadoutFor omits the readout when the masthead has no room for it, so a +// narrow terminal shows a complete identity row rather than a clipped one. +func hostReadoutFor(p palette, usage hostUtilisation, width int) string { + if width > 0 && width < 100 { + return "" + } + return " " + hostReadout(p, usage, width) +} diff --git a/internal/dashboard/host_activity.go b/internal/dashboard/host_activity.go index 57cc290..b44afc1 100644 --- a/internal/dashboard/host_activity.go +++ b/internal/dashboard/host_activity.go @@ -12,6 +12,8 @@ import ( "time" "github.com/rivo/tview" + + "systemdoc/internal/textutil" ) // Process Activity digs one level below the process table: which threads are @@ -368,9 +370,9 @@ func renderProcessActivity(p palette, glyphs string, process hostProcess, previo if current != nil && current.Comm != "" && !strings.Contains(title, current.Comm) { title = strings.TrimSuffix(current.Comm+" · "+title, " · ") } - fmt.Fprintf(&out, "[%s::b]%s[-::-]\n", hue, tview.Escape(clean(title))) + fmt.Fprintf(&out, "[%s::b]%s[-::-]\n", hue, tview.Escape(textutil.Clean(title))) if note != "" { - fmt.Fprintf(&out, "[%s::b]%s[-::-]\n\n", p.error, tview.Escape(clean(note))) + fmt.Fprintf(&out, "[%s::b]%s[-::-]\n\n", p.error, tview.Escape(textutil.Clean(note))) } if current == nil { fmt.Fprintf(&out, "[%s]Reading /proc/%d…[-]\n", p.muted, process.PID) @@ -381,10 +383,10 @@ func renderProcessActivity(p palette, glyphs string, process hostProcess, previo if paused { stamp = "PAUSED · Space resumes · sampled " + current.At.Format("15:04:05") } - fmt.Fprintf(&out, "[%s]PID %d · %s · %s[-]\n\n", p.muted, process.PID, tview.Escape(clean(process.User)), stamp) + fmt.Fprintf(&out, "[%s]PID %d · %s · %s[-]\n\n", p.muted, process.PID, tview.Escape(textutil.Clean(process.User)), stamp) section := func(name string) { fmt.Fprintf(&out, "[%s::b]── %s[-::-]\n", hue, name) } field := func(label, value string) { fmt.Fprintf(&out, " [%s]%-10s[-] %s\n", p.muted, label, value) } - muted := func(text string) string { return fmt.Sprintf("[%s]%s[-]", p.muted, tview.Escape(clean(text))) } + muted := func(text string) string { return fmt.Sprintf("[%s]%s[-]", p.muted, tview.Escape(textutil.Clean(text))) } section("VITALS") stateHue := p.muted @@ -394,7 +396,7 @@ func renderProcessActivity(p palette, glyphs string, process hostProcess, previo if strings.HasPrefix(current.State, "D") || strings.HasPrefix(current.State, "Z") { stateHue = p.warning } - field("State", fmt.Sprintf("[%s::b]%s[-::-] %s", stateHue, tview.Escape(clean(stateWord(current.State))), muted(fmt.Sprintf("%d threads · nice %d", current.Threads, current.Nice)))) + field("State", fmt.Sprintf("[%s::b]%s[-::-] %s", stateHue, tview.Escape(textutil.Clean(stateWord(current.State))), muted(fmt.Sprintf("%d threads · nice %d", current.Threads, current.Nice)))) logical := host.cpus.logical if rates.Valid { cpuHue := pressureHue(p, int(rates.CPU)) @@ -419,7 +421,7 @@ func renderProcessActivity(p palette, glyphs string, process hostProcess, previo if current.CPUsAllowed != "" { allowed := "allowed " + current.CPUsAllowed if logical > 0 && cpuListCount(current.CPUsAllowed) < logical { - allowed = fmt.Sprintf("[%s]confined to CPUs %s[-]", p.warning, tview.Escape(clean(current.CPUsAllowed))) + allowed = fmt.Sprintf("[%s]confined to CPUs %s[-]", p.warning, tview.Escape(textutil.Clean(current.CPUsAllowed))) } placement += " " + allowed } @@ -456,13 +458,13 @@ func renderProcessActivity(p palette, glyphs string, process hostProcess, previo field("Files", muted(current.FDNote)) } if current.Unit != "" { - field("Service", tview.Escape(clean(current.Unit))+" "+muted("s opens it in Services")) + field("Service", tview.Escape(textutil.Clean(current.Unit))+" "+muted("s opens it in Services")) } if current.Executable != "" { - field("Executable", tview.Escape(clean(current.Executable))) + field("Executable", tview.Escape(textutil.Clean(current.Executable))) } if current.WorkingDir != "" { - field("Workdir", tview.Escape(clean(current.WorkingDir))) + field("Workdir", tview.Escape(textutil.Clean(current.WorkingDir))) } if len(children) > 0 { names := make([]string, 0, len(children)) @@ -473,7 +475,7 @@ func renderProcessActivity(p palette, glyphs string, process hostProcess, previo } names = append(names, fmt.Sprintf("%d %s", child.PID, shortCommand(child.Command))) } - field("Children", tview.Escape(clean(strings.Join(names, " · ")))) + field("Children", tview.Escape(textutil.Clean(strings.Join(names, " · ")))) } out.WriteByte('\n') @@ -517,7 +519,7 @@ func renderProcessActivity(p palette, glyphs string, process hostProcess, previo if logical > 1 { on = muted(fmt.Sprintf(" on CPU %d", task.CPU)) } - fmt.Fprintf(&out, " [%s]%-10d[-] %-20s [%s]%s[-] %s%s\n", p.muted, task.TID, tview.Escape(clean(task.Name)), stateHue, task.State, cpu, on) + fmt.Fprintf(&out, " [%s]%-10d[-] %-20s [%s]%s[-] %s%s\n", p.muted, task.TID, tview.Escape(textutil.Clean(task.Name)), stateHue, task.State, cpu, on) } if rates.Valid && len(tasks) > 0 { fmt.Fprintf(&out, " %s\n", muted(fmt.Sprintf("%d of %d threads used CPU in the last sample", busy, len(tasks)))) diff --git a/internal/dashboard/host_cores_ui.go b/internal/dashboard/host_cores_ui.go index 6ec0f73..f238362 100644 --- a/internal/dashboard/host_cores_ui.go +++ b/internal/dashboard/host_cores_ui.go @@ -9,6 +9,8 @@ import ( "github.com/gdamore/tcell/v2" "github.com/rivo/tview" + + "systemdoc/internal/textutil" ) // CPU Cores is the per-CPU view the cards only hint at: every logical CPU on @@ -108,12 +110,12 @@ func (c *coresView) input(e *tcell.EventKey) *tcell.EventKey { func renderCPUCores(p palette, glyphs string, usage hostUtilisation, hostHistory []float64, coreHistory [][]float64, processes []hostProcess, withProcesses, sortBusy bool, width int, now time.Time) string { var out strings.Builder hue := panelHue(p, 3) - muted := func(text string) string { return fmt.Sprintf("[%s]%s[-]", p.muted, tview.Escape(clean(text))) } + muted := func(text string) string { return fmt.Sprintf("[%s]%s[-]", p.muted, tview.Escape(textutil.Clean(text))) } title := "CPU CORES" if usage.cpus.model != "" { title += " · " + usage.cpus.model } - fmt.Fprintf(&out, "[%s::b]%s[-::-]\n", hue, tview.Escape(clean(title))) + fmt.Fprintf(&out, "[%s::b]%s[-::-]\n", hue, tview.Escape(textutil.Clean(title))) detail := cpuInventoryDetail(usage) if usage.cpus.model != "" { detail = strings.Replace(detail, " · "+usage.cpus.model, "", 1) @@ -260,7 +262,7 @@ func renderCPUCores(p palette, glyphs string, usage hostUtilisation, hostHistory if processWidth > 8 { if process, ok := busiest[cpu]; ok { label := fmt.Sprintf("%s · PID %d · %.1f%%", shortCommand(process.Command), process.PID, process.CPU) - fmt.Fprintf(&out, " %s", tview.Escape(clean(ellipsize(label, processWidth)))) + fmt.Fprintf(&out, " %s", tview.Escape(textutil.Clean(ellipsize(label, processWidth)))) } else if n := placed[cpu]; n > 0 { fmt.Fprintf(&out, " %s", muted(fmt.Sprintf("idle · %d processes last here", n))) } else { diff --git a/internal/dashboard/host_cpu_topology.go b/internal/dashboard/host_cpu_topology.go index 0464aee..da03d0c 100644 --- a/internal/dashboard/host_cpu_topology.go +++ b/internal/dashboard/host_cpu_topology.go @@ -7,6 +7,9 @@ import ( "sort" "strconv" "strings" + + "systemdoc/internal/commandcapture" + "systemdoc/internal/textutil" ) // cpuInventory is what the machine has, read once at start: how many logical @@ -81,7 +84,7 @@ func parseCPUInfo(raw string) (inventory cpuInventory, clocks cpuClocks) { seenProcessor = true case "model name", "Model", "Hardware", "cpu model": if inventory.model == "" && value != "" { - inventory.model = clean(value) + inventory.model = textutil.Clean(value) } case "physical id": current.pkg = value @@ -278,7 +281,7 @@ func collectLinuxClocks(procRoot, sysRoot string, logical int) cpuClocks { func collectDarwinCPUInventory(ctx context.Context, fallbackLogical int) (cpuInventory, cpuClocks) { inventory := cpuInventory{logical: fallbackLogical, ok: fallbackLogical > 0} number := func(key string) (int64, bool) { - raw, _, err := networkCommand(ctx, "sysctl", "-n", key) + raw, _, err := commandcapture.Capture(ctx, "sysctl", "-n", key) if err != nil { return 0, false } @@ -294,8 +297,8 @@ func collectDarwinCPUInventory(ctx context.Context, fallbackLogical int) (cpuInv if n, ok := number("hw.packages"); ok { inventory.sockets = int(n) } - if raw, _, err := networkCommand(ctx, "sysctl", "-n", "machdep.cpu.brand_string"); err == nil { - inventory.model = clean(strings.TrimSpace(raw)) + if raw, _, err := commandcapture.Capture(ctx, "sysctl", "-n", "machdep.cpu.brand_string"); err == nil { + inventory.model = textutil.Clean(strings.TrimSpace(raw)) } var clocks cpuClocks if hz, ok := number("hw.cpufrequency"); ok && inventory.logical > 0 { diff --git a/internal/dashboard/host_data.go b/internal/dashboard/host_data.go index 0023360..8c9a3ea 100644 --- a/internal/dashboard/host_data.go +++ b/internal/dashboard/host_data.go @@ -14,6 +14,9 @@ import ( "strings" "sync" "time" + + "systemdoc/internal/commandcapture" + "systemdoc/internal/textutil" ) type hostProcess struct { @@ -31,25 +34,6 @@ type hostProcess struct { LastCPU int } -// joinNote concatenates the non-empty diagnostics with the status separator. -func joinNote(parts ...string) string { - var kept []string - for _, part := range parts { - if strings.TrimSpace(part) != "" { - kept = append(kept, strings.TrimSpace(part)) - } - } - return strings.Join(kept, " · ") -} - -// skippedNote describes tolerated parser rows; "" when nothing was skipped. -func skippedNote(skipped int, what string) string { - if skipped <= 0 { - return "" - } - return fmt.Sprintf("%d unrecognised %s skipped", skipped, what) -} - // allRowsFailed is the tolerant parsers' shared failure rule: a snapshot is // rejected only when it had rows and none of them parsed. func allRowsFailed(parsed, skipped int, what string) error { @@ -74,13 +58,6 @@ func leadingFields(line string, count int) ([]string, string) { return fields, strings.TrimLeft(line, " \t") } -// parseProcesses keeps the historical signature: it fails only when the -// snapshot had rows and none of them parsed. -func parseProcesses(raw string) ([]hostProcess, error) { - rows, _, err := parseProcessesTolerant(raw) - return rows, err -} - // parseProcessesTolerant skips rows it cannot read (a truncated line, a // duplicate PID from a racing ps) and reports how many, so one odd row no // longer blanks the whole Process Explorer. @@ -110,13 +87,13 @@ func parseProcessesTolerant(raw string) (rows []hostProcess, skipped int, err er continue } seen[pid] = true - rows = append(rows, hostProcess{PID: pid, PPID: ppid, User: f[2], State: f[5], Elapsed: f[6], Command: clean(command), CPU: cpu, RSS: rss, LastCPU: -1}) + rows = append(rows, hostProcess{PID: pid, PPID: ppid, User: f[2], State: f[5], Elapsed: f[6], Command: textutil.Clean(command), CPU: cpu, RSS: rss, LastCPU: -1}) } return rows, skipped, allRowsFailed(len(rows), skipped, "ps rows") } func collectProcesses(ctx context.Context) ([]hostProcess, error) { - raw, diagnostic, err := networkCommand(ctx, "ps", "-axww", "-o", "pid=,ppid=,user=,pcpu=,rss=,stat=,etime=,args=") + raw, diagnostic, err := commandcapture.Capture(ctx, "ps", "-axww", "-o", "pid=,ppid=,user=,pcpu=,rss=,stat=,etime=,args=") if err != nil { return nil, fmt.Errorf("ps: %w %s", err, diagnostic) } @@ -295,13 +272,6 @@ func dfNumber(s string) (int64, error) { return strconv.ParseInt(strings.TrimSuffix(s, "%"), 10, 64) } -// parseMounts keeps the historical signature: a missing header, or a table -// whose every row is unreadable, is an error; a single odd row is skipped. -func parseMounts(raw, platform string, inodeOnly bool) ([]hostMount, error) { - rows, _, err := parseMountsTolerant(raw, platform, inodeOnly) - return rows, err -} - // parseMountsTolerant reads POSIX df output and skips rows it cannot read. func parseMountsTolerant(raw, platform string, inodeOnly bool) (rows []hostMount, skipped int, err error) { lines := strings.Split(strings.TrimSpace(raw), "\n") @@ -335,7 +305,7 @@ func parseDFRow(line, platform string, inodeOnly bool) (hostMount, bool) { } values[i] = v } - m := hostMount{Source: clean(f[1]), Path: clean(f[6]), Size: values[0], Used: values[1], Free: values[2], Percent: int(values[3]), InodePercent: -1, Inodes: -1, InodeUsed: -1, InodeFree: -1} + m := hostMount{Source: textutil.Clean(f[1]), Path: textutil.Clean(f[6]), Size: values[0], Used: values[1], Free: values[2], Percent: int(values[3]), InodePercent: -1, Inodes: -1, InodeUsed: -1, InodeFree: -1} if inodeOnly { m.Inodes, m.InodeUsed, m.InodeFree, m.InodePercent = m.Size, m.Used, m.Free, m.Percent } else if platform == "darwin" { @@ -350,7 +320,7 @@ func parseDFRow(line, platform string, inodeOnly bool) (hostMount, bool) { } values[i] = v } - m.InodeUsed, m.InodeFree, m.InodePercent, m.Path = values[0], values[1], int(values[2]), clean(path) + m.InodeUsed, m.InodeFree, m.InodePercent, m.Path = values[0], values[1], int(values[2]), textutil.Clean(path) if values[0] >= 0 && values[1] >= 0 && values[0] <= math.MaxInt64-values[1] { m.Inodes = values[0] + values[1] } @@ -446,7 +416,7 @@ func dfPercent(used, avail int64) int { // mount converts a statfs reading into the KiB-based row df would print. func (u mountUsage) mount(entry mountEntry) hostMount { kib := func(blocks uint64) int64 { return int64(blocks * u.BlockSize / 1024) } - m := hostMount{Source: clean(entry.Source), Path: clean(entry.Path), Size: kib(u.Blocks), Used: kib(u.Blocks - min(u.Blocks, u.Bfree)), Free: kib(u.Bavail)} + m := hostMount{Source: textutil.Clean(entry.Source), Path: textutil.Clean(entry.Path), Size: kib(u.Blocks), Used: kib(u.Blocks - min(u.Blocks, u.Bfree)), Free: kib(u.Bavail)} m.Percent = dfPercent(m.Used, m.Free) m.Inodes, m.InodeFree, m.InodeUsed = int64(u.Files), int64(u.Ffree), int64(u.Files-min(u.Files, u.Ffree)) m.InodePercent = -1 @@ -520,7 +490,7 @@ func statMounts(ctx context.Context, entries []mountEntry, timeout time.Duration if len(unreadable) > 0 { notes = append(notes, "unreadable: "+strings.Join(unreadable, ", ")) } - return rows, joinNote(notes...) + return rows, textutil.JoinNote(notes...) } // mountStatTimeout bounds how long one poll waits for any single mount. @@ -546,22 +516,22 @@ func collectMounts(ctx context.Context, platform string) ([]hostMount, string, e if platform == "darwin" { args = []string{"-Pki"} } - raw, diagnostic, err := networkCommand(ctx, "df", args...) + raw, diagnostic, err := commandcapture.Capture(ctx, "df", args...) if err != nil { return nil, "", fmt.Errorf("df: %w %s", err, diagnostic) } rows, skipped, err := parseMountsTolerant(raw, platform, false) - note := joinNote(diagnostic, skippedNote(skipped, "df rows")) + note := textutil.JoinNote(diagnostic, textutil.SkippedNote(skipped, "df rows")) if err != nil || platform == "darwin" { return rows, note, err } - raw, inodeNote, inodeErr := networkCommand(ctx, "df", "-Pi") + raw, inodeNote, inodeErr := commandcapture.Capture(ctx, "df", "-Pi") if inodeErr != nil { - return rows, joinNote(note, "Inodes unavailable: "+inodeErr.Error()+" "+inodeNote), nil + return rows, textutil.JoinNote(note, "Inodes unavailable: "+inodeErr.Error()+" "+inodeNote), nil } inodes, inodeSkipped, inodeErr := parseMountsTolerant(raw, platform, true) if inodeErr != nil { - return rows, joinNote(note, inodeErr.Error()), nil + return rows, textutil.JoinNote(note, inodeErr.Error()), nil } index := map[string]hostMount{} for _, m := range inodes { @@ -572,7 +542,7 @@ func collectMounts(ctx context.Context, platform string) ([]hostMount, string, e rows[i].Inodes, rows[i].InodeUsed, rows[i].InodeFree, rows[i].InodePercent = v.Inodes, v.InodeUsed, v.InodeFree, v.InodePercent } } - return rows, joinNote(note, skippedNote(inodeSkipped, "df -i rows")), nil + return rows, textutil.JoinNote(note, textutil.SkippedNote(inodeSkipped, "df -i rows")), nil } type deletedFile struct { @@ -583,13 +553,6 @@ type deletedFile struct { func (f deletedFile) key() string { return fmt.Sprintf("%d/%s/%s/%s", f.PID, f.FD, f.Device, f.Inode) } -// parseDeletedFiles keeps the historical signature: output that is not lsof -// field format, or whose every record is unreadable, is an error. -func parseDeletedFiles(raw string) ([]deletedFile, error) { - rows, _, err := parseDeletedFilesTolerant(raw) - return rows, err -} - // parseDeletedFilesTolerant skips a process record with a bad PID (and the // files under it), a file record with no owner, and a file with an unreadable // size, counting each once, so one odd record cannot blank the view. @@ -631,9 +594,9 @@ func parseDeletedFilesTolerant(raw string) (rows []deletedFile, skipped int, err file = deletedFile{Size: -1} kind = "" case 'c': - owner.Process = clean(v) + owner.Process = textutil.Clean(v) case 'u': - owner.User = clean(v) + owner.User = textutil.Clean(v) case 'f': flush() file = deletedFile{Size: -1} @@ -655,7 +618,7 @@ func parseDeletedFilesTolerant(raw string) (rows []deletedFile, skipped int, err case 'i': file.Inode = v case 'n': - file.Path = clean(v) + file.Path = textutil.Clean(v) case 's': size, err := strconv.ParseInt(v, 10, 64) if err != nil || size < 0 { @@ -670,7 +633,7 @@ func parseDeletedFilesTolerant(raw string) (rows []deletedFile, skipped int, err } func collectDeletedFiles(ctx context.Context) ([]deletedFile, string, error) { - raw, note, err := networkCommand(ctx, "lsof", "-nP", "+L1", "-F0pcuftDins") + raw, note, err := commandcapture.Capture(ctx, "lsof", "-nP", "+L1", "-F0pcuftDins") var exit *exec.ExitError if err != nil && !(errors.As(err, &exit) && exit.ExitCode() == 1) { return nil, "", fmt.Errorf("lsof: %w %s", err, note) @@ -682,7 +645,7 @@ func collectDeletedFiles(ctx context.Context) ([]deletedFile, string, error) { if err != nil && raw != "" { note = "Partial lsof result. " + note } - return rows, joinNote(note, skippedNote(skipped, "lsof records")), parseErr + return rows, textutil.JoinNote(note, textutil.SkippedNote(skipped, "lsof records")), parseErr } func hostBytes(value int64, kib bool) string { diff --git a/internal/dashboard/host_gpu_ui.go b/internal/dashboard/host_gpu_ui.go new file mode 100644 index 0000000..9799518 --- /dev/null +++ b/internal/dashboard/host_gpu_ui.go @@ -0,0 +1,73 @@ +package dashboard + +import ( + "fmt" + "strings" + + "github.com/gdamore/tcell/v2" + "github.com/rivo/tview" + "systemdoc/internal/hostgpu" +) + +func gpuHistoryKey(gpu hostgpu.Device, index int) string { + if gpu.ID != "" { + return gpu.ID + ":" + gpu.Name + } + return fmt.Sprintf("%d:%s", index, gpu.Name) +} + +// Each device uses the same headline and two-row history as CPU and memory. +// Additional devices scroll within the card instead of expanding the dashboard. +func (w *workspace) renderGPUCard() { + w.renderGPUCardInto(w.cards[4], w.lastWidth, len(w.cards)) +} + +func (w *workspace) renderGPUCardInto(card *tview.TextView, terminalWidth, cardCount int) { + p := w.palette() + card.SetTitle(" " + w.iconLabel(iconResources, "HOST GPU ")).SetTitleColor(tcell.GetColor(p.accent)).SetBorderColor(tcell.GetColor(p.accent)) + gpus := w.hostUsage.gpus + if len(gpus) == 0 { + card.SetText(fmt.Sprintf("[%s]Usage unavailable\nDriver metrics needed[-]", p.muted)) + return + } + width := max(1, terminalWidth/cardCount-4) + if terminalWidth == 0 { + width = 20 + } + var rows []string + for i, gpu := range gpus { + busy := fmt.Sprintf("[%s]—[-]", p.muted) + if gpu.Busy >= 0 { + busy = fmt.Sprintf("[%s::b]%.0f%%[-::-]", pressureHue(p, int(gpu.Busy)), gpu.Busy) + } + temp, memory := "—°C", "—/—" + if gpu.Temperature >= 0 { + temp = fmt.Sprintf("%.0f°C", gpu.Temperature) + } + if gpu.MemoryUsed >= 0 && gpu.MemoryTotal > 0 && gpu.MemoryUsed <= gpu.MemoryTotal { + memory = compactGPUBytes(gpu.MemoryUsed) + "/" + compactGPUBytes(gpu.MemoryTotal) + } + label := "" + if len(gpus) > 1 { + label = fmt.Sprintf("%d ", i) + } + history := w.hostGPUHistory[gpuHistoryKey(gpu, i)] + trend := signalArea(trimHistory(history, width), 100, w.settings.GraphMode) + if len(history) > 0 { + trend = gradientText(trend, p.accent, p.glow) + } + rows = append(rows, fmt.Sprintf("[%s]%s[-]%s [%s]%s %s[-]\n%s", p.muted, label, busy, p.text, memory, temp, trend)) + } + if len(gpus) == 1 && terminalWidth >= 160 { + card.SetTitle(" " + w.iconLabel(iconResources, "HOST GPU · ") + tview.Escape(gpus[0].Name) + " ") + } + if len(gpus) > 1 { + card.SetTitle(fmt.Sprintf(" %s· %d ", w.iconLabel(iconResources, "HOST GPU "), len(gpus))) + } + card.SetText(strings.Join(rows, "\n")) +} + +func compactGPUBytes(value int64) string { + number, unit, _ := strings.Cut(hostBytes(value, false), " ") + return strings.TrimSuffix(number, ".0") + strings.TrimSuffix(unit, "iB") +} diff --git a/internal/dashboard/host_gpu_ui_test.go b/internal/dashboard/host_gpu_ui_test.go new file mode 100644 index 0000000..f30b087 --- /dev/null +++ b/internal/dashboard/host_gpu_ui_test.go @@ -0,0 +1,156 @@ +package dashboard + +import ( + "strings" + "testing" + + "github.com/gdamore/tcell/v2" + "github.com/rivo/tview" + + "systemdoc/internal/hostgpu" +) + +func TestDashboardGPUCardVisibleWithoutShortcut(t *testing.T) { + w := testWorkspace() + screen := tcell.NewSimulationScreen("UTF-8") + w.app.SetScreen(screen) + defer screen.Fini() + screen.SetSize(120, 30) + w.updateDashboard() + w.app.ForceDraw() + card := w.cards[4] + x, y, width, height := card.GetRect() + if width < 20 || height != 5 { + t.Fatalf("GPU card has no usable dashboard space: %dx%d", width, height) + } + if !strings.Contains(card.GetText(true), "Usage unavailable") { + t.Fatal("missing driver should remain visible on the dashboard") + } + w.recordHostUtilisation(hostUtilisation{gpus: []hostgpu.Device{{Name: "Example [red]GPU", Busy: 10, MemoryUsed: 512 * 1024 * 1024, MemoryTotal: 8 * 1024 * 1024 * 1024, Temperature: 63}}}) + gpu := w.hostUsage.gpus[0] + gpu.Busy = 42 + w.recordHostUtilisation(hostUtilisation{gpus: []hostgpu.Device{gpu}}) + w.updateDashboard() + w.app.ForceDraw() + var visible strings.Builder + for row := y + 1; row < y+height-1; row++ { + for col := x + 1; col < x+width-1; col++ { + r, _, _, _ := screen.GetContent(col, row) + visible.WriteRune(r) + } + visible.WriteByte('\n') + } + for _, want := range []string{"42%", "512M/8G", "63°C"} { + if !strings.Contains(visible.String(), want) { + t.Fatalf("GPU metric %q not visible in dashboard card:\n%s", want, visible.String()) + } + } + for _, mode := range []string{"blocks", "braille", "ascii"} { + w.settings.GraphMode = mode + w.updateDashboard() + lines := strings.Split(card.GetText(true), "\n") + want := signalArea([]float64{10, 42}, 100, mode) + if len(lines) != 3 || strings.Join(lines[1:], "\n") != want { + t.Fatalf("%s GPU history differs from CPU/memory renderer: %q, want %q", mode, lines, want) + } + } + card.MouseHandler()(tview.MouseLeftClick, tcell.NewEventMouse(x+2, y+1, tcell.ButtonNone, 0), func(p tview.Primitive) { w.app.SetFocus(p) }) + if w.pages.HasPage("host-gpus") { + t.Fatal("GPU panel click should not open a detail view") + } + if strings.Contains(strings.ToLower(card.GetTitle()+card.GetText(true)), "click") { + t.Fatal("GPU panel should have no click prompt") + } + _, bodyY, _, _ := w.body.GetRect() + for count := 2; count <= 4; count++ { + devices := make([]hostgpu.Device, count) + for i := range devices { + devices[i] = w.hostUsage.gpus[0] + } + w.recordHostUtilisation(hostUtilisation{gpus: devices}) + w.updateDashboard() + w.app.ForceDraw() + _, _, _, gpuHeight := card.GetRect() + _, nextBodyY, _, _ := w.body.GetRect() + if gpuHeight != 5 || nextBodyY != bodyY { + t.Fatalf("%d GPUs changed dashboard geometry: card height %d, body y %d (was %d)", count, gpuHeight, nextBodyY, bodyY) + } + for index, line := range strings.Split(card.GetText(true), "\n") { + if tview.TaggedStringWidth(line) > width-2 { + t.Fatalf("GPU row overflows card: %q", line) + } + if index%3 == 0 && !strings.Contains(line, "42% 512M/8G 63°C") { + t.Fatalf("GPU row missing compact readings: %q", line) + } + } + } +} + +func TestGPUHistoryKeepsDevicesSeparateAndMissingSamplesAsGaps(t *testing.T) { + w := &workspace{} + a := hostgpu.Device{ID: "nvidia:0", Name: "Same model", Busy: 10} + b := hostgpu.Device{ID: "nvidia:1", Name: "Same model", Busy: 60} + w.recordHostUtilisation(hostUtilisation{gpus: []hostgpu.Device{a, b}}) + w.recordHostUtilisation(hostUtilisation{}) + a.Busy, b.Busy = 20, -1 + w.recordHostUtilisation(hostUtilisation{gpus: []hostgpu.Device{b, a}}) + for key, want := range map[string][]float64{gpuHistoryKey(a, 0): {10, -1, 20}, gpuHistoryKey(b, 1): {60, -1, -1}} { + got := w.hostGPUHistory[key] + if len(got) != len(want) { + t.Fatalf("history %q: %v", key, got) + } + for i := range want { + if got[i] != want[i] { + t.Fatalf("history %q: %v, want %v", key, got, want) + } + } + } + for i := 0; i < 100; i++ { + w.recordHostUtilisation(hostUtilisation{gpus: []hostgpu.Device{a}}) + } + if len(w.hostGPUHistory[gpuHistoryKey(a, 0)]) != 64 || len(w.hostGPUHistory) != 1 { + t.Fatal("GPU histories must stay bounded and drop removed devices") + } +} + +func TestProcessExplorerShowsGPUCardAndProcessReadings(t *testing.T) { + w := testWorkspace() + screen := tcell.NewSimulationScreen("UTF-8") + w.app.SetScreen(screen) + defer screen.Fini() + screen.SetSize(120, 30) + device := hostgpu.Device{ID: "drm:card0", Name: "GPU", Busy: 10, MemoryUsed: 1 << 30, MemoryTotal: 8 << 30, Temperature: -1} + w.recordHostUtilisation(hostUtilisation{gpus: []hostgpu.Device{device}}) + device.Busy = 60 + w.recordHostUtilisation(hostUtilisation{gpus: []hostgpu.Device{device}}) + w.hostPage(3) + _, page := w.pages.GetFrontPage() + h := page.(*hostPage) + defer h.close() + h.processes = []hostProcess{{PID: 42, Command: "renderer", CPU: 1, RSS: 100, LastCPU: -1}, {PID: 43, Command: "unknown", CPU: 0, LastCPU: -1}} + h.gpuProcesses = map[int]hostgpu.Process{42: {Busy: 35, MemoryUsed: 512 << 20}} + h.render() + w.app.ForceDraw() + if h.gpuCard == nil || h.summary.GetItemCount() != 4 { + t.Fatal("Process Explorer must show the GPU card alongside CPU and memory") + } + _, _, _, height := h.gpuCard.GetRect() + if height != 5 { + t.Fatalf("GPU card changed process panel height: %d", height) + } + trend := signalArea([]float64{10, 60}, 100, w.settings.GraphMode) + if !strings.HasSuffix(h.gpuCard.GetText(true), trend) { + t.Fatalf("Process Explorer missing GPU trend: %q", h.gpuCard.GetText(true)) + } + if h.table.GetCell(0, 4).Text != "GPU" || h.table.GetCell(0, 5).Text != "GPU MEM" { + t.Fatal("missing GPU process columns") + } + for _, row := range h.rows { + if row.pid == 42 && (row.cells[4] != "35%" || row.cells[5] != "512.0 MiB" || !strings.Contains(row.rich(), "GPU MEMORY") || !strings.Contains(row.detail, "GPU 35%")) { + t.Fatalf("GPU process readings missing from table, selection or export: %+v", row) + } + if row.pid == 43 && (row.cells[4] != "—" || row.cells[5] != "—") { + t.Fatal("missing GPU process readings must not become zero") + } + } +} diff --git a/internal/dashboard/host_metrics.go b/internal/dashboard/host_metrics.go index 04a9942..8346b6c 100644 --- a/internal/dashboard/host_metrics.go +++ b/internal/dashboard/host_metrics.go @@ -6,12 +6,16 @@ import ( "strconv" "strings" "time" + + "systemdoc/internal/commandcapture" + "systemdoc/internal/hostgpu" ) // hostUtilisation is whole-machine utilisation. It is deliberately separate // from fleetSample, which only ever sums the workloads Systemdoc observed, so // a missing host reading can never be mistaken for a workload measurement. type hostUtilisation struct { + gpus []hostgpu.Device cpuPercent float64 cpuOK bool memPercent float64 @@ -408,11 +412,11 @@ func pressureSignal(p palette, pressure hostPressure) string { // CPU is summed from live process shares and memory from page accounting. func collectDarwinUtilisation(ctx context.Context, cores int) hostUtilisation { var result hostUtilisation - if raw, _, err := networkCommand(ctx, "ps", "-A", "-o", "%cpu="); err == nil { + if raw, _, err := commandcapture.Capture(ctx, "ps", "-A", "-o", "%cpu="); err == nil { result.cpuPercent, result.cpuOK = parseProcessCPU(raw, cores) } total := int64(0) - if raw, _, err := networkCommand(ctx, "sysctl", "-n", "hw.memsize"); err == nil { + if raw, _, err := commandcapture.Capture(ctx, "sysctl", "-n", "hw.memsize"); err == nil { if value, convErr := strconv.ParseInt(strings.TrimSpace(raw), 10, 64); convErr == nil { total = value } @@ -420,7 +424,7 @@ func collectDarwinUtilisation(ctx context.Context, cores int) hostUtilisation { if total <= 0 { return result } - if raw, _, err := networkCommand(ctx, "vm_stat"); err == nil { + if raw, _, err := commandcapture.Capture(ctx, "vm_stat"); err == nil { if used, ok := parseVMStat(raw); ok { used = max(0, min(total, used)) result.memUsed, result.memTotal, result.memOK = used, total, true @@ -459,6 +463,7 @@ func (w *workspace) hostMetricsLoop(cores int) { defer ticker.Stop() for { usage, sample := collectHostUtilisation(w.ctx, servicePlatform, cores, previous) + usage.gpus = hostgpu.Collect(w.ctx) previous = sample usage.cpus = inventory if servicePlatform == "darwin" { @@ -487,6 +492,18 @@ func (w *workspace) recordHostUtilisation(usage hostUtilisation) { } w.hostCPUHistory = appendBounded(w.hostCPUHistory, cpu) w.hostMemoryHistory = appendBounded(w.hostMemoryHistory, memory) + if len(usage.gpus) == 0 { + for key, history := range w.hostGPUHistory { + w.hostGPUHistory[key] = appendBounded(history, -1) + } + } else { + next := make(map[string][]float64, len(usage.gpus)) + for i, gpu := range usage.gpus { + key := gpuHistoryKey(gpu, i) + next[key] = appendBounded(w.hostGPUHistory[key], gpu.Busy) + } + w.hostGPUHistory = next + } // One trail per logical CPU, kept in step with the aggregate: a sample // without per-CPU figures stores a gap in every trail. if count := max(len(usage.coreBusy), len(w.hostCoreHistory)); count > 0 { diff --git a/internal/dashboard/host_test.go b/internal/dashboard/host_test.go index 61ca39f..880f2b9 100644 --- a/internal/dashboard/host_test.go +++ b/internal/dashboard/host_test.go @@ -15,6 +15,9 @@ import ( "github.com/gdamore/tcell/v2" "github.com/rivo/tview" + + "systemdoc/internal/commandcapture" + "systemdoc/internal/textutil" ) const processFixture = ` 1 0 root 0.1 900 Ss 02:30:00 /sbin/init @@ -196,11 +199,11 @@ func TestVerifiedProcessSignalTerminatesExactPID(t *testing.T) { } }() - raw, diagnostic, err := networkCommand(context.Background(), "ps", "-ww", "-p", strconv.Itoa(cmd.Process.Pid), "-o", "args=") + raw, diagnostic, err := commandcapture.Capture(context.Background(), "ps", "-ww", "-p", strconv.Itoa(cmd.Process.Pid), "-o", "args=") if err != nil { t.Fatalf("read child identity: %v %s", err, diagnostic) } - process := hostProcess{PID: cmd.Process.Pid, Command: strings.TrimSpace(clean(raw))} + process := hostProcess{PID: cmd.Process.Pid, Command: strings.TrimSpace(textutil.Clean(raw))} changed := process changed.Command += " --different-identity" if err := verifyAndSignalProcess(context.Background(), changed, syscall.SIGTERM); err == nil || !strings.Contains(err.Error(), "changed identity") { diff --git a/internal/dashboard/host_ui.go b/internal/dashboard/host_ui.go index f8c786e..41457c8 100644 --- a/internal/dashboard/host_ui.go +++ b/internal/dashboard/host_ui.go @@ -12,6 +12,10 @@ import ( "github.com/gdamore/tcell/v2" "github.com/rivo/tview" + + "systemdoc/internal/commandcapture" + "systemdoc/internal/hostgpu" + "systemdoc/internal/textutil" ) var hostTabNames = []string{"SERVICES", "CONTAINERS", "NETWORK", "PROCESSES", "STORAGE"} @@ -114,8 +118,11 @@ type hostPage struct { search *tview.InputField summary *tview.Flex cards [3]*tview.TextView + gpuCard *tview.TextView rows []hostRow processes []hostProcess + gpuProcesses map[int]hostgpu.Process + gpuSample hostgpu.ProcessSample mounts []hostMount deleted []deletedFile sort int @@ -217,12 +224,18 @@ func (h *hostPage) Draw(screen tcell.Screen) { h.header.SetBorderPadding(0, 0, 1, 0) h.ResizeItem(h.summary, cardsHeight, 0) h.ResizeItem(h.detail, detailHeight, 0) + if h.gpuCard != nil { + h.w.renderGPUCardInto(h.gpuCard, width, 4) + } h.Flex.Draw(screen) p := h.w.palette() rails := map[*tview.Box]string{} for i, card := range h.cards { rails[card.Box] = []string{p.accent, p.warning, p.success}[i] } + if h.gpuCard != nil { + rails[h.gpuCard.Box] = p.accent + } paintSurfaces(screen, h.Flex, p, h.header, h.table, rails, h.w.limitedColours) } @@ -267,6 +280,12 @@ func (w *workspace) hostPage(tab int) { h.summary.AddItem(card, 0, 1, false) w.illuminatePanel(card.Box, nil, func(p palette) string { return []string{panelHue(p, tab), p.warning, p.success}[i] }, true) } + if h.tab == 3 { + h.gpuCard = textView().SetDynamicColors(true).SetWrap(false) + h.gpuCard.SetBackgroundColor(tcell.GetColor(p.surface)).SetBorder(true).SetBorderAttributes(tcell.AttrBold) + h.summary.AddItem(h.gpuCard, 0, 1, false) + w.illuminatePanel(h.gpuCard.Box, nil, func(p palette) string { return p.accent }, true) + } nav, modeButtons, deck := w.hostNavigation(tab, func(target int) { if target == h.tab { w.app.SetFocus(h.table) @@ -461,14 +480,24 @@ func (h *hostPage) refresh() { h.lastPoll = time.Now() h.updateStatus() view, generation, platform := h.view(), h.generation, servicePlatform + previousGPU := h.gpuSample go func() { var processes []hostProcess + var gpuProcesses map[int]hostgpu.Process + var gpuSample hostgpu.ProcessSample var mounts []hostMount var deleted []deletedFile var note string var err error if h.tab == 3 { processes, err = collectProcesses(h.ctx) + if err == nil { + pids := make([]int, len(processes)) + for i, process := range processes { + pids[i] = process.PID + } + gpuProcesses, gpuSample = hostgpu.CollectProcesses(h.ctx, pids, previousGPU) + } } else if view == 1 { deleted, note, err = collectDeletedFiles(h.ctx) } else { @@ -494,6 +523,7 @@ func (h *hostPage) refresh() { h.note[view] = note if h.tab == 3 { h.processes = processes + h.gpuProcesses, h.gpuSample = gpuProcesses, gpuSample } else if view == 1 { h.deleted = deleted } else { @@ -558,6 +588,9 @@ func (h *hostPage) render() { columns := len(headers) if h.width > 0 && h.width < 100 { columns = min(columns, 4) + if h.tab == 3 && h.width >= 80 { + columns = min(len(headers), 6) + } } if h.width > 0 && h.width < 55 { columns = min(columns, 2) @@ -575,7 +608,7 @@ func (h *hostPage) render() { if col == 0 { value = " " + value } - cell := tview.NewTableCell(tview.Escape(clean(value))).SetTextColor(tcell.GetColor(p.text)).SetMaxWidth(16) + cell := tview.NewTableCell(tview.Escape(textutil.Clean(value))).SetTextColor(tcell.GetColor(p.text)).SetMaxWidth(16) if numericColumn[headers[col]] { cell.SetAlign(tview.AlignRight) } @@ -602,7 +635,7 @@ func (h *hostPage) render() { // numericColumn names the table columns whose values are figures, so they are // right-aligned and read as a column of numbers rather than ragged text. -var numericColumn = map[string]bool{"RSS": true, "PID": true, "ON": true, "PPID": true, "ELAPSED": true, "AVAILABLE": true, "SIZE": true, "USED SPACE": true, "INODES": true, "UID": true, "FD": true} +var numericColumn = map[string]bool{"GPU": true, "GPU MEM": true, "RSS": true, "PID": true, "ON": true, "PPID": true, "ELAPSED": true, "AVAILABLE": true, "SIZE": true, "USED SPACE": true, "INODES": true, "UID": true, "FD": true} func (h *hostPage) selectRow(row int) { previous := h.selected @@ -655,7 +688,7 @@ func (h *hostPage) inspectRecord() { func (w *workspace) hostText(title, body string) { focus := w.app.GetFocus() p := w.palette() - v := textView().SetWrap(true).SetScrollable(true).SetText(clean(body)).SetTextColor(tcell.GetColor(p.text)) + v := textView().SetWrap(true).SetScrollable(true).SetText(textutil.Clean(body)).SetTextColor(tcell.GetColor(p.text)) v.SetBackgroundColor(tcell.GetColor(p.surface)).SetBorder(true).SetTitle(" " + title + " · Esc returns ").SetTitleColor(tcell.GetColor(p.accent)) v.SetInputCapture(func(e *tcell.EventKey) *tcell.EventKey { if e.Key() == tcell.KeyEscape { @@ -792,15 +825,15 @@ func verifyAndSignalProcess(ctx context.Context, process hostProcess, signal sys if err := processTargetError(process); err != nil { return err } - raw, diagnostic, err := networkCommand(ctx, "ps", "-ww", "-p", strconv.Itoa(process.PID), "-o", "args=") + raw, diagnostic, err := commandcapture.Capture(ctx, "ps", "-ww", "-p", strconv.Itoa(process.PID), "-o", "args=") if err != nil { return fmt.Errorf("could not revalidate PID %d: %w %s", process.PID, err, diagnostic) } - observed := strings.TrimSpace(clean(raw)) + observed := strings.TrimSpace(textutil.Clean(raw)) if observed == "" { return fmt.Errorf("PID %d exited before the signal was sent", process.PID) } - if observed != strings.TrimSpace(clean(process.Command)) { + if observed != strings.TrimSpace(textutil.Clean(process.Command)) { return fmt.Errorf("PID %d changed identity; expected %q, now %q", process.PID, process.Command, observed) } target, err := os.FindProcess(process.PID) @@ -860,7 +893,7 @@ func (h *hostPage) updateStatus() { } note := "Host only · permissions may hide details" if h.tab == 3 { - note += " · ps CPU is a lifetime average; RSS may include shared pages" + note += " · ps CPU is a lifetime average; RSS may include shared pages · GPU is busiest engine/device; — is unavailable" for _, process := range h.processes { if process.RateCPU { note = strings.Replace(note, "ps CPU is a lifetime average", "CPU is the rate since the previous snapshot", 1) @@ -885,7 +918,7 @@ func (h *hostPage) updateStatus() { // One line: the counts first, then the caveat, cut with an ellipsis rather // than wrapped, so every host page spends the same two rows on its footer. lead := fmt.Sprintf(" %d rows · %s · poll %ds · ", len(h.rows), state, h.pollSeconds()) - note = ellipsize(clean(strings.ReplaceAll(note, "\n", " · ")), max(0, h.width-displayWidth(lead)-1)) + note = ellipsize(textutil.Clean(strings.ReplaceAll(note, "\n", " · ")), max(0, h.width-displayWidth(lead)-1)) h.status.SetText(fmt.Sprintf("%s[%s]%s[-]", tview.Escape(lead), colour, tview.Escape(note))) } @@ -900,7 +933,7 @@ func (h *hostPage) report() string { for _, r := range h.rows { out.WriteString(r.detail + "\n\n") } - return clean(out.String()) + return textutil.Clean(out.String()) } func (h *hostPage) processRows() ([]string, [3]hostCard) { @@ -970,13 +1003,28 @@ func (h *hostPage) processRows() ([]string, [3]hostCard) { } detail := fmt.Sprintf("PID %d · %s · %s\nCPU %s · RSS %s · elapsed %s\nParent %d · %s\nChildren: %s\nCommand: %s\n\nEnter live activity (threads, switches, I/O, files, journal) · c CPU cores · T sysdig runtime tracing · K process actions · n ports · s service (current scope). Signals require review and PID identity validation.", p.PID, p.User, p.State, cpuText, hostBytes(p.RSS, true), p.Elapsed, p.PPID, available(parents[p.PPID]), available(strings.Join(children[p.PID], ", ")), p.Command) cpuText, _, _ = strings.Cut(cpuText, " · ") + gpuText, gpuMemory := "—", "—" + gpu, gpuFound := h.gpuProcesses[p.PID] + if gpuFound && gpu.Busy >= 0 { + gpuText = fmt.Sprintf("%.0f%%", gpu.Busy) + } + if gpuFound && gpu.MemoryUsed >= 0 { + gpuMemory = hostBytes(gpu.MemoryUsed, false) + } + detail += fmt.Sprintf("\nGPU %s · GPU memory %s (busiest engine/device; buffers may be shared)", gpuText, gpuMemory) cpuCell := cpuText if h.width >= 100 && p.CPU >= 0 { cpuCell = cellGauge(cpuText, p.CPU, 100, 6, glyphs) } parent := parents[p.PPID] - rich := func() string { return processRich(pal, glyphs, hue, p, parent, memoryCeiling, logical) } - h.rows = append(h.rows, hostRow{key: strconv.Itoa(p.PID), cells: []string{command, cpuCell, hostBytes(p.RSS, true), strconv.Itoa(p.PID), onCPU, p.User, p.State, strconv.Itoa(p.PPID), p.Elapsed}, detail: detail, rich: rich, tint: map[int]string{1: pressureHue(pal, int(max(0, p.CPU)))}, pid: p.PID, warning: strings.HasPrefix(p.State, "Z")}) + tint := map[int]string{1: pressureHue(pal, int(max(0, p.CPU)))} + if gpuFound && gpu.Busy >= 0 { + tint[4] = pressureHue(pal, int(gpu.Busy)) + } + rich := func() string { + return processRich(pal, glyphs, hue, p, parent, memoryCeiling, logical) + fmt.Sprintf("\n[%s]GPU[-] %s · [%s]GPU MEMORY[-] %s", pal.muted, gpuText, pal.muted, gpuMemory) + } + h.rows = append(h.rows, hostRow{key: strconv.Itoa(p.PID), cells: []string{command, cpuCell, hostBytes(p.RSS, true), strconv.Itoa(p.PID), gpuText, gpuMemory, onCPU, p.User, p.State, strconv.Itoa(p.PPID), p.Elapsed}, detail: detail, rich: rich, tint: tint, pid: p.PID, warning: strings.HasPrefix(p.State, "Z")}) } mode := []string{"CPU", "MEMORY", "PID"}[h.sort] if h.tree { @@ -989,7 +1037,7 @@ func (h *hostPage) processRows() ([]string, [3]hostCard) { // are named as such so the two are never read as the same measurement. chartWidth := 20 if h.width > 0 { - chartWidth = max(8, h.width/3-8) + chartWidth = max(8, h.width/4-8) } // Beside the CPU trend, one bar per logical CPU at its own busy share and // the clock line beneath it. The trend gives up columns for the strip only @@ -1029,7 +1077,7 @@ func (h *hostPage) processRows() ([]string, [3]hostCard) { if usage.memOK { memoryHeadline = fmt.Sprintf("%.0f%% host · %s", usage.memPercent, hostBytesPair(usage.memUsed, usage.memTotal)) } - return []string{"COMMAND", "CPU", "RSS", "PID", "ON", "USER", "STATE", "PPID", "ELAPSED"}, [3]hostCard{ + return []string{"COMMAND", "CPU", "RSS", "PID", "GPU", "GPU MEM", "ON", "USER", "STATE", "PPID", "ELAPSED"}, [3]hostCard{ {title: "PROCESSES", headline: fmt.Sprintf("%d observed", len(h.processes)), visual: signalMeter(float64(running), float64(len(h.processes)), 20, glyphs), note: fmt.Sprintf("%d on a CPU at sample · %d zombies%s", running, zombies, pressurePlain(usage.pressure))}, {title: cpuTitle, headline: fmt.Sprintf("%s · %.1f%% summed", cpuHeadline, cpu), visual: cpuTrend, chart: true, aside: cpuAside}, {title: "HOST MEMORY", headline: fmt.Sprintf("%s · %s RSS", memoryHeadline, hostBytes(rss, true)), visual: memoryTrend, chart: true}, @@ -1067,9 +1115,9 @@ func processRich(pal palette, glyphs, hue string, p hostProcess, parent string, memoryGauge = gaugeLine("MEMORY", float64(p.RSS), float64(memoryCeiling), 20, hueOf(pal, hue), pal, glyphs, hostBytes(p.RSS, true), "RSS · largest observed process sets the scale") } return fmt.Sprintf("[%s::b]%s[-::-]\n%s\n%s\n[%s]PID[-] [%s::b]%d[-::-] [%s]%s[-] [%s::b] %s [-::-] [%s]elapsed %s · parent %d %s[-]", - hue, tview.Escape(clean(p.Command)), cpuGauge, memoryGauge, - pal.muted, hue, p.PID, pal.muted, tview.Escape(clean(p.User)), stateHue, tview.Escape(clean(stateText)), - pal.muted, tview.Escape(clean(p.Elapsed)), p.PPID, tview.Escape(clean(available(parent)))) + hue, tview.Escape(textutil.Clean(p.Command)), cpuGauge, memoryGauge, + pal.muted, hue, p.PID, pal.muted, tview.Escape(textutil.Clean(p.User)), stateHue, tview.Escape(textutil.Clean(stateText)), + pal.muted, tview.Escape(textutil.Clean(p.Elapsed)), p.PPID, tview.Escape(textutil.Clean(available(parent)))) } func (h *hostPage) mountRows() ([]string, [3]hostCard) { @@ -1133,7 +1181,7 @@ func mountRich(p palette, mode, hue string, m hostMount, free, spaceHue string) fmt.Sprintf("%s used / %s total · %s free", hostCount(m.InodeUsed), hostCount(m.Inodes), hostCount(m.InodeFree))) } return fmt.Sprintf("[%s::b]%s[-::-] [%s]%s[-]\n%s\n%s\n[%s]Shared pools, bind mounts and reserved blocks affect accounting.[-]", - hue, tview.Escape(clean(m.Path)), p.muted, tview.Escape(clean(m.Source)), + hue, tview.Escape(textutil.Clean(m.Path)), p.muted, tview.Escape(textutil.Clean(m.Source)), gaugeLine("SPACE", float64(m.Percent), 100, 26, hueOf(p, spaceHue), p, mode, fmt.Sprintf("%d%%", m.Percent), fmt.Sprintf("%s used · %s free · %s total", hostBytes(m.Used, true), tview.Escape(free), hostBytes(m.Size, true))), inodeGauge, p.muted) diff --git a/internal/dashboard/kube.go b/internal/dashboard/kube.go index 15b277d..386af30 100644 --- a/internal/dashboard/kube.go +++ b/internal/dashboard/kube.go @@ -83,12 +83,6 @@ var kube struct { failures int } -func resetKubeState() { - kube.mu.Lock() - defer kube.mu.Unlock() - kube.stack, kube.err, kube.dockerErr, kube.probed, kube.failures = nil, "", "", time.Time{}, 0 -} - // kubeLabel is the short stack description shown in the masthead and table. func kubeLabel() string { kube.mu.Lock() diff --git a/internal/dashboard/kube_overview.go b/internal/dashboard/kube_overview.go index 77a459e..fe449fa 100644 --- a/internal/dashboard/kube_overview.go +++ b/internal/dashboard/kube_overview.go @@ -5,6 +5,8 @@ import ( "fmt" "sort" "strings" + + "systemdoc/internal/textutil" ) // Overview is a readable projection of one pod. Environment values and @@ -131,7 +133,7 @@ func kubeSectionWriter(out *strings.Builder) (func(string), func(string, string) fmt.Fprintf(out, "── %s\n", title) } field := func(label, value string) { - value = strings.Join(strings.Fields(clean(value)), " ") + value = strings.Join(strings.Fields(textutil.Clean(value)), " ") fmt.Fprintf(out, " %-12s %s\n", label, available(value)) } return section, field @@ -205,10 +207,10 @@ func writePodContainers(section func(string), field func(string, string), pod ku } } if len(container.Command) > 0 { - field("Command", dockerArgv(container.Command)) + field("Command", textutil.Argv(container.Command)) } if len(container.Args) > 0 { - field("Args", dockerArgv(container.Args)) + field("Args", textutil.Argv(container.Args)) } field("Requests", kubeResources(container.Resources.Requests)) field("Limits", kubeResources(container.Resources.Limits)) diff --git a/internal/dashboard/log_drawer.go b/internal/dashboard/log_drawer.go index 1bc0a97..dfbd2d1 100644 --- a/internal/dashboard/log_drawer.go +++ b/internal/dashboard/log_drawer.go @@ -6,6 +6,8 @@ import ( "time" "github.com/rivo/tview" + + "systemdoc/internal/textutil" ) func (w *workspace) toggleLogDrawer() { @@ -54,7 +56,7 @@ func (w *workspace) syncLogDrawer() { w.drawerCancel = cancel generation, mode, user, filter := w.drawerGeneration, w.mode, w.user, w.logFilter w.drawerPaused = false - w.logDrawer.SetTitle(" LOGS · " + tview.Escape(clean(item.Name)) + " · L closes ") + w.logDrawer.SetTitle(" LOGS · " + tview.Escape(textutil.Clean(item.Name)) + " · L closes ") w.logDrawer.SetText(" Connecting to log stream…") go func() { select { diff --git a/internal/dashboard/log_search.go b/internal/dashboard/log_search.go index 3650411..adff412 100644 --- a/internal/dashboard/log_search.go +++ b/internal/dashboard/log_search.go @@ -9,6 +9,8 @@ import ( "github.com/gdamore/tcell/v2" "github.com/rivo/tview" + + "systemdoc/internal/textutil" ) type logSearchOptions struct { @@ -66,7 +68,7 @@ func searchRetainedLogs(raw string, o logSearchOptions) (string, []int, error) { if err != nil { return "", nil, err } - lines := strings.Split(strings.TrimSuffix(clean(raw), "\n"), "\n") + lines := strings.Split(strings.TrimSuffix(textutil.Clean(raw), "\n"), "\n") if raw == "" { return "", nil, nil } diff --git a/internal/dashboard/logs.go b/internal/dashboard/logs.go index 0e2277f..9c4bf7f 100644 --- a/internal/dashboard/logs.go +++ b/internal/dashboard/logs.go @@ -9,6 +9,8 @@ import ( "github.com/gdamore/tcell/v2" "github.com/rivo/tview" + + "systemdoc/internal/textutil" ) func (w *workspace) streamLogs(ctx context.Context, mode int, user bool, item workload, filter logFilter, generation int) { @@ -62,11 +64,6 @@ func (f logFilter) label() string { return strings.Join(parts, " · ") } -// followLogs batches bounded CLI output; callers choose how to display it. -func followLogs(ctx context.Context, mode int, user bool, item workload, update func(string, string)) { - followLogsWith(ctx, mode, user, item, logFilter{}, update) -} - func followLogsWith(ctx context.Context, mode int, user bool, item workload, filter logFilter, update func(string, string)) { name, args := "journalctl", append([]string{"--unit", item.ID, "--follow", "--lines", "150", "--no-pager", "--output=short-iso"}, filter.journalArgs()...) if mode == 1 && isPod(item) { @@ -196,7 +193,7 @@ func (w *workspace) renderDrawerLogs() { if w.drawerLog.ending != "" { state = "ENDED · L close/reopen" } - w.logDrawer.SetTitle(" " + state + " · " + tview.Escape(clean(w.current().Name)) + " · L closes ") + w.logDrawer.SetTitle(" " + state + " · " + tview.Escape(textutil.Clean(w.current().Name)) + " · L closes ") } // Bound live layout work while keeping the complete retained buffer available diff --git a/internal/dashboard/network_test.go b/internal/dashboard/network_test.go index 157cd46..59d454c 100644 --- a/internal/dashboard/network_test.go +++ b/internal/dashboard/network_test.go @@ -13,6 +13,8 @@ import ( "github.com/gdamore/tcell/v2" "github.com/rivo/tview" + + "systemdoc/internal/hostnet" ) const ssFixture = `tcp LISTEN 0 511 127.0.0.1:3000 0.0.0.0:* users:(("node server",pid=42,fd=3),("worker",pid=43,fd=4)) uid:1000 ino:700 cgroup:/user.slice/dev.service @@ -20,197 +22,14 @@ udp UNCONN 0 0 [::]:5353 [::]:* uid:70 ino:701 v6only:1 tcp ESTAB 0 0 [fe80::1%en0]:50000 [fe80::2%en0]:443 users:(("browser",pid=44,fd=5)) uid:1000 ino:702 tcp TIME-WAIT 0 0 10.0.0.1:50001 10.0.0.2:443 ino:0 ` -const lsofFixture = "p42\x00cnode server\x00u501\x00Lalice\x00\nf3\x00tIPv6\x00PTCP\x00n[::1]:3000\x00TST=LISTEN\x00\nf4\x00tIPv4\x00PTCP\x00n127.0.0.1:55000->127.0.0.1:5432\x00TST=ESTABLISHED\x00\np44\x00cmDNSResponder\x00u65\x00L_mdnsresponder\x00\nf5\x00tIPv6\x00PUDP\x00n*:5353\x00\nf6\x00tIPv4\x00PUDP\x00n10.0.0.1:5000->10.0.0.2:5000\x00\n" -const procNetDevFixture = `Inter-| Receive | Transmit - face |bytes packets errs drop fifo frame compressed multicast|bytes packets errs drop fifo colls carrier compressed - lo: 1024 10 0 0 0 0 0 0 2048 20 0 0 0 0 0 0 - eth0: 4096 40 0 0 0 0 0 0 8192 80 0 0 0 0 0 0 -` -const darwinNetstatFixture = `Name Mtu Network Address Ipkts Ierrs Ibytes Opkts Oerrs Obytes Coll -lo0 16384 00:00:00:00:00:00 100 0 1024 100 0 2048 0 -en0 1500 aa:bb:cc:dd:ee:ff 200 0 4096 300 0 8192 0 -en0 1500 192.0.2 192.0.2.5 200 - 4096 300 - 8192 - -` - -func TestSSPreservesOwnersAndIPv6(t *testing.T) { - rows, err := parseSS(ssFixture) - if err != nil || len(rows) != 5 { - t.Fatal(rows, err) - } - if rows[0].Process != "node server" || rows[1].PID != 43 || rows[0].Local != rows[1].Local { - t.Fatal("shared ownership lost", rows) - } - if rows[2].PID != 0 || rows[2].Family != "IPv6" || !rows[2].bound() { - t.Fatal("unowned UDP socket lost", rows[2]) - } - host, port := socketEndpoint(rows[3].Local) - if host != "fe80::1%en0" || port != "50000" || rows[3].State != "ESTABLISHED" { - t.Fatal(rows[3], host, port) - } - if rows[4].PID != 0 || rows[4].bound() { - t.Fatal("TIME-WAIT invented an owner", rows[4]) - } - if _, err := parseSS("Cannot open netlink socket"); err == nil { - t.Fatal("diagnostics parsed as sockets") - } -} -func TestSSSkipsOnlyTheBadRows(t *testing.T) { - raw := ssFixture + "Cannot open netlink socket\n" + `tcp LISTEN 0 1 127.0.0.1:9 0.0.0.0:* users:(("ghost",pid=0,fd=1),("real",pid=7,fd=2))` + "\n" - rows, skipped, err := parseSSTolerant(raw) - if err != nil || len(rows) != 6 || skipped != 2 || rows[5].Process != "real" || rows[5].PID != 7 { - t.Fatalf("one bad row discarded the table: %d rows, %d skipped, %v", len(rows), skipped, err) - } - if _, skipped, err := parseSSTolerant("Cannot open netlink socket\nRTNETLINK answers: Operation not permitted"); err == nil || skipped != 2 { - t.Fatal("all-bad output accepted", skipped, err) - } - dir := t.TempDir() - fixture := filepath.Join(dir, "data") - if err := os.WriteFile(fixture, []byte(raw), 0600); err != nil { - t.Fatal(err) - } - t.Setenv("SS_FIXTURE", fixture) - if err := os.WriteFile(filepath.Join(dir, "ss"), []byte("#!/bin/sh\n/bin/cat \"$SS_FIXTURE\"\n"), 0700); err != nil { - t.Fatal(err) - } - t.Setenv("PATH", dir) - result, err := collectNetworkSockets(context.Background(), "linux") - if err != nil || len(result.Sockets) != 6 || !strings.Contains(result.Note, "2 unrecognised ss rows skipped") { - t.Fatalf("skipped count not surfaced: %q %v", result.Note, err) - } -} -func TestLsofRecordsRetainProcessAndSocketBoundaries(t *testing.T) { - rows, err := parseLsofSockets(lsofFixture) - if err != nil || len(rows) != 4 { - t.Fatal(rows, err) - } - if rows[0].Process != "node server" || rows[0].User != "alice" || rows[0].State != "LISTEN" || rows[1].PID != 42 || rows[1].Remote != "127.0.0.1:5432" { - t.Fatal(rows) - } - if rows[2].PID != 44 || rows[2].Process != "mDNSResponder" || rows[2].FD != "5" || rows[2].State != "UNCONN" || rows[3].State != "CONNECTED" { - t.Fatal(rows) - } - for _, raw := range []string{"p42\x00\nf3\x00PTCP\x00npartial", "f3\x00PTCP\x00n*:80\x00\n"} { - if _, err := parseLsofSockets(raw); err == nil { - t.Fatal("broken records accepted") - } - } - if rows, err := parseLsofSockets(""); err != nil || len(rows) != 0 { - t.Fatal(rows, err) - } -} -func TestNetworkFiltersMatchExactPortsAndKeepIPv6(t *testing.T) { - rows, _ := parseSS(ssFixture) - for _, test := range []struct { - query string - want int - }{{"port:3000", 2}, {"port:300", 0}, {"remoteport:443", 2}, {"localport:443", 0}, {"process:node pid:42", 1}, {"proto:udp", 1}, {"state:estab", 1}, {"local:[fe80::1%en0]:50000", 1}, {"family:ipv6", 2}, {"service:dev.service", 2}, {"unknown:x", 0}} { - count := 0 - for _, row := range rows { - if matchesNetworkSocket(row, test.query) { - count++ - } - } - if count != test.want { - t.Fatal(test.query, count, test.want) - } - } - if socketBinding("[::1]:80") != "Loopback address" || socketBinding("0.0.0.0:80") != "All IPv4 interfaces" { - t.Fatal("bind classification incorrect") - } -} -func TestNetworkCollectorSeparatesDiagnosticsAndEmptyResults(t *testing.T) { - dir := t.TempDir() - path := filepath.Join(dir, "lsof") - if err := os.WriteFile(path, []byte("#!/bin/sh\nexit 1\n"), 0700); err != nil { - t.Fatal(err) - } - t.Setenv("PATH", dir) - result, err := collectNetworkSockets(context.Background(), "darwin") - if err != nil || len(result.Sockets) != 0 { - t.Fatal(result, err) - } - if err := os.WriteFile(path, []byte("#!/bin/sh\necho 'permission denied' >&2\nexit 1\n"), 0700); err != nil { - t.Fatal(err) - } - if _, err := collectNetworkSockets(context.Background(), "darwin"); err == nil { - t.Fatal("permission failure became an empty table") - } - fixture := filepath.Join(dir, "data") - if err := os.WriteFile(fixture, []byte(lsofFixture), 0600); err != nil { - t.Fatal(err) - } - t.Setenv("NETWORK_FIXTURE", fixture) - if err := os.WriteFile(path, []byte("#!/bin/sh\n/bin/cat \"$NETWORK_FIXTURE\"\n"), 0700); err != nil { - t.Fatal(err) - } - result, err = collectNetworkSockets(context.Background(), "linux") - if err != nil || len(result.Sockets) != 4 || !strings.Contains(result.Note, "ss is unavailable") { - t.Fatal(result, err) - } - if err := os.WriteFile(path, []byte("#!/bin/sh\n/bin/cat \"$NETWORK_FIXTURE\"\nexit 1\n"), 0700); err != nil { - t.Fatal(err) - } - result, err = collectNetworkSockets(context.Background(), "darwin") - if err != nil || len(result.Sockets) != 4 || !strings.Contains(result.Note, "partial results") { - t.Fatal("lsof partial selection discarded", result, err) - } - if err := os.WriteFile(filepath.Join(dir, "ss"), []byte("#!/bin/sh\necho 'netlink denied' >&2\nexit 1\n"), 0700); err != nil { - t.Fatal(err) - } - if _, err := collectNetworkSockets(context.Background(), "linux"); err == nil { - t.Fatal("ss failure silently fell back") - } -} -func TestNetworkOutputOverflowCannotLoseHeaders(t *testing.T) { - var b networkBuffer - b.Write([]byte("p42\x00")) - b.Write([]byte(strings.Repeat("x", 4*1024*1024))) - if !b.overflow || b.Len() != 4*1024*1024 || !strings.HasPrefix(b.String(), "p42\x00") { - t.Fatal("bounded output lost record origin") - } -} - -func TestNetworkSpeedCountersAndRates(t *testing.T) { - linux, err := parseProcNetDev(procNetDevFixture) - if err != nil || len(linux) != 2 || linux[0].Name != "eth0" || linux[0].Receive != 4096 || linux[0].Transmit != 8192 { - t.Fatal(linux, err) - } - darwin, err := parseDarwinNetstat(darwinNetstatFixture) - if err != nil || len(darwin) != 2 || darwin[0].Name != "en0" || darwin[0].Receive != 4096 || darwin[0].Transmit != 8192 { - t.Fatal(darwin, err) - } - start := time.Date(2026, time.September, 12, 12, 0, 0, 0, time.UTC) - rates := networkRates([]networkCounter{{Name: "eth0", Receive: 1000, Transmit: 2000}}, start, []networkCounter{{Name: "eth0", Receive: 4000, Transmit: 6000}, {Name: "new0", Receive: 500, Transmit: 600}}, start.Add(2*time.Second)) - if len(rates) != 2 || !rates[0].Ready || rates[0].Receive != 1500 || rates[0].Transmit != 2000 || rates[1].Ready { - t.Fatal(rates) - } - reset := networkRates([]networkCounter{{Name: "eth0", Receive: 5000, Transmit: 5000}}, start, []networkCounter{{Name: "eth0", Receive: 1, Transmit: 2}}, start.Add(time.Second)) - if reset[0].Ready { - t.Fatal("counter reset produced an invented speed", reset) - } -} - -func TestNetworkSpeedCollectorDoesNotRequireCommands(t *testing.T) { - if runtime.GOOS != "linux" { - t.Skip("Linux speed counters come directly from /proc/net/dev") - } - t.Setenv("PATH", t.TempDir()) - snapshot, err := collectNetworkView(context.Background(), "linux", 3) - if err != nil || len(snapshot.Counters) == 0 || snapshot.CounterAt.IsZero() { - t.Fatalf("speed collector unexpectedly required an external command: counters=%d err=%v", len(snapshot.Counters), err) - } - if len(snapshot.Sockets) != 0 || len(snapshot.Interfaces) != 0 { - t.Fatal("speed collector performed unrelated network collection") - } -} func TestNetworkPageFilteringFocusAndResize(t *testing.T) { w := testWorkspace() w.networkPage() _, page := w.pages.GetFrontPage() n := page.(*networkPage) - n.snapshot = networkSnapshot{Sockets: mustSS(t), Source: "fixture", At: time.Now(), Interfaces: []networkInterface{{Name: "en0", Addresses: "10.0.0.1/24", Flags: "up", MTU: 1500}}} - n.rates = []networkRate{{Name: "en0", Receive: 1536, Transmit: 2048, ReceiveTotal: 4096, SendTotal: 8192, Ready: true}} + n.snapshot = hostnet.Snapshot{Sockets: mustSS(t), Source: "fixture", At: time.Now(), Interfaces: []hostnet.Interface{{Name: "en0", Addresses: "10.0.0.1/24", Flags: "up", MTU: 1500}}} + n.rates = []hostnet.Rate{{Name: "en0", Receive: 1536, Transmit: 2048, ReceiveTotal: 4096, SendTotal: 8192, Ready: true}} n.render() screen := tcell.NewSimulationScreen("UTF-8") w.app.SetScreen(screen) @@ -228,7 +47,7 @@ func TestNetworkPageFilteringFocusAndResize(t *testing.T) { } n.table.Select(2, 0) selected := n.selected - n.snapshot.Sockets = append([]networkSocket{{Protocol: "TCP", State: "LISTEN", Local: "*:80", PID: 1}}, n.snapshot.Sockets...) + n.snapshot.Sockets = append([]hostnet.Socket{{Protocol: "TCP", State: "LISTEN", Local: "*:80", PID: 1}}, n.snapshot.Sockets...) n.render() if n.selected != selected { t.Fatal("refresh changed selected socket") @@ -262,7 +81,7 @@ func TestNetworkPageFilteringFocusAndResize(t *testing.T) { t.Fatal("network did not close") } } -func mustSS(t *testing.T) []networkSocket { +func mustSS(t *testing.T) []hostnet.Socket { t.Helper() rows, err := parseSS(ssFixture) if err != nil { @@ -333,7 +152,7 @@ func TestLiveNetworkReadOnly(t *testing.T) { t.Fatal(err) } defer listener.Close() - _, port := socketEndpoint(listener.Addr().String()) + _, port := hostnet.Endpoint(listener.Addr().String()) platforms := []string{runtime.GOOS} if runtime.GOOS != "darwin" { if _, err := exec.LookPath("lsof"); err == nil { @@ -342,12 +161,12 @@ func TestLiveNetworkReadOnly(t *testing.T) { } for _, platform := range platforms { t.Run(platform, func(t *testing.T) { - result, err := collectNetworkSockets(context.Background(), platform) + result, err := hostnet.CollectSockets(context.Background(), platform) if err != nil { t.Fatal(err) } for _, s := range result.Sockets { - _, p := socketEndpoint(s.Local) + _, p := hostnet.Endpoint(s.Local) if p == port && s.State == "LISTEN" && s.PID == os.Getpid() { return } diff --git a/internal/dashboard/network_ui.go b/internal/dashboard/network_ui.go index a008222..57ec757 100644 --- a/internal/dashboard/network_ui.go +++ b/internal/dashboard/network_ui.go @@ -11,6 +11,10 @@ import ( "github.com/gdamore/tcell/v2" "github.com/rivo/tview" + + "systemdoc/internal/commandcapture" + "systemdoc/internal/hostnet" + "systemdoc/internal/textutil" ) type networkPage struct { @@ -28,11 +32,11 @@ type networkPage struct { cards [3]*tview.TextView buttons [4]*tview.Button modeButtons [5]*tview.Button - snapshot networkSnapshot - visible []networkSocket - interfaces []networkInterface - rates []networkRate - visibleRates []networkRate + snapshot hostnet.Snapshot + visible []hostnet.Socket + interfaces []hostnet.Interface + rates []hostnet.Rate + visibleRates []hostnet.Rate view int filters [4]string selected string @@ -284,7 +288,7 @@ func (n *networkPage) refresh() { n.updateStatus() platform, view := servicePlatform, n.view go func() { - snapshot, err := collectNetworkView(n.ctx, platform, view) + snapshot, err := hostnet.CollectView(n.ctx, platform, view) if n.ctx.Err() != nil { return } @@ -304,7 +308,7 @@ func (n *networkPage) refresh() { switch view { case 3: n.rateInterval = snapshot.CounterAt.Sub(n.snapshot.CounterAt) - n.rates = networkRates(n.snapshot.Counters, n.snapshot.CounterAt, snapshot.Counters, snapshot.CounterAt) + n.rates = hostnet.Rates(n.snapshot.Counters, n.snapshot.CounterAt, snapshot.Counters, snapshot.CounterAt) n.recordRateHistory() n.snapshot.Counters = snapshot.Counters n.snapshot.CounterAt = snapshot.CounterAt @@ -441,7 +445,7 @@ func (n *networkPage) render() { if col == 0 { value = " " + value // Leave room for the shared selection marker. } - cell := tview.NewTableCell(tview.Escape(clean(value))).SetTextColor(tcell.GetColor(p.text)).SetBackgroundColor(tcell.GetColor(p.background)).SetMaxWidth(12) + cell := tview.NewTableCell(tview.Escape(textutil.Clean(value))).SetTextColor(tcell.GetColor(p.text)).SetBackgroundColor(tcell.GetColor(p.background)).SetMaxWidth(12) if networkNumeric[headers[col]] { cell.SetAlign(tview.AlignRight) } @@ -492,7 +496,7 @@ func (n *networkPage) render() { } } else { for _, s := range n.snapshot.Sockets { - if n.view == 0 && !s.bound() || !matchesNetworkSocket(s, n.filters[n.view]) { + if n.view == 0 && !s.Bound() || !hostnet.MatchesSocket(s, n.filters[n.view]) { continue } n.visible = append(n.visible, s) @@ -511,7 +515,7 @@ func (n *networkPage) render() { if width < 60 { values = values[1:] } - add(values, s.key()) + add(values, s.Key()) row := n.table.GetRowCount() - 1 if row > 0 { protoCol := 0 @@ -529,7 +533,7 @@ func (n *networkPage) render() { } if cell := n.table.GetCell(row, stateCol); cell != nil { colour := p.muted - if s.bound() { + if s.Bound() { colour = p.success } else if strings.Contains(s.State, "CLOSE") { colour = p.warning @@ -580,20 +584,20 @@ func (n *networkPage) updateDetail(row int) { if row < 1 || row > len(n.visible) { n.selected = "" if n.failure[n.view] != "" { - n.detail.SetText("Socket lookup failed:\n" + clean(n.failure[n.view]) + "\n\nInterfaces are available separately. Press r to retry.") + n.detail.SetText("Socket lookup failed:\n" + textutil.Clean(n.failure[n.view]) + "\n\nInterfaces are available separately. Press r to retry.") return } n.detail.SetText("No matching sockets. Try Connections or clear the filter.\nFilters: port:3000 localport:8080 process:node pid:123 proto:tcp state:listen\nHost permissions can hide owners or sockets. Docker VM/container namespaces are separate.") return } s := n.visible[row-1] - n.selected = s.key() + n.selected = s.Key() pid := "unavailable" if s.PID > 0 { pid = strconv.Itoa(s.PID) } p := n.w.palette() - n.detail.SetText(fmt.Sprintf("[%s::b]%s %s[-::-] %s\n%s\n%s\n%s\n%s\n%s\n[%s]%s · a bind address is not a firewall rule[-]", p.accent, tview.Escape(s.Protocol), tview.Escape(s.State), tview.Escape(available(s.Process)), exposureChip(p, s.Local, s.State), networkField("Local", s.Local, p.muted)+" "+networkField("Remote", available(s.Remote), p.muted), networkField("Process", available(s.Process), p.accent)+" "+networkField("PID", pid, p.muted)+" "+networkField("User", available(s.User), p.muted)+" "+networkField("UID", available(s.UID), p.muted), networkField("FD", available(s.FD), p.muted)+" "+networkField("Inode", available(s.Inode), p.muted)+" "+networkField("Family", available(s.Family), p.muted), networkField("Cgroup", available(s.Cgroup), p.muted), p.muted, tview.Escape(socketBinding(s.Local)))) + n.detail.SetText(fmt.Sprintf("[%s::b]%s %s[-::-] %s\n%s\n%s\n%s\n%s\n%s\n[%s]%s · a bind address is not a firewall rule[-]", p.accent, tview.Escape(s.Protocol), tview.Escape(s.State), tview.Escape(available(s.Process)), exposureChip(p, s.Local, s.State), networkField("Local", s.Local, p.muted)+" "+networkField("Remote", available(s.Remote), p.muted), networkField("Process", available(s.Process), p.accent)+" "+networkField("PID", pid, p.muted)+" "+networkField("User", available(s.User), p.muted)+" "+networkField("UID", available(s.UID), p.muted), networkField("FD", available(s.FD), p.muted)+" "+networkField("Inode", available(s.Inode), p.muted)+" "+networkField("Family", available(s.Family), p.muted), networkField("Cgroup", available(s.Cgroup), p.muted), p.muted, tview.Escape(hostnet.Binding(s.Local)))) } if n.selected == oldKey { n.detail.ScrollTo(scroll, col) @@ -609,13 +613,13 @@ func (n *networkPage) updateSummary() { // or CLOSE-WAIT pile-up is visible here before anyone scrolls the table. states := map[string]int{} for _, s := range n.snapshot.Sockets { - if s.bound() { + if s.Bound() { listeners++ } else { connections++ states[s.State]++ } - if s.Protocol == "UDP" && s.bound() { + if s.Protocol == "UDP" && s.Bound() { udp++ } if s.PID > 0 { @@ -632,7 +636,7 @@ func (n *networkPage) updateSummary() { } if n.view == 3 { download, upload := 0.0, 0.0 - ready, busiest := 0, networkRate{} + ready, busiest := 0, hostnet.Rate{} for _, rate := range n.rates { if !rate.Ready { continue @@ -699,7 +703,7 @@ func socketStateSummary(states map[string]int) string { var networkNumeric = map[string]bool{"PID": true, "MTU": true, "DOWNLOAD": true, "UPLOAD": true, "TOTAL RX": true, "TOTAL TX": true} func networkField(label, value, colour string) string { - return fmt.Sprintf("[%s]%s[-] %s", colour, label, tview.Escape(clean(value))) + return fmt.Sprintf("[%s]%s[-] %s", colour, label, tview.Escape(textutil.Clean(value))) } func networkBytes(value float64) string { @@ -747,10 +751,10 @@ func (n *networkPage) updateStatus() { note = "Rates use byte-counter deltas between polls; loopback and virtual interfaces are included and may overlap." } if n.snapshot.Note != "" { - note += " · " + clean(n.snapshot.Note) + note += " · " + textutil.Clean(n.snapshot.Note) } if n.failure[n.view] != "" { - note = "Refresh failed; previous sample retained: " + clean(n.failure[n.view]) + note = "Refresh failed; previous sample retained: " + textutil.Clean(n.failure[n.view]) } stateColour := n.w.palette().muted if n.busy { @@ -763,9 +767,9 @@ func (n *networkPage) updateStatus() { if n.view == 3 { source = n.snapshot.CounterSource } - lead := fmt.Sprintf(" %d rows · %s · poll %ds · %s · ", count, state, n.w.settings.RefreshSeconds, clean(source)) - note = ellipsize(clean(strings.ReplaceAll(note, "\n", " · ")), max(0, n.lastWidth-displayWidth(lead)-1)) - n.status.SetText(fmt.Sprintf(" [%s::b]%d rows[-::-] · [%s]%s[-] · poll %ds · %s · [%s]%s[-]", n.w.palette().accent, count, stateColour, state, n.w.settings.RefreshSeconds, tview.Escape(clean(source)), n.w.palette().muted, tview.Escape(note))) + lead := fmt.Sprintf(" %d rows · %s · poll %ds · %s · ", count, state, n.w.settings.RefreshSeconds, textutil.Clean(source)) + note = ellipsize(textutil.Clean(strings.ReplaceAll(note, "\n", " · ")), max(0, n.lastWidth-displayWidth(lead)-1)) + n.status.SetText(fmt.Sprintf(" [%s::b]%d rows[-::-] · [%s]%s[-] · poll %ds · %s · [%s]%s[-]", n.w.palette().accent, count, stateColour, state, n.w.settings.RefreshSeconds, tview.Escape(textutil.Clean(source)), n.w.palette().muted, tview.Escape(note))) } func (n *networkPage) report() string { host, _ := os.Hostname() @@ -807,9 +811,9 @@ func (n *networkPage) report() string { } out.WriteString("\nHost network view; container/VM/remote Docker endpoints are not queried. Owners and sockets may be hidden by permissions. Multiple owners can share a socket. PID reuse and separately sampled process details are possible. Speed is host-interface throughput between polls, not negotiated link capacity; virtual interfaces may overlap.\n") out.WriteString(n.snapshot.Note) - return clean(out.String()) + return textutil.Clean(out.String()) } -func (n *networkPage) processDetails(s networkSocket) { +func (n *networkPage) processDetails(s hostnet.Socket) { if s.PID <= 0 { n.w.message("Process owner unavailable", "The socket is visible, but no PID was reported. Native permissions may hide ownership; closed sockets can also have no owner.\n\nCgroup: "+available(s.Cgroup)) return @@ -828,7 +832,7 @@ func (n *networkPage) processDetails(s networkSocket) { }) n.w.pages.AddPage("network-process", centered(view, 120, 24), true, true) go func() { - raw, diagnostic, err := networkCommand(ctx, "ps", "-ww", "-p", strconv.Itoa(s.PID), "-o", "pid=,ppid=,user=,etime=,command=") + raw, diagnostic, err := commandcapture.Capture(ctx, "ps", "-ww", "-p", strconv.Itoa(s.PID), "-o", "pid=,ppid=,user=,etime=,command=") if ctx.Err() != nil { return } @@ -838,7 +842,7 @@ func (n *networkPage) processDetails(s networkSocket) { } n.w.queue(func() { if ctx.Err() == nil { - view.SetText(clean(content)) + view.SetText(textutil.Clean(content)) } }) }() diff --git a/internal/dashboard/operations.go b/internal/dashboard/operations.go index 59c901e..169488c 100644 --- a/internal/dashboard/operations.go +++ b/internal/dashboard/operations.go @@ -12,6 +12,8 @@ import ( "github.com/gdamore/tcell/v2" "github.com/rivo/tview" + + "systemdoc/internal/textutil" ) // tailBuffer bounds command output even during long builds or noisy failures. @@ -219,7 +221,7 @@ func (w *workspace) execute(target, name string, args []string) { case err = <-done: running = false case <-ticker.C: - snapshot := clean(output.String()) + snapshot := textutil.Clean(output.String()) w.queue(func() { op.Output = snapshot front, _ := w.pages.GetFrontPage() @@ -234,7 +236,7 @@ func (w *workspace) execute(target, name string, args []string) { cancelled := ctx.Err() != nil w.operationCancel = nil cancel() - op.Output = clean(output.String()) + op.Output = textutil.Clean(output.String()) op.Status = "command completed · verify workload health" if err != nil { op.Status = "failed" diff --git a/internal/dashboard/paint_golden_test.go b/internal/dashboard/paint_golden_test.go index 814ba1a..f171200 100644 --- a/internal/dashboard/paint_golden_test.go +++ b/internal/dashboard/paint_golden_test.go @@ -130,7 +130,7 @@ func TestPaintSurfacesMatchesLegacyPerCellFormula(t *testing.T) { root, header, table = w.root, w.header, w.table rails[w.selectionCard.Box] = p.accent for i, card := range w.cards { - rails[card.Box] = []string{p.success, p.error, p.accent, p.glow}[i] + rails[card.Box] = []string{p.success, p.error, p.accent, p.glow, p.accent}[i] } } reference := tcell.NewSimulationScreen("UTF-8") diff --git a/internal/dashboard/remote.go b/internal/dashboard/remote.go index e05cb6e..ff79b4f 100644 --- a/internal/dashboard/remote.go +++ b/internal/dashboard/remote.go @@ -8,6 +8,7 @@ import ( "github.com/gdamore/tcell/v2" "github.com/rivo/tview" + "systemdoc/internal/remote" ) diff --git a/internal/dashboard/snapshot.go b/internal/dashboard/snapshot.go index fbb0320..d5bbb6c 100644 --- a/internal/dashboard/snapshot.go +++ b/internal/dashboard/snapshot.go @@ -9,6 +9,8 @@ import ( "github.com/gdamore/tcell/v2" "github.com/rivo/tview" + + "systemdoc/internal/textutil" ) type snapshotRequest struct { @@ -87,7 +89,7 @@ func collectSnapshot(ctx context.Context, request snapshotRequest) string { if !request.Config { out.WriteString("\nConfiguration / inspect JSON omitted. Overview and logs may still contain sensitive data.\n") } - return clean(out.String()) + return textutil.Clean(out.String()) } func (w *workspace) snapshotDialog() { item := w.current() @@ -98,7 +100,7 @@ func (w *workspace) snapshotDialog() { host, _ := os.Hostname() request := snapshotRequest{Item: item, Mode: w.mode, User: w.user, Host: host, Endpoint: dockerEndpoint(), At: time.Now(), Activity: append([]activityEvent(nil), w.activity...)} form := tview.NewForm().AddCheckbox("Include recent logs (latest 150)", true, nil).AddCheckbox("Include configuration / inspect JSON", false, nil) - form.SetBorder(true).SetTitle(" Troubleshooting snapshot · " + tview.Escape(clean(item.Name)) + " ") + form.SetBorder(true).SetTitle(" Troubleshooting snapshot · " + tview.Escape(textutil.Clean(item.Name)) + " ") close := func() { w.pages.RemovePage("snapshot-options"); w.app.SetFocus(w.table) } form.AddButton("Collect and review", func() { request.Logs = form.GetFormItem(0).(*tview.Checkbox).IsChecked() diff --git a/internal/dashboard/style.go b/internal/dashboard/style.go index 42b1243..d9b0715 100644 --- a/internal/dashboard/style.go +++ b/internal/dashboard/style.go @@ -140,10 +140,10 @@ func (d *luminousDashboard) Draw(screen tcell.Screen) { p := d.w.palette() rails := map[*tview.Box]string{d.w.selectionCard.Box: p.accent} for i, card := range d.w.cards { - // Active and attention are states, so they wear state colours. The two + // Active and attention are states, so they wear state colours. The // host cards are identities and take decorative hues; their figures // carry severity in the headline instead. - rails[card.Box] = []string{p.success, p.error, p.accent, p.glow}[i] + rails[card.Box] = []string{p.success, p.error, p.accent, p.glow, p.accent}[i] } paintSurfaces(screen, d.Flex, p, d.w.header, d.w.table, rails, d.w.limitedColours) } diff --git a/internal/dashboard/style_test.go b/internal/dashboard/style_test.go index c859f4d..f8d6ec8 100644 --- a/internal/dashboard/style_test.go +++ b/internal/dashboard/style_test.go @@ -7,6 +7,8 @@ import ( "github.com/gdamore/tcell/v2" "github.com/rivo/tview" + + "systemdoc/internal/hostnet" ) func TestNetworkSharesDashboardChromeAndSurfaces(t *testing.T) { @@ -34,7 +36,7 @@ func TestNetworkSharesDashboardChromeAndSurfaces(t *testing.T) { _, page := w.pages.GetFrontPage() n := page.(*networkPage) n.ctx, n.cancel = context.WithCancel(context.Background()) - n.snapshot = networkSnapshot{Sockets: mustSS(t)} + n.snapshot = hostnet.Snapshot{Sockets: mustSS(t)} n.render() w.app.ForceDraw() _, _, _, networkHeaderHeight := n.header.GetRect() diff --git a/internal/dashboard/sudo.go b/internal/dashboard/sudo.go index 4613289..e7cd580 100644 --- a/internal/dashboard/sudo.go +++ b/internal/dashboard/sudo.go @@ -9,6 +9,8 @@ import ( "github.com/gdamore/tcell/v2" "github.com/rivo/tview" + + "systemdoc/internal/textutil" ) // Elevated probes need sudo, and the reader should not leave the workspace to @@ -32,7 +34,7 @@ func sudoValidate(ctx context.Context, password []byte) error { cmd.Stdout, cmd.Stderr = &output, &output err := cmd.Run() if err != nil { - text := strings.TrimSpace(clean(output.String())) + text := strings.TrimSpace(textutil.Clean(output.String())) if text == "" { text = err.Error() } @@ -72,12 +74,12 @@ func (w *workspace) sudoPasswordDialog(problem string, then func()) { note := textView().SetDynamicColors(true).SetWrap(true) text := "[" + p.muted + "]sudo has no cached authorization. The password goes to sudo -S -v on standard input, refreshes its timestamp and is discarded; nothing is stored. Enter continues · Escape cancels.[-]" if problem != "" { - text = "[" + p.error + "::b]" + tview.Escape(clean(problem)) + "[-::-]\n" + text + text = "[" + p.error + "::b]" + tview.Escape(textutil.Clean(problem)) + "[-::-]\n" + text } note.SetText(text) panel := tview.NewFlex().SetDirection(tview.FlexRow).AddItem(note, 0, 1, false).AddItem(field, 1, 0, true) panel.SetBackgroundColor(tcell.GetColor(p.surface)).SetBorder(true).SetBorderColor(tcell.GetColor(p.warning)).SetBorderAttributes(tcell.AttrBold) - panel.SetTitle(" " + w.iconLabel(iconLock, "AUTHORIZE · sudo for "+tview.Escape(clean(name))+" ")).SetTitleColor(tcell.GetColor(p.warning)) + panel.SetTitle(" " + w.iconLabel(iconLock, "AUTHORIZE · sudo for "+tview.Escape(textutil.Clean(name))+" ")).SetTitleColor(tcell.GetColor(p.warning)) close := func() { w.pages.RemovePage("sudo"); w.app.SetFocus(focus) } field.SetDoneFunc(func(key tcell.Key) { if key == tcell.KeyEscape { diff --git a/internal/dashboard/sysdig.go b/internal/dashboard/sysdig.go index 8f07ad4..fca216c 100644 --- a/internal/dashboard/sysdig.go +++ b/internal/dashboard/sysdig.go @@ -9,6 +9,8 @@ import ( "strings" "syscall" "time" + + "systemdoc/internal/textutil" ) // sysdig looks at what a binary actually does at runtime: every syscall, file, @@ -336,19 +338,13 @@ func collectSysdig(ctx context.Context, runner sysdigRunner, probe sysdigProbe, func lastFrame(text string) string { frames := strings.Split(text, "\x1b[2J") for i := len(frames) - 1; i >= 0; i-- { - if strings.TrimSpace(clean(frames[i])) != "" { + if strings.TrimSpace(textutil.Clean(frames[i])) != "" { return frames[i] } } return text } -// streamSysdig runs a live probe and publishes the bounded buffer every 400 ms -// until the context is cancelled or sysdig exits. -func streamSysdig(ctx context.Context, runner sysdigRunner, args []string, update func(text, ending string)) { - streamCommand(ctx, sysdigCommand(ctx, runner, args), "sysdig", update) -} - // streamCommand runs any long-lived command and publishes its bounded output // every 400 ms until the context is cancelled or the command exits. The final // update carries the ending so a panel can say whether the stream stopped or diff --git a/internal/dashboard/sysdig_ui.go b/internal/dashboard/sysdig_ui.go index e3fd09e..ba55fe0 100644 --- a/internal/dashboard/sysdig_ui.go +++ b/internal/dashboard/sysdig_ui.go @@ -11,6 +11,8 @@ import ( "github.com/gdamore/tcell/v2" "github.com/rivo/tview" + + "systemdoc/internal/textutil" ) // sysdigPalette lists the probes for one target. Every probe is reviewed with @@ -140,7 +142,7 @@ func (w *workspace) streamPanel(title, commandLine, name string, build func(cont view := textView().SetDynamicColors(true).SetScrollable(true).SetWrap(false) view.SetBackgroundColor(tcell.GetColor(p.surface)).SetBorder(true).SetBorderColor(tcell.GetColor(panelHue(p, 3))).SetBorderAttributes(tcell.AttrBold) view.SetTitleColor(tcell.GetColor(panelHue(p, 3))) - view.SetText(fmt.Sprintf("[%s]%s\n\nStarting…[-]", p.muted, tview.Escape(clean(commandLine)))) + view.SetText(fmt.Sprintf("[%s]%s\n\nStarting…[-]", p.muted, tview.Escape(textutil.Clean(commandLine)))) var snapshot logSnapshot paused, lines := false, 0 setTitle := func() { @@ -151,7 +153,7 @@ func (w *workspace) streamPanel(title, commandLine, name string, build func(cont if snapshot.ending != "" { state = "ENDED" } - view.SetTitle(fmt.Sprintf(" %s · %s · %d lines · Space pause · e export · Esc stops ", tview.Escape(clean(title)), state, lines)) + view.SetTitle(fmt.Sprintf(" %s · %s · %d lines · Space pause · e export · Esc stops ", tview.Escape(textutil.Clean(title)), state, lines)) } render := func() { if paused { @@ -177,7 +179,7 @@ func (w *workspace) streamPanel(title, commandLine, name string, build func(cont paused = false render() case e.Rune() == 'e': - w.reviewSnapshot(commandLine + "\n\n" + clean(snapshot.text) + snapshot.ending) + w.reviewSnapshot(commandLine + "\n\n" + textutil.Clean(snapshot.text) + snapshot.ending) case e.Key() == tcell.KeyUp || e.Key() == tcell.KeyPgUp || e.Rune() == 'k': paused = true setTitle() @@ -273,7 +275,7 @@ func (w *workspace) runSysdigCollection(runner sysdigRunner, probe sysdigProbe, cancel() w.pages.RemovePage("sysdig-loading") header := fmt.Sprintf("%s\n%s · collected %d s from %s\n\n", runner.display(args), target.label, probe.seconds, started.Format("15:04:05")) - body := strings.TrimSpace(clean(output)) + body := strings.TrimSpace(textutil.Clean(output)) if err != nil { body = "Collection failed: " + err.Error() + "\n\n" + body } else if body == "" { @@ -294,7 +296,7 @@ func (w *workspace) traceResult(title, body string) { view := textView().SetDynamicColors(true).SetScrollable(true).SetWrap(false) view.SetText(richOutput(body, 0, p)) view.SetBackgroundColor(tcell.GetColor(p.surface)).SetBorder(true).SetBorderColor(tcell.GetColor(panelHue(p, 3))).SetBorderAttributes(tcell.AttrBold) - view.SetTitle(" " + tview.Escape(clean(title)) + " · e export · Esc returns ").SetTitleColor(tcell.GetColor(panelHue(p, 3))) + view.SetTitle(" " + tview.Escape(textutil.Clean(title)) + " · e export · Esc returns ").SetTitleColor(tcell.GetColor(panelHue(p, 3))) close := func() { w.pages.RemovePage("trace-result"); w.app.SetFocus(focus) } view.SetInputCapture(func(e *tcell.EventKey) *tcell.EventKey { switch { diff --git a/internal/dashboard/timers.go b/internal/dashboard/timers.go index 549f2a3..7816610 100644 --- a/internal/dashboard/timers.go +++ b/internal/dashboard/timers.go @@ -10,6 +10,8 @@ import ( "github.com/gdamore/tcell/v2" "github.com/rivo/tview" + + "systemdoc/internal/textutil" ) type systemTimer struct { @@ -140,7 +142,7 @@ func (w *workspace) timers() { } for i, v := range rows { for col, value := range []string{v.Unit, timerTime(v.Next), timerTime(v.Last), v.Activates} { - table.SetCell(i+1, col, tview.NewTableCell(tview.Escape(clean(value))).SetExpansion(1).SetMaxWidth(48)) + table.SetCell(i+1, col, tview.NewTableCell(tview.Escape(textutil.Clean(value))).SetExpansion(1).SetMaxWidth(48)) } } if len(rows) == 0 { @@ -181,7 +183,7 @@ func (w *workspace) timerRead(user bool, id string, tab int) { } ctx, cancel := context.WithCancel(w.ctx) view := textView().SetScrollable(true).SetWrap(true).SetText("Loading…") - view.SetBorder(true).SetTitle(" " + tview.Escape(clean(id)) + " · Escape returns ") + view.SetBorder(true).SetTitle(" " + tview.Escape(textutil.Clean(id)) + " · Escape returns ") view.SetInputCapture(func(e *tcell.EventKey) *tcell.EventKey { if e.Key() == tcell.KeyEscape { cancel() @@ -202,7 +204,7 @@ func (w *workspace) timerRead(user bool, id string, tab int) { } w.queue(func() { if ctx.Err() == nil { - view.SetText(clean(raw)) + view.SetText(textutil.Clean(raw)) } }) }() diff --git a/internal/dashboard/ui.go b/internal/dashboard/ui.go index 545d4ce..65bc98c 100644 --- a/internal/dashboard/ui.go +++ b/internal/dashboard/ui.go @@ -12,6 +12,8 @@ import ( "github.com/gdamore/tcell/v2" "github.com/rivo/tview" + + "systemdoc/internal/textutil" ) type workspace struct { @@ -69,7 +71,7 @@ type workspace struct { // telemetryDirty is raised when a host sample or inventory changes what // the cards show; the one-second tick draws a frame only when it is set. telemetryDirty atomic.Bool - cards [4]*tview.TextView + cards [5]*tview.TextView modeButtons [2]*tview.Button tabButtons [5]*tview.Button toolbarButtons []*tview.Button @@ -80,6 +82,7 @@ type workspace struct { hostCPUHistory []float64 hostCoreHistory [][]float64 hostMemoryHistory []float64 + hostGPUHistory map[string][]float64 lastRefresh [2]time.Time backendError [2]bool // inventoryNote names an accounting source that failed on the last poll. @@ -317,7 +320,7 @@ func (w *workspace) updateHeader() { } func dashboardHeader(p palette, icon, host, contextLabel string) string { - return fmt.Sprintf(" [%s::b]%s[-::-] %s [%s]// SYSTEM OBSERVATORY[-] [%s]%s / %s[-]", p.accent, icon, brandWordmark(p), p.muted, p.text, tview.Escape(clean(host)), tview.Escape(clean(contextLabel))) + return fmt.Sprintf(" [%s::b]%s[-::-] %s [%s]// SYSTEM OBSERVATORY[-] [%s]%s / %s[-]", p.accent, icon, brandWordmark(p), p.muted, p.text, tview.Escape(textutil.Clean(host)), tview.Escape(textutil.Clean(contextLabel))) } // mastheadHeight is one rule for every page: two lines and a gradient rule @@ -359,18 +362,6 @@ func brandWordmark(p palette) string { return "[::b]" + gradientText("SYSTEMDOC", p.accent, p.glow) + "[::-]" } -// Host utilisation sits beside the workload counts in the masthead so the -// machine's own load is visible without opening a panel. Missing readings stay -// missing rather than rendering as zero. -// hostReadoutFor omits the readout when the masthead has no room for it, so a -// narrow terminal shows a complete identity row rather than a clipped one. -func hostReadoutFor(p palette, usage hostUtilisation, width int) string { - if width > 0 && width < 100 { - return "" - } - return " " + hostReadout(p, usage, width) -} - // hostReadout is the one-line host figure set. From 120 columns the CPU // share is followed by what it is a share of: the logical CPU count and the // clock they are running at, muted so the percentages stay the subject. @@ -390,6 +381,13 @@ func hostReadout(p palette, usage hostUtilisation, width int) string { readout += fmt.Sprintf(" [%s]%s[-]", p.muted, tview.Escape(label)) } readout += fmt.Sprintf(" [%s]·[-] [%s::b]MEM %s[-::-]", p.muted, memoryHue, memory) + for i, gpu := range usage.gpus { + value, hue := "—", p.muted + if gpu.Busy >= 0 { + value, hue = fmt.Sprintf("%.0f%%", gpu.Busy), pressureHue(p, int(gpu.Busy)) + } + readout += fmt.Sprintf(" [%s]·[-] [%s::b]GPU%d %s[-::-]", p.muted, hue, i, value) + } if usage.loadOK { readout += fmt.Sprintf(" [%s]·[-] [%s::b]LOAD %.2f[-::-]", p.muted, p.text, usage.load1) } @@ -508,7 +506,7 @@ func (w *workspace) renderTable() { colour = pressureHue(p, int(cpuShare)) } } - cell := tview.NewTableCell(tview.Escape(clean(value))).SetTextColor(tcell.GetColor(colour)) + cell := tview.NewTableCell(tview.Escape(textutil.Clean(value))).SetTextColor(tcell.GetColor(colour)) if col == 2 || col == 3 { // Figures read as a column when their units line up. cell.SetAlign(tview.AlignRight) @@ -664,16 +662,6 @@ func (w *workspace) showDetail() { }() } -func clean(value string) string { - value = ansiSequence.ReplaceAllString(value, "") - return strings.Map(func(r rune) rune { - if r == '\n' || r == '\t' || r >= 32 && r != 127 && !(r >= 128 && r <= 159) { - return r - } - return -1 - }, value) -} - func (w *workspace) input(event *tcell.EventKey) *tcell.EventKey { front, _ := w.pages.GetFrontPage() if front != "main" { diff --git a/internal/dashboard/visual_check_test.go b/internal/dashboard/visual_check_test.go index cb94ebd..4ef4182 100644 --- a/internal/dashboard/visual_check_test.go +++ b/internal/dashboard/visual_check_test.go @@ -8,6 +8,9 @@ import ( "time" "github.com/gdamore/tcell/v2" + + "systemdoc/internal/dockerinspect" + "systemdoc/internal/hostnet" ) // This opt-in test exports terminal cells for visual review, without adding any @@ -63,11 +66,11 @@ func TestVisualReview(t *testing.T) { } w.activity = []activityEvent{{mode: 1, id: "website-worker-1", text: "running → restarting", at: time.Now().Add(-time.Minute)}, {mode: 1, id: "website-web-1", text: "health starting → healthy", at: time.Now().Add(-15 * time.Second)}} w.applyTheme() - raw, err := os.ReadFile("testdata/docker-inspect.json") + raw, err := os.ReadFile("../dockerinspect/testdata/docker-inspect.json") if err != nil { t.Fatal(err) } - overview, err := dockerOverview(string(raw), false) + overview, err := dockerinspect.Overview(string(raw), false) if err != nil { t.Fatal(err) } @@ -77,13 +80,13 @@ func TestVisualReview(t *testing.T) { w.networkPage() _, page := w.pages.GetFrontPage() n := page.(*networkPage) - n.snapshot = networkSnapshot{Source: "fixture · illustrative data", CounterSource: "fixture · host interface counters", At: time.Now(), CounterAt: time.Now(), Sockets: []networkSocket{ + n.snapshot = hostnet.Snapshot{Source: "fixture · illustrative data", CounterSource: "fixture · host interface counters", At: time.Now(), CounterAt: time.Now(), Sockets: []hostnet.Socket{ {Protocol: "TCP", Family: "IPv4", State: "LISTEN", Local: "127.0.0.1:3000", Remote: "0.0.0.0:*", Process: "node", PID: 1842, User: "demo", FD: "21", Cgroup: "/user.slice/dev-server.service"}, {Protocol: "TCP", Family: "IPv6", State: "LISTEN", Local: "[::]:5432", Remote: "[::]:*", Process: "postgres", PID: 2201, User: "postgres", FD: "6"}, {Protocol: "TCP", Family: "IPv4", State: "LISTEN", Local: "0.0.0.0:8080", Remote: "0.0.0.0:*", Process: "python", PID: 3244, User: "demo", FD: "3"}, {Protocol: "UDP", Family: "IPv4", State: "UNCONN", Local: "0.0.0.0:5353", Remote: "0.0.0.0:*", Process: "", PID: 0, UID: "70"}, }} - n.rates = []networkRate{ + n.rates = []hostnet.Rate{ {Name: "eth0", Receive: 24.8 * 1024 * 1024, Transmit: 3.7 * 1024 * 1024, ReceiveTotal: 18 * 1024 * 1024 * 1024, SendTotal: 7 * 1024 * 1024 * 1024, Ready: true}, {Name: "tailscale0", Receive: 1.2 * 1024 * 1024, Transmit: 840 * 1024, ReceiveTotal: 4 * 1024 * 1024 * 1024, SendTotal: 2 * 1024 * 1024 * 1024, Ready: true}, {Name: "lo", Receive: 128 * 1024, Transmit: 128 * 1024, ReceiveTotal: 900 * 1024 * 1024, SendTotal: 900 * 1024 * 1024, Ready: true}, diff --git a/internal/dashboard/workflows.go b/internal/dashboard/workflows.go index 6ace958..432470e 100644 --- a/internal/dashboard/workflows.go +++ b/internal/dashboard/workflows.go @@ -9,6 +9,8 @@ import ( "github.com/gdamore/tcell/v2" "github.com/rivo/tview" + + "systemdoc/internal/textutil" ) func (w *workspace) refreshLoop() { @@ -243,7 +245,7 @@ func (w *workspace) projectDialog() { w.projects = projects w.showProjects() if err != nil { - w.footer.SetText(" Compose discovery: " + clean(err.Error())) + w.footer.SetText(" Compose discovery: " + textutil.Clean(err.Error())) } }) }() diff --git a/internal/dockerinspect/README.md b/internal/dockerinspect/README.md new file mode 100644 index 0000000..29c2436 --- /dev/null +++ b/internal/dockerinspect/README.md @@ -0,0 +1,12 @@ +# Docker inspection + +`Overview(raw, connectionsOnly)` turns one container's inspect JSON into a +readable overview or connections report. It excludes environment values and +preserves labels and names as plain text; the UI escapes terminal markup. + +Run `go test ./internal/dockerinspect`. The JSON fixture lives in `testdata/`. +Inspector routing and terminal markup integration tests remain in +`internal/dashboard/docker_overview_test.go`. + +Dependencies: the standard library and `internal/textutil`. Docker invocation +and workspace state belong to the caller. diff --git a/internal/dashboard/docker_overview.go b/internal/dockerinspect/overview.go similarity index 94% rename from internal/dashboard/docker_overview.go rename to internal/dockerinspect/overview.go index 82c3342..5950cac 100644 --- a/internal/dashboard/docker_overview.go +++ b/internal/dockerinspect/overview.go @@ -1,4 +1,5 @@ -package dashboard +// Package dockerinspect formats Docker inspection JSON for human reading. +package dockerinspect import ( "encoding/json" @@ -6,6 +7,8 @@ import ( "net" "sort" "strings" + + "systemdoc/internal/textutil" ) type dockerPortBinding struct{ HostIP, HostPort string } @@ -57,7 +60,7 @@ type dockerInspection struct { // Overview is a readable projection of one container inspection. Config keeps // the complete JSON; environment values are not copied into the overview. -func dockerOverview(raw string, connectionsOnly bool) (string, error) { +func Overview(raw string, connectionsOnly bool) (string, error) { var rows []dockerInspection if err := json.Unmarshal([]byte(raw), &rows); err != nil { return "", fmt.Errorf("decode Docker container inspection: %w", err) @@ -74,8 +77,11 @@ func dockerOverview(raw string, connectionsOnly bool) (string, error) { fmt.Fprintf(&out, "── %s\n", title) } field := func(label, value string) { - value = strings.Join(strings.Fields(clean(value)), " ") - fmt.Fprintf(&out, " %-12s %s\n", label, available(value)) + value = strings.Join(strings.Fields(textutil.Clean(value)), " ") + if value == "" { + value = "—" + } + fmt.Fprintf(&out, " %-12s %s\n", label, value) } yesNo := func(value bool) string { if value { @@ -261,8 +267,8 @@ func dockerOverview(raw string, connectionsOnly bool) (string, error) { field("OOM killed", yesNo(c.State.OOMKilled)) } if c.Config != nil { - field("Entrypoint", dockerArgv(c.Config.Entrypoint)) - field("Command", dockerArgv(c.Config.Cmd)) + field("Entrypoint", textutil.Argv(c.Config.Entrypoint)) + field("Command", textutil.Argv(c.Config.Cmd)) field("Hostname", c.Config.Hostname) user := c.Config.User if user == "" { @@ -311,15 +317,6 @@ func dockerOverview(raw string, connectionsOnly bool) (string, error) { return out.String(), nil } -func sortedKeys[V any](values map[string]V) []string { - keys := make([]string, 0, len(values)) - for key := range values { - keys = append(keys, key) - } - sort.Strings(keys) - return keys -} - func dockerTimestamp(value string) string { if strings.HasPrefix(value, "0001-01-01") { return "—" @@ -327,13 +324,11 @@ func dockerTimestamp(value string) string { return value } -func dockerArgv(args []string) string { - if len(args) == 0 { - return "—" - } - parts := make([]string, len(args)) - for i, arg := range args { - parts[i] = fmt.Sprintf("%q", arg) +func sortedKeys[V any](values map[string]V) []string { + keys := make([]string, 0, len(values)) + for key := range values { + keys = append(keys, key) } - return strings.Join(parts, " ") + sort.Strings(keys) + return keys } diff --git a/internal/dockerinspect/overview_test.go b/internal/dockerinspect/overview_test.go new file mode 100644 index 0000000..9cd6ce6 --- /dev/null +++ b/internal/dockerinspect/overview_test.go @@ -0,0 +1,48 @@ +package dockerinspect + +import ( + "os" + "strings" + "testing" +) + +func TestDockerOverviewDescribesContainerAndConnections(t *testing.T) { + raw, err := os.ReadFile("testdata/docker-inspect.json") + if err != nil { + t.Fatal(err) + } + out, err := Overview(string(raw), false) + if err != nil { + t.Fatal(err) + } + for _, want := range []string{"website-web-1", "nginx:alpine", "healthy", "unless-stopped", "127.0.0.1:8080 → 80/tcp", "[::1]:8080 → 80/tcp", "exposed only", "website_data → /data", "/srv/website/nginx.conf → /etc/nginx/nginx.conf", "read-only", "read-write", "tmpfs → /tmp", "172.20.0.4", "fd00::4", "website_default", "256.0 MiB", "1.00 CPUs", "app.owner"} { + if !strings.Contains(out, want) { + t.Errorf("missing %q", want) + } + } + if strings.Contains(out, "not-for-overview") || strings.Contains(out, "0001-01-01") { + t.Fatal("overview leaked env value or zero timestamp") + } + connections, err := Overview(string(raw), true) + if err != nil || !strings.Contains(connections, "VOLUMES & MOUNTS") || strings.Contains(connections, "── RUNTIME") { + t.Fatal(connections, err) + } +} + +func TestDockerOverviewHandlesStoppedAndMissingData(t *testing.T) { + raw := `[{"Id":"abc","Name":"/stopped","State":{"Status":"exited","ExitCode":137,"OOMKilled":true},"HostConfig":{"PortBindings":{"80/tcp":[{"HostIp":"","HostPort":"8080"}]}},"NetworkSettings":{"Ports":{"80/tcp":null},"Networks":{"missing":null}}}]` + out, err := Overview(raw, false) + if err != nil { + t.Fatal(err) + } + for _, want := range []string{"exited", "137", "OOM killed yes", "configured; no live binding reported", "not configured", "none reported", "unavailable"} { + if !strings.Contains(out, want) { + t.Errorf("missing %q in %s", want, out) + } + } + for _, raw := range []string{"bad", "[]", "[{}]", "[{\"Id\":\"one\"},{\"Id\":\"two\"}]"} { + if _, err := Overview(raw, false); err == nil { + t.Fatal("accepted invalid inspection", raw) + } + } +} diff --git a/internal/dashboard/testdata/docker-inspect.json b/internal/dockerinspect/testdata/docker-inspect.json similarity index 100% rename from internal/dashboard/testdata/docker-inspect.json rename to internal/dockerinspect/testdata/docker-inspect.json diff --git a/internal/hostgpu/gpu.go b/internal/hostgpu/gpu.go new file mode 100644 index 0000000..10da656 --- /dev/null +++ b/internal/hostgpu/gpu.go @@ -0,0 +1,109 @@ +// Package hostgpu collects optional, read-only GPU telemetry. +package hostgpu + +import ( + "context" + "encoding/csv" + "math" + "os" + "path/filepath" + "strconv" + "strings" + "time" + + "systemdoc/internal/commandcapture" + "systemdoc/internal/textutil" +) + +// Device uses -1 for unavailable readings. Memory values are bytes. +type Device struct { + ID string // Stable backend identity; names alone are not unique. + Name string + Busy, Temperature float64 + MemoryUsed, MemoryTotal int64 +} + +// Collect bounds optional CLI work so a broken driver cannot stall sampling. +func Collect(ctx context.Context) []Device { + ctx, cancel := context.WithTimeout(ctx, time.Second) + defer cancel() + raw, _, err := commandcapture.Capture(ctx, "nvidia-smi", "--query-gpu=index,name,utilization.gpu,memory.used,memory.total,temperature.gpu", "--format=csv,noheader,nounits") + var devices []Device + if err == nil { + devices = parseNVIDIA(raw) + } + return append(devices, collectDRM("/sys/class/drm")...) +} + +func number(raw string) float64 { + n, err := strconv.ParseFloat(strings.TrimSpace(raw), 64) + if err != nil || math.IsNaN(n) || math.IsInf(n, 0) || n < 0 { + return -1 + } + return n +} + +func parseNVIDIA(raw string) []Device { + r := csv.NewReader(strings.NewReader(raw)) + r.TrimLeadingSpace = true + r.FieldsPerRecord = -1 + var devices []Device + for { + row, err := r.Read() + if err != nil { + break + } + if len(row) != 6 || number(row[0]) < 0 { + continue + } + d := Device{ID: "nvidia:" + strings.TrimSpace(row[0]), Name: textutil.Clean(strings.TrimSpace(row[1])), Busy: number(row[2]), MemoryUsed: -1, MemoryTotal: -1, Temperature: number(row[5])} + if d.Busy > 100 { + d.Busy = -1 + } + if n := number(row[3]); n >= 0 && n < float64(math.MaxInt64)/(1024*1024) { + d.MemoryUsed = int64(n * 1024 * 1024) + } + if n := number(row[4]); n > 0 && n < float64(math.MaxInt64)/(1024*1024) { + d.MemoryTotal = int64(n * 1024 * 1024) + } + devices = append(devices, d) + } + return devices +} + +func collectDRM(root string) []Device { + paths, _ := filepath.Glob(filepath.Join(root, "card*")) + var devices []Device + for _, path := range paths { + if _, err := strconv.Atoi(strings.TrimPrefix(filepath.Base(path), "card")); err != nil { + continue + } + device := filepath.Join(path, "device") + vendor, _ := os.ReadFile(filepath.Join(device, "vendor")) + // NVIDIA is queried above; never count it twice. + if strings.TrimSpace(string(vendor)) == "0x10de" { + continue + } + read := func(name string) float64 { + raw, err := os.ReadFile(filepath.Join(device, name)) + if err != nil { + return -1 + } + return number(string(raw)) + } + d := Device{ID: "drm:" + filepath.Base(path), Name: filepath.Base(path), Busy: read("gpu_busy_percent"), MemoryUsed: -1, MemoryTotal: -1, Temperature: -1} + if d.Busy > 100 { + d.Busy = -1 + } + if n := read("mem_info_vram_used"); n >= 0 && n < float64(math.MaxInt64) { + d.MemoryUsed = int64(n) + } + if n := read("mem_info_vram_total"); n > 0 && n < float64(math.MaxInt64) { + d.MemoryTotal = int64(n) + } + if d.Busy >= 0 || d.MemoryTotal > 0 { + devices = append(devices, d) + } + } + return devices +} diff --git a/internal/hostgpu/gpu_test.go b/internal/hostgpu/gpu_test.go new file mode 100644 index 0000000..cc2acba --- /dev/null +++ b/internal/hostgpu/gpu_test.go @@ -0,0 +1,48 @@ +package hostgpu + +import ( + "os" + "path/filepath" + "testing" +) + +func TestNVIDIAReadings(t *testing.T) { + devices := parseNVIDIA("0, Example GPU, 42, 512, 8192, 63\n1, Other GPU, [N/A], [Not Supported], 4096, N/A\n2, Bad reading, NaN, Inf, -3, NaN\nmalformed\n") + if len(devices) != 3 { + t.Fatalf("devices: %+v", devices) + } + if d := devices[0]; d.Busy != 42 || d.MemoryUsed != 512*1024*1024 || d.MemoryTotal != 8192*1024*1024 || d.Temperature != 63 { + t.Fatalf("reading: %+v", d) + } + if d := devices[1]; d.Busy != -1 || d.MemoryUsed != -1 || d.MemoryTotal != 4096*1024*1024 || d.Temperature != -1 { + t.Fatalf("missing: %+v", d) + } + if d := devices[2]; d.Busy != -1 || d.MemoryUsed != -1 || d.MemoryTotal != -1 || d.Temperature != -1 { + t.Fatalf("invalid: %+v", d) + } +} + +func TestDRMDevices(t *testing.T) { + root := t.TempDir() + for _, card := range []string{"card0", "card1", "card0-HDMI-A-1"} { + dir := filepath.Join(root, card, "device") + if err := os.MkdirAll(dir, 0755); err != nil { + t.Fatal(err) + } + for name, value := range map[string]string{"vendor": "0x1002", "gpu_busy_percent": "25", "mem_info_vram_used": "1024", "mem_info_vram_total": "8192"} { + if card == "card1" && name == "vendor" { + value = "0x10de" + } + if err := os.WriteFile(filepath.Join(dir, name), []byte(value), 0600); err != nil { + t.Fatal(err) + } + } + } + devices := collectDRM(root) + if len(devices) != 1 || devices[0].Busy != 25 || devices[0].MemoryTotal != 8192 { + t.Fatalf("devices: %+v", devices) + } + if got := collectDRM(filepath.Join(root, "missing")); len(got) != 0 { + t.Fatalf("missing: %+v", got) + } +} diff --git a/internal/hostgpu/process_drm.go b/internal/hostgpu/process_drm.go new file mode 100644 index 0000000..eefe612 --- /dev/null +++ b/internal/hostgpu/process_drm.go @@ -0,0 +1,197 @@ +package hostgpu + +import ( + "context" + "io" + "math" + "os" + "path/filepath" + "strconv" + "strings" + "time" +) + +type drmClient struct { + pid int + device, id string + memory int64 + engines map[string]uint64 + capacity map[string]uint64 +} + +func parseDRMClient(raw string) (drmClient, bool) { + c := drmClient{memory: -1, engines: map[string]uint64{}, capacity: map[string]uint64{}} + driver := "" + resident, legacy, system := int64(-1), int64(-1), int64(-1) + for _, line := range strings.Split(raw, "\n") { + key, rest, ok := strings.Cut(line, ":") + if !ok { + continue + } + value := strings.TrimSpace(rest) + switch key { + case "drm-driver": + driver = value + case "drm-pdev": + c.device = value + case "drm-client-id": + c.id = value + case "drm-resident-vram": + resident = drmMemory(value) + case "drm-memory-vram": + legacy = drmMemory(value) + case "drm-resident-memory": + system = drmMemory(value) + default: + fields := strings.Fields(value) + if len(fields) == 0 { + continue + } + n, err := strconv.ParseUint(fields[0], 10, 64) + if err != nil { + continue + } + if engine, found := strings.CutPrefix(key, "drm-engine-capacity-"); found { + if n > 0 { + c.capacity[engine] = n + } + } else if engine, found := strings.CutPrefix(key, "drm-engine-"); found && len(fields) == 2 && fields[1] == "ns" { + c.engines[engine] = n + } + } + } + // Skip NVIDIA to avoid counting its clients again through pmon. Without + // a client identity duplicate FDs cannot safely be distinguished. + if driver == "" || driver == "nvidia-drm" || driver == "nvidia" || c.id == "" { + return c, false + } + c.memory = resident + if c.memory < 0 { + c.memory = legacy + } + if c.memory < 0 { + c.memory = system + } + return c, true +} + +func drmMemory(raw string) int64 { + fields := strings.Fields(raw) + if len(fields) == 0 || len(fields) > 2 { + return -1 + } + n, err := strconv.ParseUint(fields[0], 10, 64) + multiplier := uint64(1) + if len(fields) == 2 { + switch fields[1] { + case "KiB": + multiplier = 1024 + case "MiB": + multiplier = 1024 * 1024 + default: + return -1 + } + } + if err != nil || n > uint64(math.MaxInt64)/multiplier { + return -1 + } + return int64(n * multiplier) +} + +func collectDRMProcesses(ctx context.Context, root string, pids []int, previous ProcessSample, now time.Time) (map[int]Process, ProcessSample) { + result := map[int]Process{} + next := ProcessSample{at: now, clients: map[string]drmClient{}} + // First deduplicate shared/duplicated FDs within each process. The same + // client can be visible in two processes; these are attributed, not summed + // into a machine total, just like shared process RSS pages. + for _, pid := range pids { + if ctx.Err() != nil { + return result, next + } + base := filepath.Join(root, strconv.Itoa(pid)) + fds, _ := os.ReadDir(filepath.Join(base, "fd")) + if len(fds) == 0 { + continue + } + // Start time prevents a reused PID from borrowing another process's interval. + stat, _ := os.ReadFile(filepath.Join(base, "stat")) + tail := "" + // Commands can contain ') '; the final closing parenthesis terminates comm. + if end := strings.LastIndex(string(stat), ") "); end >= 0 { + tail = string(stat)[end+2:] + } + fields := strings.Fields(tail) + if len(fields) <= 19 { + continue + } + for _, fd := range fds { + if ctx.Err() != nil { + return result, next + } + target, err := os.Readlink(filepath.Join(base, "fd", fd.Name())) + if err != nil || (!strings.HasPrefix(target, "/dev/dri/") && !strings.HasPrefix(target, "/dev/char/226:")) { + continue + } + file, err := os.Open(filepath.Join(base, "fdinfo", fd.Name())) + if err != nil { + continue + } + raw, err := io.ReadAll(io.LimitReader(file, 64*1024+1)) + file.Close() + if err != nil || len(raw) > 64*1024 { + continue + } + client, ok := parseDRMClient(string(raw)) + if !ok { + continue + } + client.pid = pid + + key := strconv.Itoa(pid) + ":" + fields[19] + ":" + client.device + ":" + client.id + if _, duplicate := next.clients[key]; duplicate { + continue + } + next.clients[key] = client + } + } + // Sum each engine's work across this process's clients, then take the + // busiest engine. Concurrent engines must not create a fictitious >100%. + engineBusy := map[int]map[string]float64{} + for key, client := range next.clients { + reading := Process{Busy: -1, MemoryUsed: client.memory} + if old, ok := result[client.pid]; ok { + reading = combineProcess(old, reading) + } + result[client.pid] = reading + before, found := previous.clients[key] + if !found || !now.After(previous.at) { + continue + } + for engine, value := range client.engines { + old, ok := before.engines[engine] + if !ok { + continue + } + if value < old { + // Drivers may temporarily regress; retain the high-water counter. + client.engines[engine] = old + next.clients[key] = client + continue + } + capacity := max(uint64(1), client.capacity[engine]) + share := float64(value-old) / float64(now.Sub(previous.at).Nanoseconds()) / float64(capacity) * 100 + if engineBusy[client.pid] == nil { + engineBusy[client.pid] = map[string]float64{} + } + engineBusy[client.pid][client.device+":"+engine] += share + } + } + for pid, engines := range engineBusy { + reading := result[pid] + for _, share := range engines { + reading.Busy = max(reading.Busy, min(100, share)) + } + result[pid] = reading + } + return result, next +} diff --git a/internal/hostgpu/processes.go b/internal/hostgpu/processes.go new file mode 100644 index 0000000..e41627e --- /dev/null +++ b/internal/hostgpu/processes.go @@ -0,0 +1,101 @@ +package hostgpu + +import ( + "context" + "math" + "strconv" + "strings" + "time" + + "systemdoc/internal/commandcapture" +) + +// Process readings use -1 for unavailable metrics. Busy is the busiest GPU +// engine/device, not a sum of overlapping engine execution times. +type Process struct { + Busy float64 + MemoryUsed int64 // bytes; may include buffers shared with other processes +} + +// ProcessSample holds DRM counters for the next interval. Owned by one collector. +type ProcessSample struct { + at time.Time + clients map[string]drmClient +} + +// CollectProcesses runs only with the Process Explorer's background refresh. +// NVIDIA pmon reports compute and graphics clients; DRM is read without sudo. +func CollectProcesses(ctx context.Context, pids []int, previous ProcessSample) (map[int]Process, ProcessSample) { + result, next := collectDRMProcesses(ctx, "/proc", pids, previous, time.Now()) + ctx, cancel := context.WithTimeout(ctx, 3*time.Second) + defer cancel() + raw, _, err := commandcapture.Capture(ctx, "nvidia-smi", "pmon", "-c", "1", "-s", "um") + if err == nil { + for pid, reading := range parsePMON(raw) { + // NVIDIA's own readings are authoritative for its clients. + if existing, found := result[pid]; found { + reading = combineProcess(existing, reading) + } + result[pid] = reading + } + } + return result, next +} + +func combineProcess(a, b Process) Process { + memory := int64(-1) + if a.MemoryUsed >= 0 && b.MemoryUsed >= 0 && a.MemoryUsed <= math.MaxInt64-b.MemoryUsed { + memory = a.MemoryUsed + b.MemoryUsed + } + return Process{Busy: max(a.Busy, b.Busy), MemoryUsed: memory} +} + +// Locate columns by header: older drivers omit JPEG/OFA/CCPM columns. The +// "mem" column is bandwidth utilisation; only "fb" measures allocated memory. +func parsePMON(raw string) map[int]Process { + result := map[int]Process{} + columns := map[string]int{} + seen := map[string]bool{} + for _, line := range strings.Split(raw, "\n") { + fields := strings.Fields(line) + if len(fields) == 0 { + continue + } + if fields[0] == "#" { + if len(fields) > 2 && fields[1] == "gpu" && fields[2] == "pid" { + columns = map[string]int{} + for i, name := range fields[1:] { + columns[name] = i + } + } + continue + } + get := func(name string) string { + if index, ok := columns[name]; ok && index < len(fields) { + return fields[index] + } + return "" + } + pid, err := strconv.Atoi(get("pid")) + if err != nil || pid <= 0 || number(get("gpu")) < 0 { + continue + } + key := get("gpu") + ":" + get("pid") + if seen[key] { + continue + } + seen[key] = true + reading := Process{Busy: number(get("sm")), MemoryUsed: -1} + if reading.Busy > 100 { + reading.Busy = -1 + } + if memory := number(get("fb")); memory >= 0 && memory < float64(math.MaxInt64)/(1024*1024) { + reading.MemoryUsed = int64(memory * 1024 * 1024) + } + if old, ok := result[pid]; ok { + reading = combineProcess(old, reading) + } + result[pid] = reading + } + return result +} diff --git a/internal/hostgpu/processes_test.go b/internal/hostgpu/processes_test.go new file mode 100644 index 0000000..3e5492e --- /dev/null +++ b/internal/hostgpu/processes_test.go @@ -0,0 +1,99 @@ +package hostgpu + +import ( + "context" + "fmt" + "os" + "path/filepath" + "strings" + "testing" + "time" +) + +func TestPMONColumnsAndMissingReadings(t *testing.T) { + // New and old drivers differ in JPEG/OFA/CCPM columns. mem is a + // bandwidth percentage and must never be presented as allocated VRAM. + for _, raw := range []string{ + "# gpu pid type sm mem enc dec fb command\n# Idx # C/G % % % % MB name\n0 42 C 25 70 0 0 512 worker\n1 42 G 60 12 0 0 128 worker\n1 42 G 60 12 0 0 128 worker\n0 43 G - - - - - compositor\n0 - - - - - - - -\n", + "# gpu pid type sm mem enc dec jpg ofa fb ccpm command\n0 42 C 25 70 0 0 0 0 512 0 worker\n1 42 G 60 12 0 0 0 0 128 0 worker\n0 43 G - - - - - - - - compositor\n", + } { + got := parsePMON(raw) + if len(got) != 2 || got[42].Busy != 60 || got[42].MemoryUsed != 640*1024*1024 { + t.Fatalf("process readings: %+v", got) + } + if got[43].Busy != -1 || got[43].MemoryUsed != -1 { + t.Fatalf("missing: %+v", got[43]) + } + } + got := parsePMON("# gpu pid type sm mem command\n0 42 C 0 90 worker\n") + if got[42].Busy != 0 || got[42].MemoryUsed != -1 { + t.Fatalf("memory utilisation was mistaken for VRAM: %+v", got) + } +} + +func TestDRMProcessIntervalsDeduplicateFDsAndHandleCounterRegression(t *testing.T) { + root := t.TempDir() + base := filepath.Join(root, "42") + for _, name := range []string{"fd", "fdinfo"} { + if err := os.MkdirAll(filepath.Join(base, name), 0755); err != nil { + t.Fatal(err) + } + } + // stat's starttime is field 22; comm can contain spaces and parentheses. + stat := "42 (a command) ) " + strings.Repeat("0 ", 19) + "12345 0\n" + if err := os.WriteFile(filepath.Join(base, "stat"), []byte(stat), 0600); err != nil { + t.Fatal(err) + } + for _, fd := range []string{"3", "4"} { + if err := os.Symlink("/dev/dri/renderD128", filepath.Join(base, "fd", fd)); err != nil { + t.Fatal(err) + } + } + write := func(counter uint64) { + t.Helper() + raw := fmt.Sprintf("drm-driver: amdgpu\ndrm-pdev: 0000:01:00.0\ndrm-client-id: 7\ndrm-memory-vram: 2048 KiB\ndrm-resident-vram: 1024 KiB\ndrm-engine-gfx: %d ns\ndrm-engine-compute: %d ns\ndrm-engine-capacity-compute: 2\n", counter, counter) + for _, fd := range []string{"3", "4"} { + if err := os.WriteFile(filepath.Join(base, "fdinfo", fd), []byte(raw), 0600); err != nil { + t.Fatal(err) + } + } + } + at := time.Unix(100, 0) + write(1_000_000_000) + first, before := collectDRMProcesses(context.Background(), root, []int{42}, ProcessSample{}, at) + if len(first) != 1 || first[42].Busy != -1 || first[42].MemoryUsed != 1024*1024 || len(before.clients) != 1 { + t.Fatalf("first sample/deduplication: %+v (%d clients)", first, len(before.clients)) + } + write(1_500_000_000) + second, before := collectDRMProcesses(context.Background(), root, []int{42}, before, at.Add(time.Second)) + if second[42].Busy != 50 { + t.Fatalf("overlapping engines must not be summed: %+v", second) + } + write(1_200_000_000) + regressed, before := collectDRMProcesses(context.Background(), root, []int{42}, before, at.Add(2*time.Second)) + if regressed[42].Busy != -1 { + t.Fatalf("regression invented a rate: %+v", regressed) + } + write(1_600_000_000) + third, before := collectDRMProcesses(context.Background(), root, []int{42}, before, at.Add(3*time.Second)) + if third[42].Busy != 10 { + t.Fatalf("regression lost high-water counter: %+v", third) + } + if err := os.WriteFile(filepath.Join(base, "stat"), []byte(strings.Replace(stat, "12345", "54321", 1)), 0600); err != nil { + t.Fatal(err) + } + reused, _ := collectDRMProcesses(context.Background(), root, []int{42}, before, at.Add(4*time.Second)) + if reused[42].Busy != -1 { + t.Fatalf("reused PID borrowed an interval: %+v", reused) + } +} + +func TestDRMMalformedFieldsStayUnknown(t *testing.T) { + client, ok := parseDRMClient("drm-driver: amdgpu\ndrm-client-id: 7\ndrm-resident-vram: 9223372036854775807 MiB\ndrm-engine-gfx: broken ns\n") + if !ok || client.memory != -1 || len(client.engines) != 0 { + t.Fatalf("malformed: %+v, %v", client, ok) + } + if _, ok := parseDRMClient("drm-driver: amdgpu\ndrm-engine-gfx: 123 ns\n"); ok { + t.Fatal("missing client identity accepted") + } +} diff --git a/internal/hostnet/README.md b/internal/hostnet/README.md new file mode 100644 index 0000000..f951444 --- /dev/null +++ b/internal/hostnet/README.md @@ -0,0 +1,17 @@ +# Host networking + +This package owns socket parsing and collection, socket filters, interface +enumeration, byte counters and rate calculations. It has no terminal UI state. + +- `CollectSockets` collects Linux `ss` or macOS/fallback `lsof` records. +- `CollectView` selects sockets, interfaces or byte counters for a view. +- `Rates` compares counters without inventing rates after resets. +- `MatchesSocket`, `Endpoint` and `Binding` support filtering and presentation. +- `ParseSS` exposes parsing with a skipped-row count for partial snapshots. + +Run `go test ./internal/hostnet`. Parser and collector tests live here; terminal +focus, rendering and refresh integration tests remain in +`internal/dashboard/network_test.go`. + +Dependencies: the standard library, `internal/commandcapture` and +`internal/textutil`. Do not import dashboard or terminal widget packages here. diff --git a/internal/dashboard/network_data.go b/internal/hostnet/network.go similarity index 71% rename from internal/dashboard/network_data.go rename to internal/hostnet/network.go index 4f7940b..1e58c52 100644 --- a/internal/dashboard/network_data.go +++ b/internal/hostnet/network.go @@ -1,7 +1,7 @@ -package dashboard +// Package hostnet collects and filters host sockets and interface telemetry. +package hostnet import ( - "bytes" "context" "errors" "fmt" @@ -14,21 +14,24 @@ import ( "strconv" "strings" "time" + + "systemdoc/internal/commandcapture" + "systemdoc/internal/textutil" ) -type networkSocket struct { +type Socket struct { Protocol, Family, State, Local, Remote string Process, User, UID, FD, Inode, Cgroup string PID int } -func (s networkSocket) key() string { +func (s Socket) Key() string { return fmt.Sprintf("%s/%s/%s/%s/%d/%s/%s", s.Protocol, s.Family, s.Local, s.Remote, s.PID, s.FD, s.Inode) } -func (s networkSocket) bound() bool { +func (s Socket) Bound() bool { return s.State == "LISTEN" || s.Protocol == "UDP" && s.State == "UNCONN" } -func socketEndpoint(value string) (string, string) { +func Endpoint(value string) (string, string) { host, port, err := net.SplitHostPort(value) if err == nil { return host, port @@ -38,8 +41,8 @@ func socketEndpoint(value string) (string, string) { } return value, "" } -func socketBinding(address string) string { - host, _ := socketEndpoint(address) +func Binding(address string) string { + host, _ := Endpoint(address) switch host { case "0.0.0.0": return "All IPv4 interfaces" @@ -69,16 +72,9 @@ func socketState(value string) string { var ssOwner = regexp.MustCompile(`\("((?:[^"\\]|\\.)*)",pid=([0-9]+),fd=([0-9]+)\)`) -// parseSS keeps the historical signature: it fails only when the output had -// rows and none of them was a socket. -func parseSS(raw string) ([]networkSocket, error) { - rows, _, err := parseSSTolerant(raw) - return rows, err -} - -// parseSSTolerant skips rows (or owner entries) it cannot read and counts +// ParseSS skips rows (or owner entries) it cannot read and counts // them, so one odd row no longer blanks the whole socket table. -func parseSSTolerant(raw string) (rows []networkSocket, skipped int, err error) { +func ParseSS(raw string) (rows []Socket, skipped int, err error) { for _, line := range strings.Split(raw, "\n") { if strings.TrimSpace(line) == "" { continue @@ -88,8 +84,8 @@ func parseSSTolerant(raw string) (rows []networkSocket, skipped int, err error) skipped++ continue } - s := networkSocket{Protocol: strings.ToUpper(f[0]), State: socketState(f[1]), Local: f[4], Remote: f[5]} - host, _ := socketEndpoint(s.Local) + s := Socket{Protocol: strings.ToUpper(f[0]), State: socketState(f[1]), Local: f[4], Remote: f[5]} + host, _ := Endpoint(s.Local) if strings.Contains(host, ":") { s.Family = "IPv6" } else if host != "*" { @@ -140,15 +136,15 @@ func parseSSTolerant(raw string) (rows []networkSocket, skipped int, err error) // NUL field records keep process names separate from file records and endpoints. // Newline is only stripped at record boundaries, never used as a field delimiter. -func parseLsofSockets(raw string) ([]networkSocket, error) { +func parseLsofSockets(raw string) ([]Socket, error) { if raw == "" { return nil, nil } if !strings.HasSuffix(raw, "\x00\n") && !strings.HasSuffix(raw, "\x00") { return nil, fmt.Errorf("incomplete lsof field output") } - var rows []networkSocket - var process, current networkSocket + var rows []Socket + var process, current Socket hasFile := false seenProcess := false flush := func() error { @@ -190,7 +186,7 @@ func parseLsofSockets(raw string) ([]networkSocket, error) { if err != nil || pid <= 0 { return nil, fmt.Errorf("invalid lsof PID") } - process = networkSocket{PID: pid} + process = Socket{PID: pid} case 'c': process.Process = value case 'u': @@ -225,82 +221,44 @@ func parseLsofSockets(raw string) ([]networkSocket, error) { return rows, nil } -type networkInterface struct { +type Interface struct { Name, Flags, Addresses, Hardware string MTU int } -type networkCounter struct { +type Counter struct { Name string Receive uint64 Transmit uint64 } -type networkRate struct { +type Rate struct { Name string Receive, Transmit float64 ReceiveTotal, SendTotal uint64 Ready bool } -type networkSnapshot struct { +type Snapshot struct { InterfaceAt, CounterAt time.Time - Sockets []networkSocket - Interfaces []networkInterface - Counters []networkCounter + Sockets []Socket + Interfaces []Interface + Counters []Counter Source, CounterSource, Note string At time.Time } -// Preserve the beginning of structured output, and fail visibly on overflow. -// A tail buffer could drop a process header and misattribute subsequent sockets. -type networkBuffer struct { - bytes.Buffer - overflow bool -} - -func (b *networkBuffer) Write(p []byte) (int, error) { - n := len(p) - space := 4*1024*1024 - b.Len() - if n > space { - b.overflow = true - p = p[:space] - } - _, _ = b.Buffer.Write(p) - return n, nil -} -func networkCommand(ctx context.Context, name string, args ...string) (string, string, error) { - ctx, cancel := context.WithTimeout(ctx, 8*time.Second) - defer cancel() - var output, diagnostic networkBuffer - cmd := exec.CommandContext(ctx, name, args...) - cmd.Env = append(os.Environ(), "LC_ALL=C") - cmd.Stdout = &output - cmd.Stderr = &diagnostic - // lsof forks a helper child; if it keeps the pipe open after the parent is - // killed on timeout, Wait must still return or the page's busy flag never - // clears and it stops refreshing for the rest of the session. - cmd.WaitDelay = 2 * time.Second - err := cmd.Run() - if output.overflow || diagnostic.overflow { - return "", "", fmt.Errorf("%s output exceeded 4 MiB; snapshot was not used", name) - } - if ctx.Err() != nil { - return "", "", fmt.Errorf("%s: %w", name, ctx.Err()) - } - return output.String(), clean(diagnostic.String()), err -} -func collectNetworkSockets(ctx context.Context, platform string) (networkSnapshot, error) { - result := networkSnapshot{At: time.Now()} +func CollectSockets(ctx context.Context, platform string) (Snapshot, error) { + result := Snapshot{At: time.Now()} var raw, diagnostic string var err error if platform != "darwin" { result.Source = "ss · host network namespace" - raw, diagnostic, err = networkCommand(ctx, "ss", "-H", "-n", "-a", "-t", "-u", "-p", "-e") + raw, diagnostic, err = commandcapture.Capture(ctx, "ss", "-H", "-n", "-a", "-t", "-u", "-p", "-e") if err == nil { var skipped int - result.Sockets, skipped, err = parseSSTolerant(raw) + result.Sockets, skipped, err = ParseSS(raw) if err != nil { return result, err } - result.Note = joinNote(diagnostic, skippedNote(skipped, "ss rows")) + result.Note = textutil.JoinNote(diagnostic, textutil.SkippedNote(skipped, "ss rows")) enrichNetworkOwners(result.Sockets) return result, nil } @@ -309,7 +267,7 @@ func collectNetworkSockets(ctx context.Context, platform string) (networkSnapsho } } result.Source = "lsof · visible process sockets" - raw, diagnostic, err = networkCommand(ctx, "lsof", "-nP", "-iTCP", "-iUDP", "-F0pcuLftPnT") + raw, diagnostic, err = commandcapture.Capture(ctx, "lsof", "-nP", "-iTCP", "-iUDP", "-F0pcuLftPnT") if err != nil { var status *exec.ExitError if !(errors.As(err, &status) && status.ExitCode() == 1 && (raw != "" || diagnostic == "")) { @@ -329,7 +287,7 @@ func collectNetworkSockets(ctx context.Context, platform string) (networkSnapsho } return result, nil } -func enrichNetworkOwners(rows []networkSocket) { +func enrichNetworkOwners(rows []Socket) { users := map[string]string{} for i := range rows { if rows[i].User != "" || rows[i].UID == "" { @@ -346,12 +304,12 @@ func enrichNetworkOwners(rows []networkSocket) { rows[i].User = name } } -func collectNetworkInterfaces() ([]networkInterface, error) { +func collectNetworkInterfaces() ([]Interface, error) { interfaces, err := net.Interfaces() if err != nil { return nil, err } - var rows []networkInterface + var rows []Interface for _, iface := range interfaces { addresses, err := iface.Addrs() if err != nil { @@ -361,14 +319,14 @@ func collectNetworkInterfaces() ([]networkInterface, error) { for _, address := range addresses { values = append(values, address.String()) } - rows = append(rows, networkInterface{Name: iface.Name, Flags: iface.Flags.String(), Addresses: strings.Join(values, ", "), Hardware: iface.HardwareAddr.String(), MTU: iface.MTU}) + rows = append(rows, Interface{Name: iface.Name, Flags: iface.Flags.String(), Addresses: strings.Join(values, ", "), Hardware: iface.HardwareAddr.String(), MTU: iface.MTU}) } sort.Slice(rows, func(i, j int) bool { return rows[i].Name < rows[j].Name }) return rows, nil } -func parseProcNetDev(raw string) ([]networkCounter, error) { - var rows []networkCounter +func parseProcNetDev(raw string) ([]Counter, error) { + var rows []Counter for _, line := range strings.Split(raw, "\n") { name, values, ok := strings.Cut(line, ":") if !ok { @@ -386,7 +344,7 @@ func parseProcNetDev(raw string) ([]networkCounter, error) { if err != nil { return nil, fmt.Errorf("invalid transmit counter") } - rows = append(rows, networkCounter{Name: strings.TrimSpace(name), Receive: receive, Transmit: transmit}) + rows = append(rows, Counter{Name: strings.TrimSpace(name), Receive: receive, Transmit: transmit}) } if len(rows) == 0 { return nil, fmt.Errorf("no interface counters in /proc/net/dev") @@ -395,9 +353,9 @@ func parseProcNetDev(raw string) ([]networkCounter, error) { return rows, nil } -func parseDarwinNetstat(raw string) ([]networkCounter, error) { +func parseDarwinNetstat(raw string) ([]Counter, error) { indices := map[string]int{} - rows := map[string]networkCounter{} + rows := map[string]Counter{} for _, line := range strings.Split(raw, "\n") { fields := strings.Fields(line) if len(fields) == 0 { @@ -422,10 +380,10 @@ func parseDarwinNetstat(raw string) ([]networkCounter, error) { } current := rows[fields[0]] if receive >= current.Receive && transmit >= current.Transmit { - rows[fields[0]] = networkCounter{Name: fields[0], Receive: receive, Transmit: transmit} + rows[fields[0]] = Counter{Name: fields[0], Receive: receive, Transmit: transmit} } } - result := make([]networkCounter, 0, len(rows)) + result := make([]Counter, 0, len(rows)) for _, row := range rows { result = append(result, row) } @@ -436,9 +394,9 @@ func parseDarwinNetstat(raw string) ([]networkCounter, error) { return result, nil } -func collectNetworkCounters(ctx context.Context, platform string) ([]networkCounter, error) { +func collectNetworkCounters(ctx context.Context, platform string) ([]Counter, error) { if platform == "darwin" { - raw, diagnostic, err := networkCommand(ctx, "netstat", "-ibn") + raw, diagnostic, err := commandcapture.Capture(ctx, "netstat", "-ibn") if err != nil { return nil, fmt.Errorf("netstat: %w\n%s", err, diagnostic) } @@ -451,15 +409,15 @@ func collectNetworkCounters(ctx context.Context, platform string) ([]networkCoun return parseProcNetDev(string(raw)) } -func networkRates(previous []networkCounter, previousAt time.Time, current []networkCounter, currentAt time.Time) []networkRate { - before := map[string]networkCounter{} +func Rates(previous []Counter, previousAt time.Time, current []Counter, currentAt time.Time) []Rate { + before := map[string]Counter{} for _, row := range previous { before[row.Name] = row } elapsed := currentAt.Sub(previousAt).Seconds() - rates := make([]networkRate, 0, len(current)) + rates := make([]Rate, 0, len(current)) for _, row := range current { - rate := networkRate{Name: row.Name, ReceiveTotal: row.Receive, SendTotal: row.Transmit} + rate := Rate{Name: row.Name, ReceiveTotal: row.Receive, SendTotal: row.Transmit} old, ok := before[row.Name] if ok && elapsed > 0 && row.Receive >= old.Receive && row.Transmit >= old.Transmit { rate.Receive = float64(row.Receive-old.Receive) / elapsed @@ -471,8 +429,8 @@ func networkRates(previous []networkCounter, previousAt time.Time, current []net return rates } -func collectNetworkView(ctx context.Context, platform string, view int) (networkSnapshot, error) { - var result networkSnapshot +func CollectView(ctx context.Context, platform string, view int) (Snapshot, error) { + var result Snapshot switch view { case 3: result.CounterSource = "/proc/net/dev · host interface counters" @@ -490,21 +448,21 @@ func collectNetworkView(ctx context.Context, platform string, view int) (network return result, err default: var err error - result, err = collectNetworkSockets(ctx, platform) + result, err = CollectSockets(ctx, platform) result.Interfaces, _ = collectNetworkInterfaces() result.InterfaceAt = time.Now() sortNetworkSockets(result.Sockets) return result, err } } -func sortNetworkSockets(rows []networkSocket) { +func sortNetworkSockets(rows []Socket) { sort.SliceStable(rows, func(i, j int) bool { a, b := rows[i], rows[j] - if a.bound() != b.bound() { - return a.bound() + if a.Bound() != b.Bound() { + return a.Bound() } - _, ap := socketEndpoint(a.Local) - _, bp := socketEndpoint(b.Local) + _, ap := Endpoint(a.Local) + _, bp := Endpoint(b.Local) an, _ := strconv.Atoi(ap) bn, _ := strconv.Atoi(bp) if an != bn { @@ -513,12 +471,12 @@ func sortNetworkSockets(rows []networkSocket) { if a.Protocol != b.Protocol { return a.Protocol < b.Protocol } - return a.key() < b.key() + return a.Key() < b.Key() }) } -func matchesNetworkSocket(s networkSocket, query string) bool { - _, localPort := socketEndpoint(s.Local) - _, remotePort := socketEndpoint(s.Remote) +func MatchesSocket(s Socket, query string) bool { + _, localPort := Endpoint(s.Local) + _, remotePort := Endpoint(s.Remote) for _, term := range strings.Fields(strings.ToLower(query)) { key, value, field := strings.Cut(term, ":") if !field { diff --git a/internal/hostnet/network_test.go b/internal/hostnet/network_test.go new file mode 100644 index 0000000..b672b26 --- /dev/null +++ b/internal/hostnet/network_test.go @@ -0,0 +1,193 @@ +package hostnet + +import ( + "context" + "os" + "path/filepath" + "runtime" + "strings" + "testing" + "time" +) + +const ssFixture = `tcp LISTEN 0 511 127.0.0.1:3000 0.0.0.0:* users:(("node server",pid=42,fd=3),("worker",pid=43,fd=4)) uid:1000 ino:700 cgroup:/user.slice/dev.service +udp UNCONN 0 0 [::]:5353 [::]:* uid:70 ino:701 v6only:1 + tcp ESTAB 0 0 [fe80::1%en0]:50000 [fe80::2%en0]:443 users:(("browser",pid=44,fd=5)) uid:1000 ino:702 + tcp TIME-WAIT 0 0 10.0.0.1:50001 10.0.0.2:443 ino:0 +` +const lsofFixture = "p42\x00cnode server\x00u501\x00Lalice\x00\nf3\x00tIPv6\x00PTCP\x00n[::1]:3000\x00TST=LISTEN\x00\nf4\x00tIPv4\x00PTCP\x00n127.0.0.1:55000->127.0.0.1:5432\x00TST=ESTABLISHED\x00\np44\x00cmDNSResponder\x00u65\x00L_mdnsresponder\x00\nf5\x00tIPv6\x00PUDP\x00n*:5353\x00\nf6\x00tIPv4\x00PUDP\x00n10.0.0.1:5000->10.0.0.2:5000\x00\n" +const procNetDevFixture = `Inter-| Receive | Transmit + face |bytes packets errs drop fifo frame compressed multicast|bytes packets errs drop fifo colls carrier compressed + lo: 1024 10 0 0 0 0 0 0 2048 20 0 0 0 0 0 0 + eth0: 4096 40 0 0 0 0 0 0 8192 80 0 0 0 0 0 0 +` +const darwinNetstatFixture = `Name Mtu Network Address Ipkts Ierrs Ibytes Opkts Oerrs Obytes Coll +lo0 16384 00:00:00:00:00:00 100 0 1024 100 0 2048 0 +en0 1500 aa:bb:cc:dd:ee:ff 200 0 4096 300 0 8192 0 +en0 1500 192.0.2 192.0.2.5 200 - 4096 300 - 8192 - +` + +func TestSSPreservesOwnersAndIPv6(t *testing.T) { + rows, err := parseSS(ssFixture) + if err != nil || len(rows) != 5 { + t.Fatal(rows, err) + } + if rows[0].Process != "node server" || rows[1].PID != 43 || rows[0].Local != rows[1].Local { + t.Fatal("shared ownership lost", rows) + } + if rows[2].PID != 0 || rows[2].Family != "IPv6" || !rows[2].Bound() { + t.Fatal("unowned UDP socket lost", rows[2]) + } + host, port := Endpoint(rows[3].Local) + if host != "fe80::1%en0" || port != "50000" || rows[3].State != "ESTABLISHED" { + t.Fatal(rows[3], host, port) + } + if rows[4].PID != 0 || rows[4].Bound() { + t.Fatal("TIME-WAIT invented an owner", rows[4]) + } + if _, err := parseSS("Cannot open netlink socket"); err == nil { + t.Fatal("diagnostics parsed as sockets") + } +} +func TestSSSkipsOnlyTheBadRows(t *testing.T) { + raw := ssFixture + "Cannot open netlink socket\n" + `tcp LISTEN 0 1 127.0.0.1:9 0.0.0.0:* users:(("ghost",pid=0,fd=1),("real",pid=7,fd=2))` + "\n" + rows, skipped, err := ParseSS(raw) + if err != nil || len(rows) != 6 || skipped != 2 || rows[5].Process != "real" || rows[5].PID != 7 { + t.Fatalf("one bad row discarded the table: %d rows, %d skipped, %v", len(rows), skipped, err) + } + if _, skipped, err := ParseSS("Cannot open netlink socket\nRTNETLINK answers: Operation not permitted"); err == nil || skipped != 2 { + t.Fatal("all-bad output accepted", skipped, err) + } + dir := t.TempDir() + fixture := filepath.Join(dir, "data") + if err := os.WriteFile(fixture, []byte(raw), 0600); err != nil { + t.Fatal(err) + } + t.Setenv("SS_FIXTURE", fixture) + if err := os.WriteFile(filepath.Join(dir, "ss"), []byte("#!/bin/sh\n/bin/cat \"$SS_FIXTURE\"\n"), 0700); err != nil { + t.Fatal(err) + } + t.Setenv("PATH", dir) + result, err := CollectSockets(context.Background(), "linux") + if err != nil || len(result.Sockets) != 6 || !strings.Contains(result.Note, "2 unrecognised ss rows skipped") { + t.Fatalf("skipped count not surfaced: %q %v", result.Note, err) + } +} +func TestLsofRecordsRetainProcessAndSocketBoundaries(t *testing.T) { + rows, err := parseLsofSockets(lsofFixture) + if err != nil || len(rows) != 4 { + t.Fatal(rows, err) + } + if rows[0].Process != "node server" || rows[0].User != "alice" || rows[0].State != "LISTEN" || rows[1].PID != 42 || rows[1].Remote != "127.0.0.1:5432" { + t.Fatal(rows) + } + if rows[2].PID != 44 || rows[2].Process != "mDNSResponder" || rows[2].FD != "5" || rows[2].State != "UNCONN" || rows[3].State != "CONNECTED" { + t.Fatal(rows) + } + for _, raw := range []string{"p42\x00\nf3\x00PTCP\x00npartial", "f3\x00PTCP\x00n*:80\x00\n"} { + if _, err := parseLsofSockets(raw); err == nil { + t.Fatal("broken records accepted") + } + } + if rows, err := parseLsofSockets(""); err != nil || len(rows) != 0 { + t.Fatal(rows, err) + } +} +func TestNetworkFiltersMatchExactPortsAndKeepIPv6(t *testing.T) { + rows, _ := parseSS(ssFixture) + for _, test := range []struct { + query string + want int + }{{"port:3000", 2}, {"port:300", 0}, {"remoteport:443", 2}, {"localport:443", 0}, {"process:node pid:42", 1}, {"proto:udp", 1}, {"state:estab", 1}, {"local:[fe80::1%en0]:50000", 1}, {"family:ipv6", 2}, {"service:dev.service", 2}, {"unknown:x", 0}} { + count := 0 + for _, row := range rows { + if MatchesSocket(row, test.query) { + count++ + } + } + if count != test.want { + t.Fatal(test.query, count, test.want) + } + } + if Binding("[::1]:80") != "Loopback address" || Binding("0.0.0.0:80") != "All IPv4 interfaces" { + t.Fatal("bind classification incorrect") + } +} +func TestNetworkCollectorSeparatesDiagnosticsAndEmptyResults(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "lsof") + if err := os.WriteFile(path, []byte("#!/bin/sh\nexit 1\n"), 0700); err != nil { + t.Fatal(err) + } + t.Setenv("PATH", dir) + result, err := CollectSockets(context.Background(), "darwin") + if err != nil || len(result.Sockets) != 0 { + t.Fatal(result, err) + } + if err := os.WriteFile(path, []byte("#!/bin/sh\necho 'permission denied' >&2\nexit 1\n"), 0700); err != nil { + t.Fatal(err) + } + if _, err := CollectSockets(context.Background(), "darwin"); err == nil { + t.Fatal("permission failure became an empty table") + } + fixture := filepath.Join(dir, "data") + if err := os.WriteFile(fixture, []byte(lsofFixture), 0600); err != nil { + t.Fatal(err) + } + t.Setenv("NETWORK_FIXTURE", fixture) + if err := os.WriteFile(path, []byte("#!/bin/sh\n/bin/cat \"$NETWORK_FIXTURE\"\n"), 0700); err != nil { + t.Fatal(err) + } + result, err = CollectSockets(context.Background(), "linux") + if err != nil || len(result.Sockets) != 4 || !strings.Contains(result.Note, "ss is unavailable") { + t.Fatal(result, err) + } + if err := os.WriteFile(path, []byte("#!/bin/sh\n/bin/cat \"$NETWORK_FIXTURE\"\nexit 1\n"), 0700); err != nil { + t.Fatal(err) + } + result, err = CollectSockets(context.Background(), "darwin") + if err != nil || len(result.Sockets) != 4 || !strings.Contains(result.Note, "partial results") { + t.Fatal("lsof partial selection discarded", result, err) + } + if err := os.WriteFile(filepath.Join(dir, "ss"), []byte("#!/bin/sh\necho 'netlink denied' >&2\nexit 1\n"), 0700); err != nil { + t.Fatal(err) + } + if _, err := CollectSockets(context.Background(), "linux"); err == nil { + t.Fatal("ss failure silently fell back") + } +} +func TestNetworkSpeedCountersAndRates(t *testing.T) { + linux, err := parseProcNetDev(procNetDevFixture) + if err != nil || len(linux) != 2 || linux[0].Name != "eth0" || linux[0].Receive != 4096 || linux[0].Transmit != 8192 { + t.Fatal(linux, err) + } + darwin, err := parseDarwinNetstat(darwinNetstatFixture) + if err != nil || len(darwin) != 2 || darwin[0].Name != "en0" || darwin[0].Receive != 4096 || darwin[0].Transmit != 8192 { + t.Fatal(darwin, err) + } + start := time.Date(2026, time.September, 12, 12, 0, 0, 0, time.UTC) + rates := Rates([]Counter{{Name: "eth0", Receive: 1000, Transmit: 2000}}, start, []Counter{{Name: "eth0", Receive: 4000, Transmit: 6000}, {Name: "new0", Receive: 500, Transmit: 600}}, start.Add(2*time.Second)) + if len(rates) != 2 || !rates[0].Ready || rates[0].Receive != 1500 || rates[0].Transmit != 2000 || rates[1].Ready { + t.Fatal(rates) + } + reset := Rates([]Counter{{Name: "eth0", Receive: 5000, Transmit: 5000}}, start, []Counter{{Name: "eth0", Receive: 1, Transmit: 2}}, start.Add(time.Second)) + if reset[0].Ready { + t.Fatal("counter reset produced an invented speed", reset) + } +} + +func TestNetworkSpeedCollectorDoesNotRequireCommands(t *testing.T) { + if runtime.GOOS != "linux" { + t.Skip("Linux speed counters come directly from /proc/net/dev") + } + t.Setenv("PATH", t.TempDir()) + snapshot, err := CollectView(context.Background(), "linux", 3) + if err != nil || len(snapshot.Counters) == 0 || snapshot.CounterAt.IsZero() { + t.Fatalf("speed collector unexpectedly required an external command: counters=%d err=%v", len(snapshot.Counters), err) + } + if len(snapshot.Sockets) != 0 || len(snapshot.Interfaces) != 0 { + t.Fatal("speed collector performed unrelated network collection") + } +} + +func parseSS(raw string) ([]Socket, error) { rows, _, err := ParseSS(raw); return rows, err } diff --git a/internal/textutil/text.go b/internal/textutil/text.go new file mode 100644 index 0000000..424400e --- /dev/null +++ b/internal/textutil/text.go @@ -0,0 +1,53 @@ +// Package textutil cleans external command output and formats plain text. +package textutil + +import ( + "fmt" + "regexp" + "strings" +) + +var ansiSequence = regexp.MustCompile(`\x1b(?:\[[0-?]*[ -/]*[@-~]|\][^\x07\x1b]*(?:\x07|\x1b\\)|[@-_])`) + +// Clean removes ANSI escapes and control characters, preserving tabs, newlines +// and printable Unicode. Terminal markup must still be escaped by the caller. +func Clean(value string) string { + value = ansiSequence.ReplaceAllString(value, "") + return strings.Map(func(r rune) rune { + if r == '\n' || r == '\t' || r >= 32 && r != 127 && !(r >= 128 && r <= 159) { + return r + } + return -1 + }, value) +} + +// JoinNote concatenates the non-empty diagnostics with the status separator. +func JoinNote(parts ...string) string { + var kept []string + for _, part := range parts { + if strings.TrimSpace(part) != "" { + kept = append(kept, strings.TrimSpace(part)) + } + } + return strings.Join(kept, " · ") +} + +// SkippedNote describes tolerated parser rows; "" when nothing was skipped. +func SkippedNote(skipped int, what string) string { + if skipped <= 0 { + return "" + } + return fmt.Sprintf("%d unrecognised %s skipped", skipped, what) +} + +// Argv quotes arguments for display, returning a dash when none were reported. +func Argv(args []string) string { + if len(args) == 0 { + return "—" + } + parts := make([]string, len(args)) + for i, arg := range args { + parts[i] = fmt.Sprintf("%q", arg) + } + return strings.Join(parts, " ") +}