runtop — data contract¶
This document describes target states, normalization and the
runtop.snapshot/v1 JSON document shared by both editions.
Targets¶
| kind | key | source | writable |
|---|---|---|---|
lima |
lima:<name> |
limactl list --format json (one object per line) |
yes |
colima |
colima:<profile> |
colima list --json (engine profiles, including stopped ones); $COLIMA_HOME/<profile>/docker.sock or ~/.colima/<profile>/docker.sock |
yes |
host |
host |
$DOCKER_HOST unix://…, else $XDG_RUNTIME_DIR/docker.sock (rootless), else /var/run/docker.sock (runtop on servers) |
yes |
host |
local:<name> |
engine contexts with unix:// endpoints |
yes |
context |
ctx:<name> |
docker context ls --format json, endpoints ssh:// or tcp:// only |
never |
- Managed VM socket:
<dir>/sock/docker.sock. Missing file → stateno_docker_socket(CI VMs without an engine), not "unreachable". - Managed VM fields used:
name status dir vmType arch cpus memory disk(bytes;diskis the cap). - Host allocation:
du -sk <dir>× 1024, optionally measured at most every 30 s. Interactive monitoring does not run this scan. This is not guest filesystem usage. - Discover sources independently, keep successful results alongside errors, and deduplicate local sockets by canonical path. Prefer managed VM metadata over context aliases.
- Managed profile lifecycle uses
colima start|stop --profile <profile>; managed VM useslimactl. Profiles configured for other engine types are not targets.
Target states (UI must render each distinctly; a loader is never an empty list)¶
loading (no data yet) · ok · vm_stopped · no_docker_socket · unreachable
(daemon request failed; show error) · no_targets. Within ok: empty (no containers) and
no_match (filter hides everything) are separate states.
Engine API (local sockets)¶
HTTP over the unix socket. GET /version → prefix all calls with /v<ApiVersion>.
Endpoints: /_ping, /version, /containers/json?all=1, /containers/{id}/json,
/containers/{id}/stats?stream=false&one-shot=true, /containers/{id}/logs,
POST /containers/{id}/start|stop?t=10|restart?t=10, DELETE /containers/{id}?force=1,
/images/json, POST /images/prune?filters={"dangling":["true"]}.
Container normalization¶
| field | rule |
|---|---|
id |
first 12 chars of Id |
name |
Names with leading / trimmed, sorted, joined with , |
image |
Image verbatim |
state |
State lower-cased (running, exited, created, paused, restarting, dead) |
status |
Status verbatim (Up 2 hours (healthy), Exited (1) 3 hours ago) |
health |
from Status: (healthy)→healthy, (unhealthy)→unhealthy, (health: starting)→starting, else null |
exit_code |
from Status Exited (N) → N, else null |
project |
label com.docker.compose.project, else "" (UI label: standalone) |
service |
label com.docker.compose.service, else "" |
ports |
per entry: PublicPort≠0 → "<public>-><private>"; else PrivatePort≠0 → "<private>". Dedupe (IPv4+IPv6 duplicate), sort numerically by (public-or-private, private) |
Container order: by project, then name (the UI may re-sort within groups).
Image normalization¶
| field | rule |
|---|---|
id |
Id without sha256:, first 12 chars |
ref |
first RepoTags entry that is not <none>:<none>, with any @sha256:… suffix removed; else first RepoDigests as <repo>@<first 12 hex of digest>; else <none>:<none> |
size_bytes |
Size |
containers |
Containers (-1 → null) |
dangling |
RepoTags empty or only <none>:<none> |
Image order: size_bytes descending.
Stats¶
Given a sample s and a previous sample p (p = s.precpu_stats when the daemon
filled it, i.e. stream=false without one-shot; otherwise the previous one-shot
sample of the same container kept client-side):
cpu_delta = s.cpu_stats.cpu_usage.total_usage - p.cpu_usage.total_usage
sys_delta = s.cpu_stats.system_cpu_usage - p.system_cpu_usage
ncpu = s.cpu_stats.online_cpus or len(s.cpu_stats.cpu_usage.percpu_usage) or 1
cpu_percent = cpu_delta / sys_delta * ncpu * 100 if sys_delta > 0 and cpu_delta >= 0
= null otherwise (first one-shot sample)
mem_bytes = max(0, memory_stats.usage - cache)
cache = memory_stats.stats.inactive_file (cgroup v2)
or memory_stats.stats.total_inactive_file (cgroup v1) or 0
mem_limit_bytes = memory_stats.limit
percpu_usage is empty on cgroup v2 — counting it under-reports CPU
by the core count. Raw usage includes page cache — subtracting it matches docker stats.
Stats are fetched only for running containers, max 8 concurrent, with a 4 s budget
for the complete stats round including queued requests. Unavailable samples remain unknown.
Logs¶
stdout=1&stderr=1&tail=200[&follow=1][×tamps=1]. If the container's
Config.Tty is false the body is multiplexed: repeated 8-byte headers
[stream, 0, 0, 0, size uint32 big-endian] (stream 1 = stdout, 2 = stderr) followed
by size bytes; frames may split lines. TTY containers send raw bytes. Split on \n,
drop a trailing \r. Blank lines are kept; a final line without \n is still emitted
(per stream). UIs strip non-SGR escape sequences before rendering.
Remote contexts (docker CLI, read-only)¶
Every call is docker --context <name> <verb> … with verb in the allowlist
ps images logs inspect stats version. Anything else is a programming error and must
raise/refuse before spawning. Remote stats are not polled (each call is an ssh round trip).
Target discovery (docker context ls --format json) runs without --context and is the
only CLI call outside this allowlist.
ps -a --format json:ID Names Image State Status Labels Ports.Labelsisk=v,k=v;Portslike0.0.0.0:8080->80/tcp, :::8080->80/tcp, 9000/tcp→ pairs(\d+)->(\d+)deduped, plus bare(\d+)/(tcp|udp)ports that are not either side of a pair; same numeric sort as the Engine rule.images --format json:Repository Tag ID Size Containers.Sizeis decimal (376MB= 376·10⁶,22.6kB,1.02GB,1.5TB). Repository<none>→ dangling.logs --tail 200 [-f]: read stdout and stderr.
Snapshot JSON — runtop.snapshot/v1¶
Used by spec/fixtures/snapshots/*.json, runtop --demo, runtop --dump lima:docker,
and the fixture/snapshot tests. snake_case; decoders ignore unknown fields.
{
"schema": "runtop.snapshot/v1",
"generated_at": "2026-09-12T20:31:00Z",
"targets": [
{
"key": "lima:docker", "kind": "lima", "name": "docker", "read_only": false,
"endpoint": "unix:///Users/demo/.lima/docker/sock/docker.sock",
"state": "ok", "error": null,
"vm": {"status": "Running", "vm_type": "vz", "arch": "aarch64", "cpus": 4,
"memory_bytes": 4294967296, "disk_bytes": 107374182400, "disk_used_bytes": 22548578304},
"containers": [
{"id": "b2c3d4e5f6a1", "name": "shop-api-1", "image": "shop/api:dev",
"state": "running", "status": "Up 2 hours (healthy)", "health": "healthy", "exit_code": null,
"project": "shop", "service": "api", "ports": ["3000->3000"],
"stats": {"cpu_percent": 2.2, "mem_bytes": 171966464, "mem_limit_bytes": 4294967296}}
],
"images": [
{"id": "9f86d081884c", "ref": "shop/api:dev", "size_bytes": 335544320, "containers": 1, "dangling": false}
]
}
]
}
vmisnullfor targets other than managed machines;disk_used_bytesmay benull(not measured).statsisnullwhen unavailable (not running, remote). A container with a first one-shot sample has astatsobject whosecpu_percentisnull(memory is known).stateis one of the target states above;containers/imagesare[]unlessok.
Optional partial-result fields¶
images_error (string, omitted on success) reports an image-list failure without
invalidating successfully read containers. stale (boolean, omitted when false)
marks retained UI data after a failed daemon refresh; its timestamp remains the last
successful fetch. Stale data does not append resource history and cannot drive mutations.
Cached images may be displayed after an image failure, but pruning is disabled. The
--dump command emits the newly read result; consumers should inspect state and
images_error to detect unavailable or partial data.
On-demand storage¶
GET /system/df is a local read only, requested explicitly with D, with a 30 s timeout.
The app normalizes engine/disk_usage.json using these rules:
- Image layers:
LayersSize, not the sum of image sizes (shared layers counted once). - Containers: sum
SizeRw; unknown if any entry lacks a nonnegative size. - Volumes: sum
UsageData.Size; unknown if any entry lacks a nonnegative size. - Build cache: sum
Size, labelled logical record sizes, which may share data. - Empty lists have count/size zero. Categories are not additive and exclude guest OS, bind mounts and some logs. Unknown is never labelled zero or unused.
- No background accounting or automatic deletion. Pruning only calls the engine’s existing
dangling-image endpoint and reports actual
SpaceReclaimedafterwards.
Project workflows¶
l on a project group combines logs with service/container prefixes and a bounded queue.
Closing the view cancels and joins its readers. s / x / R on a project previews the
listed members, captures their IDs and target, and applies operations to existing containers.
Filters restrict membership; new containers discovered later are not included. All project
operations confirm. These are not project deployments and do not execute project files or
perform dependency ordering. Partial failures are reported per container, with named failures. Remote and stale targets remain non-writable.
Fixtures (spec/fixtures/)¶
engine/— raw Engine responses captured from disposable fixture containers (env values redacted, home/user/private names scrubbed).cli/— rawlimactl list(field subset) anddocker ps/images --format jsonlines.expected/— normalized output of the rules above for those raw fixtures, generated byscripts/gen_expected.py(stdlib reference implementation). The tests assert them.snapshots/demo.json— synthetic multi-target snapshot for--demo, recordings and golden tests. No real hostnames or data.
Action behavior¶
- Context targets are hard read-only: banner, mutating keys/buttons refuse with a reason, and the client layer cannot issue mutating calls.
- Destructive ops confirm: remove container, prune images, stop VM.
- Actions bind to the target + container id captured when the user triggered them — never to whatever is selected when the async work runs.
- Env values are masked by default in any detail view.
Navigation and action keys¶
↑↓/jk move · enter open/toggle · s start · x stop · R restart · X remove ·
e exec · p prune dangling · / filter · o sort · r refresh · ? help · q quit.
Recording (README visuals + design validation)¶
scripts/record.sh <tool> <name> "<keys>" <seconds> [COLSxROWS] runs the tool with
--demo --keys … --quit-after … under asciinema, renders a GIF with agg and extracts PNG
keyframes into assets/<tool>/. Committed assets come from --demo only.
Log archive contract¶
Archives are opt-in JSONL in a user-selected directory. Names match
runtop-<20-digit creation nanoseconds>-<32 hex random digits>.jsonl.
A .runtop-record.lock advisory lock permits one writer per folder. Readers stream
files in name order, tolerate an incomplete trailing line and reject symlinks.
Each v1 record contains version: 1, capture time (ISO UTC), target, container,
name, project, service, stream, text, and truncated (boolean). The serialized
record is at most 64 KiB. Capture time is distinct from runtime timestamps in text.
The default total JSONL budget is 64 MiB, rotated file limit 8 MiB and retention 7 days. The oldest owned archives are removed first; unrelated files are never deleted. Retention is applied by the active writer; no cleanup daemon is installed. Disk I/O runs off the UI event loop and capture uses a bounded queue. A recording is foreground-only and never resumes automatically on startup.
runtop logs record|export|search|status --help defines the full-edition CLI.
Search is literal, result-limited, and emits one record per line with --json.
It returns 0 for matches, 1 for none and 2 for errors. Recording returns nonzero
when any source or disk operation fails.