← Design · c138

research/monitor-tui.md

The monitor TUI: inventory and data provenance

Surveyed: 2026-11-11, by a read-only agent, for this cycle's plan. Nothing was modified, built or run. Source: qcontrol origin/main at 9e6cd55a0 (2026-11-10). The TUI is not on the feat/new-pages checkout; see the index for how to read it again. Standing: a reading at one commit. Line numbers drift. Everything is read from code unless marked (inferred). The snapshot read held cli, crates/qdash/src, crates/qevents, crates/qmonitor, crates/qsupervisor and crates/qworker; qsinks, qconfig, qprocess and qinject were not in it, so what is said about them comes from call sites. Checked afterwards: the TUI was added by Kamal Nasser on 2026-10-16 as qcontrol #782 (2b2051a95, "Add monitor TUI (qcontrol start --tui)"), which the survey could not verify from a snapshot without git history.

Conventions: $Q is the root of qcontrol at that commit, and $T is $Q/cli/src/commands/monitor/ui/terminal.

0. Headline

  • The TUI has no single central feed. It has three in-process inputs, plus two things the UI thread produces itself and disk reads of config.yaml.
  • Only the raw event stream and the installation / matched-process inventory are reachable from another process today.
  • The worker table, full process index, health rows, last error, resource samples, live log stream and event-to-worker attribution have no out-of-process surface.
  • The natural seam is Hooks { view: Publisher, events: qsinks::BoxSink } ($Q/cli/src/commands/monitor/ui/shared/mod.rs:26-29), passed to host::run(stop, Some(hooks)). The plan calls shared/ free of terminal dependencies (plan.md:14-16), and ui/mod.rs:15-17 says both modules are "currently" compiled with tui.
  • None of the snapshot types derive serde, so a wire feed needs new types or derives.

1. Screen inventory

Tabs in key order are Overview, Qcontrol, Workers, Events, Config, Inventory, Log ($T/app/mod.rs:32-50). There is no Resources, Processes or Targets tab:

  • resources/ is the "Qcontrol" tab (its heading reads "Resources").
  • targets.rs is the "Config" tab.
  • config/ builds YAML documents for the pager.
  • processes.rs holds tree helpers shared by two tabs.

Chrome ($T/app/chrome.rs, footer.rs, overlay.rs, input.rs)

  • Minimum size: 100×28, otherwise a one-line "Terminal too small…" message (chrome.rs:21-28).
  • Brand bar: qcontrol badge, monitor, DEV. Right side shows ● live or ◐ paused · N new (click toggles pause) and pid {monitor_pid} · up {uptime} (chrome.rs:58-106).
  • Tab strip: 1 Overview … 7 Log, clickable, with an accent rule under the active tab.
  • Footer: context-sensitive key hints that list only keys that do something now; pause, time, help, quit, clear-filters and close are clickable (footer.rs:17-137).
  • Global keys (input.rs:16-58):
    • 1-7, Tab / Shift-Tab cycle tabs.
    • q and Ctrl-C stop the monitor.
    • ? help, t clock ↔ ages (Go-style durations), space pause.
    • Esc: Log returns to live tail; Events with filters clears them; otherwise go to Overview.
  • Mouse: click, double-click (400 ms), right-click context menu, wheel (3 rows), hover tint. Hit regions are rebuilt every frame.
  • Overlays:
    • Right-click menu.
    • Help, three groups: Navigate, View, Mouse (overlay.rs:156-185).
    • Pager for event JSON or config YAML: line numbers, scrollbar, syntect highlighting. Keys: ↑↓/jk, PgUp/PgDn/space/b, g/G, y copy, esc/q/v close (input.rs:108-130).
    • Toast, bottom right, 2.6 s; "Stopping monitor…" persists.
  • Copy is OSC 52 through the terminal ($T/term.rs:43-45). Open-URL launches the browser as the sudo-invoking user (term.rs:50-107).
  • Pause freezes only the event list at a sequence number (app/mod.rs:158-161). Snapshot-driven panels keep updating.

