Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
60 changes: 60 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
@@ -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.
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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/<pid>/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.
Expand Down
17 changes: 17 additions & 0 deletions SPEC.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand All @@ -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.
Expand Down
10 changes: 10 additions & 0 deletions docs/development.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,11 +39,21 @@ 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.
- `playground`: optional real Docker containers.

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.
8 changes: 8 additions & 0 deletions docs/host-panels.md
Original file line number Diff line number Diff line change
@@ -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).
Expand Down
2 changes: 2 additions & 0 deletions docs/usage.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
10 changes: 10 additions & 0 deletions internal/commandcapture/README.md
Original file line number Diff line number Diff line change
@@ -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`.
56 changes: 56 additions & 0 deletions internal/commandcapture/capture.go
Original file line number Diff line number Diff line change
@@ -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
}
15 changes: 15 additions & 0 deletions internal/commandcapture/capture_test.go
Original file line number Diff line number Diff line change
@@ -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")
}
}
Loading
Loading