1. Overview ($T/overview/)

Two columns.

Left column:

  • Workers: live only, two-line items.
    • Line 1: name, W{id} · launch|attach, {rate}/s, status glyph and word.
    • Line 2: optional no harness ·, pid N or not spawned, · proxy :port.
    • Click selects, double-click opens the Workers tab, menu has "Open details" and "Show events".
    • Empty: "No workers. Run qcontrol run -- <harness> or add a target."
  • Throughput: one sparkline per live worker plus monitor, events/s over the last 40 s, with a trailing 10 s rate. Click filters Events by that source (workers.rs:148-241).
  • Latest events: time kind summary. Click selects and pauses, double-click opens the pager, "All events ›" links to Events.

Right column:

  • Health (health.rs):
    • Header counts N failing · N degraded · N ok.
    • ● N healthy › opens a "Subsystems" menu listing every row.
    • Then each non-OK row, a last error row (time and first line), and a dash <url> row (click opens the browser; menu has Open and Copy URL).
  • Event types (last 60 s): horizontal bar chart of the top kinds with a total. Click a bar to filter Events. Empty: "No events yet".
  • Targets: title → behaviors | discover only, right-aligned N installs · N attached or —. "Targets ›" links to Config.

Keys: ↑↓ select worker, enter goes to the Workers tab.

2. Qcontrol ($T/resources/)

  • Title: Resources N processes · X% CPU · Y resident, with the note "CPU is percent of one core".
  • Tree rooted at the monitor pid over its descendants. Columns:
    • Process: qcontrol monitor, worker W{id} · {name}, or exe basename.
    • PID.
    • CPU: 5 s mean.
    • Memory: latest RSS.
    • CPU history: inline 24 s sparkline on a fixed scale of at least 10%.
  • Detail for the selected process:
    • pid N · graphs show the last {2×width}s.
    • CPU X% now.
    • Averages over 1m / 5m / 10m / 30m; a partial window is shown muted with (Ns).
    • Braille CPU line chart (5 s rolling mean) and memory line chart.
    • Empty: "No samples yet".
  • Keys: ↑↓/jk, ←→/hl collapse or expand, enter toggles. Wheel scrolls.
  • Covered harnesses are deliberately not in this view (resources/mod.rs:1-5).

3. Workers ($T/workers/)

  • List: live workers plus up to 8 departed ones, dimmed with gone {ts}. Each shows name, glyph, W{id} · kind [· no harness] [· rate/s]. Menu: Open details, Copy pid, Show events.
  • Detail (detail.rs:22-249):
    • Title with since {first transition} and status.
    • Owner line: qcontrol run · pid N, target X, or explicit opt-in.
    • Lifecycle timeline (timeline.rs): launch is prepare → ready → running → exited; attach is prepare → ready → detached. Each phase shows its duration, and the current setup phase counts up.
    • Process facts: harness|process as pid and basename; signing chips (signed / ad-hoc / unsigned, hardened, library validation, no DYLD env); preload and inject verdict chips (try or ✗ code); worker pid; cwd; capture (enrolled → addr or not enrolled).
    • Behavior facts: chosen by, proxy (off, or mitm / no mitm with address), inject (on · file · exec or off).
    • Plugins: ✓ loaded or × reason from load events; otherwise the requested list; otherwise "recipe only; no load events seen yet".
    • Events (last 60 s): bar chart for this worker, with "Show all ›".
    • Process tree rooted at the target pid: name with W{id} / no harness / unattached badges, pid, 60 s event count. Empty: "Not spawned yet".
  • Empty state: "No workers" with a hint.
  • Keys: only ↑↓ select a worker. The process tree is mouse-only (workers/mod.rs:242-261).

4. Events ($T/events/)

  • Filter sidebar:
    • Source chips: each worker, monitor, other.
    • Types grouped by family with 60 s counts. Click toggles a kind, double-click solos it, clicking a family toggles the group, wheel scrolls.
    • "Clear" link.
  • Table, newest first: Time/Age, Source, Type, Summary, with scrollbar.
    • Title Raw events N matching.
    • State shows ● newest first or ◐ paused · N new above Resume ›.
  • Inspect pane: event, summary, when, process (pid basename args, a link when a worker covers it), size in bytes, and "View payload v". Empty: "Select an event. Double-click or press v…".
  • Keys: ↑↓/jk select (this pauses), enter/v pager, y copy JSON, esc clear filters.
  • Wheel scrolls and pauses; reaching the top resumes.
  • Menu: View payload, Only {kind}, Only {source}, Open worker.
  • There is no text search.

5. Config ($T/targets.rs)

  • Targets table: #, Target, Match (rulekit source, quoted strings coloured), Behaviors, Harness. Header N · first match wins.
  • Behaviors table: Name, Network, File, Exec, Inject, Env, Plugins. Bright values are the behavior's own, muted come from the base. Behaviors of the selected target are highlighted.
  • Selected target detail: title → behaviors id X, matching Installations (name, path), and Attached workers (links).
  • Empty states: "No targets configured.", "none match…", "none right now", "none · this target never attaches".
  • Keys: ↑↓ target, enter/v opens the target YAML pager. Clicking a behavior opens a pager with "As written" plus "Effective" YAML. Menu: View config, Copy match.

6. Inventory ($T/inventory.rs)

  • Title: N harnesses · M matched processes.
  • Harnesses table: Harness, Path, Target, Behaviors, Running (count), Worker (W{id} link).
  • Other matched processes table: Binary, PID, Target, Behaviors, Worker.
  • One selection walks both tables. Enter or double-click opens the worker. Menu: Open worker, Copy path / Copy pid. Empty: "None".

7. Log ($T/log.rs)

  • tui-logger widget showing HH:MM:SS LEVEL target message.
  • Captured stderr appears under target stderr.
  • ←/→ level, PgUp/PgDn history, esc live tail, wheel pages.
  • No text search and no target filter.

2. Data provenance

Feed A: MonitorView snapshot

  • Transport: tokio::sync::watch<Arc<MonitorView>>.
  • Producer: Publisher::build, on the monitor's host loop (shared/build.rs:10-97).
  • Call sites: once per loop pass ($Q/cli/src/commands/monitor/host.rs:436-449), and only when the TUI hooks are present.
  • Rate limit: PUBLISH_INTERVAL = 250 ms, with an "owed" wake-up (shared/mod.rs:32, 239-264).
  • UI side: adopts it in ui_loop ($T/mod.rs:107-115).
Field (shared/mod.rs:35-53)TypeSource in the monitor
monitor_pid, started_atu32, SystemTimestd::process::id(); time the Publisher was created
workersVec<WorkerRow { view: WorkerView, target: Option<String>, capture: Option<SocketAddr> }>Workers::view(); matched_target(attach_key(id)); capture.enrollment(key).destination
processesArc<BTreeMap<u32, ProcessRow { pid, ppid, exe, args }>>Publisher::observe folds every qprocess::Event (host.rs:478-481); exists only because of the TUI
liveVec<LiveRow { pid, exe, harness, target }>ProcessMonitorState::live(), classified(), matched_target()
installationsVec<InstallationRow { name, path, target }>InventoryState.installations (agent.name, app_path or executable_path, tap.rule)
targetsVec<TargetRow { id, title, matcher, behaviors, harness }>file_config().targets
behaviorsVec<BehaviorRow { name, network, file, exec, inject, plugins, env: Setting { value, set }, effective: qconfig::ResolvedBehavior }>each behavior resolved alone onto the base (build.rs:102-153)
healthVec<HealthRow { name: &'static str, status: Ok/Degraded/Failing, detail }>see below
last_errorOption<(SystemTime, String)>the loop's sticky last_error, stamped on first sight
dash_url, config_includeOption<String>, Vec<String>dash subsystem handle; config.include

Health rows (build.rs:156-215):

  • process tracker: Ok with {N} processes, or Failing.
  • containers: appears only when admission is degraded.
  • tap watcher: always Ok; the detail says watching or idle.
  • dash: appears only when there is a URL.
  • One route row per router destination, with its drop count.

WorkerView ($Q/crates/qmonitor/src/workers/telemetry.rs:152-174):

  • Fields: id: LaunchId(u64), kind: Launch|Attach, worker_pid, target_pid, client_pid, adapter_id (__none for non-harness), program, cwd, behavior: WorkerBehavior { plugins, proxy, inject }, proxy_addr, container_id, transitions: Vec<Transition { phase: Prepare|Ready|Started|Exited, at: SystemTime }>.
  • Reaped launches are omitted from the view.
  • Departed workers and the final "exited" / "detached" step are synthesised by the TUI by diffing snapshots ($T/app/mod.rs:126-142, timeline.rs:51-53).

Feed B: public events

  • Transport: router route tui (Route::unfiltered, $Q/cli/src/commands/monitor/events.rs:33-35) into TuiSink, a std::sync::mpsc::sync_channel that drops with try_send when full.
  • Buffers ($T/feed.rs:17-19): CHANNEL_DEPTH = 10_000; EventStore keeps HISTORY = 5_000 rows.
  • FeedEntry: { kind, at, pid, raw: Arc<[u8]> }.
  • EventRow: { seq, at, kind, pid, summary, raw }.
    • pid is parsed from entity_id (pid:<pid>:start:<secs>).
    • summary joins a fixed list of payload keys (feed.rs:142-157).
  • It is the same record stream the dash route receives.

Everything below is derived on the UI thread from those 5,000 rows:

  • Throughput: 40 one-second buckets.
  • Rates: 10 s.
  • Event mix and per-pid counts: 60 s.
  • Plugin status: scanning run.plugin_load_success|failure payloads.
  • Source attribution: source_of walks ppid through view.processes to a worker's target_pid or worker_pid, at most 64 hops, cached per snapshot (app/mod.rs:202-231).

Feed C: log

  • A tui_logger::TuiTracingSubscriberLayer is added to the tracing registry under the same EnvFilter ($Q/cli/src/logging.rs:30-36, 74-75).
  • Process stderr is swapped for a pipe whose lines are re-logged as tracing::warn!(target: "stderr") ($T/term.rs:117-135).
  • The filter defaults to Info when QCONTROL_LOG is unset ($T/mod.rs:45-49).
  • Ring size is the tui-logger default; the code sets none.

UI-thread local

  • Resources: sysinfo refresh_pids_specifics(cpu + memory) for the monitor pid and its descendants in view.processes.
    • SAMPLE_EVERY = 1 s, HISTORY = 1800 samples per process ($T/resources/sample.rs:13-14, 39-64).
    • The table shows a 5 s mean.
  • Eligibility chips: qinject::check_eligibility(exe), read once per binary path and cached ($T/workers/mod.rs:46-52).
  • Config pager: reads config.yaml, its include files or the stock document from disk on demand, and re-emits YAML ($T/config/mod.rs:74-143).

Cadences

  • Input poll: 50 ms.
  • Redraw: on change, or every TICK = 250 ms.
  • Host loop: install pass every 5 min, maintenance every 3 s, macOS recovery every 5 s (host.rs:41-45).

3. In-process vs out-of-process

Out-of-process surfaces that exist today:

  • Control socket: <data_dir>/monitor.control.sock (Windows \\.\pipe\qcontrol-monitor-<user>).
    • Owner-only, one JSON request per line.
    • MonitorRequest has only Snapshot, Shutdown, CheckUpdate, ApplyUpdate { force } ($Q/cli/src/commands/monitor/control.rs:87-105).
    • Snapshot is {"service":"monitor","payload":{"op":"snapshot"}}. It returns a bare ack and republishes installation.snapshot plus process.snapshot on the bus (dispatch.rs:475-490). Floods are limited to 2 per 500 ms.
    • The session service is for qcontrol run and MCP.
    • No message returns state.
  • Lock files:
    • <data_dir>/start.lock: <pid>\n[manager=<id>\n]<sink>….
    • <data_dir>/dash/qdash.sock.lock: <pid>\n<dashboard url>\n.
  • qdash loopback HTTP: default 127.0.0.1:7878, falling back to an ephemeral port.
    • GET /api/events/stream is SSE of every public event ($Q/crates/qdash/src/web.rs:62, 295-317). It has no replay, a 1024-record per-subscriber buffer, and lag disconnects. Attaching triggers the same inventory resync.
    • REST over SQLite projections includes /api/installations, /api/agents, /api/sessions, /api/harnesses, /api/stats and others.
  • macOS tray feed: <data_dir>/monitor.ui.sock, NDJSON of ScanEvent only (installation., process.), broadcast capacity 256. It is documented as private to the tray worker (ui/feed.rs:1-9).
  • Configured sinks (file or socket URLs), and the log file.
TUI datumReachable out of process today?
Raw event stream (Events tab, all derived rates and mixes)Yes: SSE or sinks. Live only; the 5,000-row history is the TUI's own.
Installations with matched target and behaviorsYes: installation.snapshot / discovered (tap.rule, tap.policies, tap.status), or REST.
Matched live processes with harness and targetYes: process.snapshot / started / stopped (agent, rule, ppid, argv, cwd).
Monitor pid, sink URLs, dash URLYes: lock files.
Targets and behaviorsNo wire. Rebuildable from config.yaml with qconfig, but that is the disk view, not what the monitor has loaded.
Plugin load statusPartly: run.plugin_load_* on the bus. Success is Debug severity ($Q/crates/qevents/src/run_diagnostic.rs:49), so it is probably filtered at default level (inferred).
Worker table (WorkerView, target, capture, proxy address, lifecycle timestamps)No. Workers::view() is compiled only under telemetry.
Full process index (pid → ppid, exe, args for every tracked process)No. Public process events cover only classified or matched processes.
Health rows (tracker, container admission, tap watcher, per-route drops)No.
last_errorNo.
Monitor start time / uptimeNo. Only derivable from the pid.
Event-to-worker attributionNo. The envelope carries only entity_id; plan.md defers a run or worker id as a schema change.
Resource samplesNo producer exists. A user-level app could sample itself, but the monitor runs elevated, and on macOS reading another user's task info needs privilege (inferred, verify).
Live log lines and stderrNo stream. Only the log file (QCONTROL_LOG_FILE, else temp_dir()/qcontrol.log). Outside TUI mode its default filter is qcontrol=error and eprintln! output does not reach it.
Eligibility chipsNo feed. Computable by any process that links qinject and can read the binary.
Departed workers and the final lifecycle stepNo. Synthesised by the TUI.

4. Cost

Neither plan.md nor the code gives a CPU or RAM figure for the TUI. The rationale is qualitative: dev tooling, no compatibility promise, never blocks the loop.

Work that happens only when --tui runs:

  • MonitorView builds on the host loop at up to 4 Hz. Each build resolves every behavior and clones workers, live processes and installations.
  • The full process index is maintained.
  • The tui route is added to the router.
  • The UI thread samples sysinfo at 1 Hz.
  • The log layer and stderr pipe thread are installed.
  • Each event is JSON-parsed once on arrival, and all aggregates are recomputed from the 5,000 rows every frame.

Compiled in but trivial: the telemetry trace fields in qmonitor are recorded in any tui build, whether or not --tui is passed.

5. Gate mechanics

  • Declaration ($Q/cli/Cargo.toml:9-21): tui enables optional deps ratatui, tui-tree-widget, tui-logger, serde-saphyr, crossterm (osc52), go-duration, bytesize, ratatui-cheese, tui-syntax-highlight, syntect, plus qmonitor/telemetry. sysinfo and chrono are already unconditional.
  • Build ($Q/Makefile:126-138, 161-162): make build-dev runs cargo build -p qcontrol --features "tui …" (debug profile). make build is --release without it.
  • Cuts:
    • ui/mod.rs:22-25 gates the shared and terminal modules.
    • cli.rs:155 gates the --tui flag itself.
    • lifecycle/mod.rs:36-39 dispatches to terminal::run().
    • logging.rs gates the extra layer.
    • monitor/mod.rs:165 gates start_with_tui.
    • host.rs:47-51 aliases TuiHooks to Infallible when the feature is off; publish, owed-wake and observe are gated at :436, :456, :478.
    • qmonitor gates telemetry.rs and every trace field.
  • Non-TUI path: host::run(stop, None).

6. Keep, drop, improve

Keep:

  • The seven-view split and cross-links (worker ↔ events ↔ target ↔ inventory).
  • Lifecycle timeline with per-phase durations.
  • Bright-versus-inherited behavior values.
  • Problems-first health.
  • Pause with a "N new" count.
  • Clock / age toggle.
  • Payload and YAML viewers with copy.

Drop:

  • Minimum-size screen, hover painting, OSC 52, stderr capture.
  • Footer key legend and numeric tab keys as primary navigation.
  • "Quit stops the monitor".

Improve:

  • Real charts with hover, zoom and a time-range choice.
  • Sortable, resizable tables with text search on Events and Log.
  • Persistent split panes in place of modal pagers.
  • A keyboard-navigable process tree.
  • Longer or disk-backed history.
  • An explicit "worker gone" record instead of snapshot diffing.

7. Surprises and contradictions

  • ui/feed.rs is not the TUI feed. It is the macOS tray feed. The TUI's event feed is terminal/feed.rs.
  • ui/dash.rs is not an alternative UI path. It is the embedded qdash server and runs alongside the TUI.
  • The mockups are stale. mockups.html:496 has six tabs (Overview, Workers, Processes, Events, Taps, Log) and an Activity / Raw toggle. The Activity view is deferred (plan.md:69-72).
  • The TUI is the monitor process. The plan's rule is "in-process and read-only", so an attached native app is a different architecture, not a port.
  • The Qcontrol tab is not sensor telemetry about harnesses. It covers only the monitor's own process tree.
  • Minor code oddities:
    • The Events pid filter is rendered but nothing ever sets it.
    • WorkerView.container_id is never shown.
    • A launch worker in Ready displays "running".
    • plan.md:126 says stderr goes to a file; the code pipes it into tracing.
  • Not verifiable: the PR number and the 2026-10-16 date, because the snapshot has no git metadata. The snapshot also lacks qsinks, qconfig, qprocess and qinject, so those APIs are known only from call sites.

Key files

  • $Q/docs/plan/2026-10-12-monitor-tui/plan.md
  • $Q/cli/src/commands/monitor/ui/shared/mod.rs
  • $Q/cli/src/commands/monitor/ui/shared/build.rs
  • $Q/cli/src/commands/monitor/ui/terminal/mod.rs
  • $Q/cli/src/commands/monitor/ui/terminal/feed.rs
  • $Q/cli/src/commands/monitor/host.rs
  • $Q/cli/src/commands/monitor/control.rs
  • $Q/crates/qmonitor/src/workers/telemetry.rs
  • $Q/crates/qdash/src/web.rs
  • $Q/crates/qevents/src/scan.rs

Qpoint Brand Style Guide