yuzu

Echo Dashboard

Status: Design + partial ship (PR-A.A shipped 2026-04-26: page shell + retention-paused list + Scan / Re-enable; typed-confirmation Purge shipped 2026-07-01 — dashboard fragment + POST /api/v1/echo/retention-paused/purge, §3.4; persistence still deferred. PR-H shipped 2026-06-18: process tree viewer, as-built §5 — local-Echo-data-only. See docs/roadmap.md Phase 15.) Audience: Server engineers, dashboard UI engineers, Echo plugin maintainers Owners: architect (page architecture), plugin-developer (Echo action surface), security-guardian (SQL execution surface), docs-writer (DSL + REST docs) Related: docs/scope-walking-design.md (the cross-cutting result-set primitive this page consumes), docs/yuzu-guardian-design-v1.1.md (the agent tamper-resistance pillar that the process tree viewer’s data quality depends on), agents/plugins/echo/ (the data plane).


1. Why a dedicated Echo page

The Echo plugin is one of the highest-leverage operator surfaces in Yuzu — it answers the “what happened on this machine” question that drives forensics, compliance, and reactive IR. Today it is reachable only via the generic instruction-launch flow and the /fragments/echo-sql HTMX fragment embedded in another page; the surface area is large but the affordances are scattered. Operators repeatedly asked “where do I go to do Echo” — the answer needs to be a single dashboard page.

The page is also the home for several affordances that did not have anywhere to live:

  1. Retention-paused source list — the operator-facing view of <source>_enabled=false per device, born from issue #539. Without it, an operator who disabled process_enabled on a device for forensic preservation has no aggregate view of which boxes are accumulating non-aging data.
  2. Process tree viewer — reconstructed per-host from that host’s local Echo warehouse ($Process_Live + $TCP_Live) only, with no seed (as-built; see §5). Forensic timeline of every process born under a parent over a chosen window, with an honest “no-seed” completeness caveat (Windows is names-only). The pane also embeds device-level DNS-cache + ARP-table panels (ADR-0015) — dispatched read-only echo.sql over $DNS_Live/$ARP_Live to the selected host, rendered beside the tree. These are device views, not per-process (the DNS cache carries no pid); they audit echo.dns.read / echo.arp.read (DNS is usage-class PII, separately countable).
  3. Capture sources frame (ADR-0015) — a per-device toggle table for the eleven core sources (process, tcp, service, user, perf, procperf, netqual, module, arp, dns, registry; the rest are toggled through echo.configure), with a category filter (six chips: Processes, Network, Services, User sessions, Performance and, for registry, System; the registry row also carries a NEW badge) and a staged-then-push guardrail: toggling a source only stages the change; nothing is dispatched until the operator clicks Push (guards against an accidental toggle of a usage-class source in either direction). Reads echo status + echo compatibility; Push dispatches echo configure <source>_enabled per change (audited echo.sources.read / echo.sources.configure). The same configure path is reachable via MCP/REST crossplatform.echo.configure.

The page is also the surface where scope walking (per docs/scope-walking-design.md) becomes most visibly useful — the Echo SQL query is the canonical iterative-narrowing tool (it now starts from the Result sets page, From Echo query, rather than from a frame on /echo), and result-set chips are the operator’s reasoning chain made visible.

2. Information architecture

 Dashboard ──────────────────────────────────────────────────────────────────
 │
 ├─ Devices       (existing)
 ├─ Inventory     (existing)
 ├─ Instructions  (existing)
 ├─ Policies      (existing)
 ├─ Echo           (new — this page) ──────────────────────────────────────────┐
 │                                                                            │
 │  /echo                                                                      │
 │  ┌────────────────────────────────────────────────────────────────────┐    │
 │  │  [Scope chip ▼]  windows-chrome-suspects · 2,798 devices · pinned  │    │
 │  │  ─── lineage breadcrumb (per scope-walking §8.2) ────────────────  │    │
 │  └────────────────────────────────────────────────────────────────────┘    │
 │  ┌─ Retention-paused sources (PR-A) ──────────────────────────────────┐    │
 │  │   device              source     paused since   live rows  oldest  │    │
 │  │   prod-eu-1 (Linux)   process    2026-04-25      18,402   31d 4h   │    │
 │  │   prod-eu-1 (Linux)   tcp        2026-04-25       4,193   31d 4h   │    │
 │  │   ...                                                              │    │
 │  │   [Re-enable]  [Purge data]  [Export CSV]                          │    │
 │  └────────────────────────────────────────────────────────────────────┘    │
 │  ┌─ Echo SQL (PR-D, scope-walking-aware) ──────────────────────────────┐    │
 │  │   $Echo  > SELECT name, COUNT(*) FROM process_hourly GROUP BY name  │    │
 │  │   [Run scoped to: windows-chrome-suspects]                         │    │
 │  │                                                                    │    │
 │  │   results... (streamed)                                            │    │
 │  │   [Save as result set ▼ "chrome-procs"]                            │    │
 │  └────────────────────────────────────────────────────────────────────┘    │
 │  ┌─ Process tree viewer (PR-H) ───────────────────────────────────────┐    │
 │  │   device: [pick from scope ▼]                                      │    │
 │  │                                                                    │    │
 │  │   1   systemd                                                      │    │
 │  │   ├── 234   sshd                                                   │    │
 │  │   │   └── 1402  bash (user=alice)                                  │    │
 │  │   │       └── 1419  python3 train.py  ← still running              │    │
 │  │   ├── 412   docker                                                 │    │
 │  │   │   ├── 998   containerd                                         │    │
 │  │   │   └── ...                                                      │    │
 │  │   ├── 814   yuzu-agent  (★ self)                                   │    │
 │  │   ...                                                              │    │
 │  │   Tree as observed since 2026-04-12 09:14 UTC (agent install)      │    │
 │  └────────────────────────────────────────────────────────────────────┘    │
 │                                                                            │
 ├─ Settings      (existing)                                                  │
 └────────────────────────────────────────────────────────────────────────────┘

URL structure:

PathPurposeRequired permission
/echoThe pageInfrastructure:Read
/fragments/echo/retention-pausedHTMX fragment for the paused-sources tableInfrastructure:Read
/fragments/echo/sql(retired) the SQL frame fragment; Ad-hoc Echo SQL now starts from Result sets > From Echo query (POST /api/v1/result-sets/from-echo-query)Execution:Execute
/fragments/echo/process-treeHTMX fragment for the tree viewerInfrastructure:Read
/api/v1/echo/retention-statusJSON: per-device per-source enabled/disabledInfrastructure:Read
/api/v1/echo/process-tree/{device_id}JSON: reconstructed tree for one device — DEFERRED, not implemented (§5.6); the shipped viewer is HTMX-fragment-onlyInfrastructure:Read

3. PR-A — Retention-paused source list

Goal: every device × source pair where <source>_enabled=false, surfaced in a sortable, filterable, exportable table, with one-click re-enable and an explicit “purge data” action gated behind a typed-hostname confirmation.

3.1 Data flow

The source of truth is the agent’s echo_config table — the <source>_enabled keys, set by the echo.configure action. The server does not currently maintain a centralised “Echo config” mirror; the data path is request-driven:

  1. Page load fans out echo.status (extended in PR-A to emit the four <source>_enabled values + the <source>_live_rows count + <source>_oldest_ts per source) to every device the page’s scope resolves to. Scope is the active scope chip (default __all__ for fresh page load).
  2. Responses stream back via the standard agent-streaming pipeline.
  3. Server collates into a transient in-memory table; renders as HTMX OOB swaps.
  4. Empty rows (enabled=true for all four sources) are dropped — the table only shows the paused state, not the entire fleet.

Tri-state enabled values + dedup (#560/#558/#561). The <source>_enabled value is read as a tri-state: true → dropped (collecting normally); false → normal paused row; any other value (errored from a corrupt/tampered agent DB, or garbage) → a value-error badge row showing the reported value (it is NOT silently dropped — that would hide a paused/broken source). A disabled source with paused_at == 0 (a pre-v0.12.0 agent) renders a “schema older than server” badge and sorts as the oldest (top), instead of a bare — sunk to the bottom. Both value-error and unknown-paused_at rows float to the top. Before parsing, responses are deduplicated to the most-recent per agent by the server-stamped received_at_ms (never the agent-claimed timestamp, which a compromised agent could backdate to hide its state) — bounding render cost to O(agents × sources) against a response-spam DoS. The complementary store-side per-(command_id, agent_id) cap is a tracked follow-up.

This avoids a new persistent server-side mirror, which would have to be reconciled with agent state on every config change. The trade-off is a 5-15s page-load cost on a large fleet; mitigated by:

Bounded scan read (#4644). The scan’s response read goes through the byte-aware store fetch (10,000 rows / 50 MiB of response payload). A cut is reported, never silent: the page shows a “Partial result” banner, qualifies its header counts and, with no rows in the part that was read, replaces the “still in progress” / “all clear” empty states with one saying the page cannot tell; GET /api/v1/echo/retention-paused and list_echo_retention_paused carry result_truncated_by_cap: true and also set store_degraded: true. This page is a dispatch-and-poll view, not a capture source, so the CORE capture-source pattern (§8 of the implementer doc) does not apply.

3.2 Extending echo.status

The action’s response gains four lines per source (so 16 new lines total for the 4 sources):

config|process_enabled|true
config|process_live_rows|18402
config|process_oldest_ts|1730000000
config|process_paused_at|0           # 0 = never; otherwise the ts when last set to false
config|tcp_enabled|false
config|tcp_live_rows|4193
config|tcp_oldest_ts|1733000000
config|tcp_paused_at|1735689600
...

config|<source>_paused_at|... is new — it requires the do_configure handler in echo_plugin.cpp to write this timestamp whenever it transitions a source from enabled to disabled (and clear it on the reverse). This is the operator-facing “paused since” column and is only meaningful at the server’s wall-clock. Backwards-compatible — if the field is absent, the dashboard renders — and falls back to “unknown.”

3.3 Re-enable action

Single-row “Re-enable” button → confirm modal → server dispatches echo.configure with the appropriate <source>_enabled=true to the named device. On success, the row drops out of the table on the next refresh; on failure the row turns yellow with the error tooltip.

Per-source independence (the #539 invariant) means re-enabling one source on a device does not touch the other three — the dispatched parameter is per-source.

3.4 Purge action

The “Purge data” button on a row is more dangerous than re-enable — it tells the agent to drop every row from <source>_live, <source>_hourly, <source>_daily, <source>_monthly for that source, without re-enabling the collector. The use case is “we paused process collection 8 weeks ago for forensic preservation; we have what we need, the disk is filling up, drop the rows but keep the collector paused.”

Server dispatches a new agent action echo.purge_source with {source: <name>}. The action is gated behind a typed-hostname confirmation (“type the device hostname to confirm purge of process data”), per the destructive-action discipline in the project root CLAUDE.md. Audit row: action: echo.source.purge, principal: <session>, result: success|failure, detail: device=<id> source=<name> — the dispatch audit row does not carry rows_deleted (see the as-shipped note below).

As shipped (Phase 15.A, 2026-07-01). Route POST /fragments/echo/retention-paused/purge; audit verb echo.source.purge (matching the echo.source.reenable sibling); metric yuzu_echo_source_purge_total{result}. The typed confirmation is a native prompt() (“type the device hostname”), not a bespoke modal — CSP-safe (no hx-on/eval), consistent with the native confirm() used for re-enable. The paused-guard is agent-side and authoritative: echo.purge_source refuses (source_not_paused) if the source is currently enabled, or (config_unreadable) if the enable flag cannot be read so the agent cannot confirm the source is paused (nothing is purged either way), which closes the scan→purge TOCTOU. The server does not enforce a SOURCE_NOT_PAUSED check (§3.5) because the retention-paused frame is an ephemeral live scan with no persisted paused-state to check. Dispatch is fire-and-forget, so rows_deleted is computed agent-side and returned in the response record — it is not in the immediate dispatch audit row.

A1 REST parity + generic-dispatch hardening (2026-07-01). Automation/MCP callers use the structured POST /api/v1/echo/retention-paused/purge (JSON body {device_id, source}, Infrastructure:Delete per-device-scoped, A4 error envelope, 202 + command_id; audited fail-closed — no dispatch without a durable audit row). Both the fragment and the REST route confirm the guard is agent-side authoritative. The generic POST /api/command escape hatch now also elevates destructive actions: echo.purge_source there additionally requires Infrastructure:Delete and is confined to the caller’s visible agents with untargeted broadcast/scope fan-out refused, so it can’t be a weaker path than the dedicated route. The atomic agent-side guard (check + delete under one lock) closes the agent-local check→delete race in addition to the scan→purge TOCTOU.

3.5 Permissions and safety

3.6 Tests

4. PR-D — Echo SQL frame, scope-walking-aware

As built: the frame was not kept on /echo. The Echo page carries a short note linking to Result sets, and the same query (parent scope, SQL, name, include-empty) is run from Result sets > From Echo query, behind a confirm that shows the SQL. The text below is the original design.

The frame body is largely the existing /fragments/echo-sql route (server/core/src/dashboard_routes.cpp:500). PR-D moves it onto the new page and wires it into the result-set primitive (docs/scope-walking-design.md):

  1. Scope chip in the frame header — operator picks an active result set (or a standard scope) before submitting. The dispatch resolves from_result_set:<id> to the device-ID set per scope-walking-design.md §4.
  2. “Save as result set” button on the results pane — after a query returns, operator clicks “Save as result set,” names it, and the server creates a new result set (source_kind=echo_query) with member IDs equal to the union of agents that returned ≥1 row. Default name is echo-<first-five-chars-of-sql-hash> so the operator can save without typing.
  3. Lineage breadcrumb above the frame mirrors the active scope chip’s lineage (scope-walking-design.md §8.2).

No changes to the echo.sql agent action; the change is purely server-side scope resolution + result-set persistence.

5. PR-H — Process tree viewer (as-built)

Deviation from the original seed-based design below (kept for history). What shipped reconstructs the tree from the agent’s existing local Echo warehouse only ($Process_Live + $TCP_Live, queried via the read-only echo.sql action) — there is no agent-side seed action, no action='seed' rows, no __checkpoint__ rows, and (until #4027) no /api/v1/echo/process-tree REST surface at all. #4027 added GET /api/v1/echo/process-tree as the frame’s device-PICKER list twin only (§5.6) — the per-device reconstructed-tree JSON surface this paragraph is about remains deferred. This was a deliberate scope choice (local-Echo-data-only); the consequence is an honest completeness limit (see “Honesty” below). Modules: server engine server/core/src/echo_process_tree.{hpp,cpp} (pure, unit-tested), routes server/core/src/echo_tree_routes.{hpp,cpp}, page echo_page_ui.cpp Frame 3.

5.1 Reconstruction model (as-built)

Honesty in framing. Banner reads e.g. Window <from> → <to> · observed since <oldest-retained> · 411 nodes (281 running / 130 exited) · 0 flagged, with a note that — because there is no seed — a process whose started event has aged out of the 100k $Process_Live cap (or that predates the oldest retained row) may not appear, and that the boot/install anchors are proxies. On Windows the feeder is ETW (names-only) so per-process path and command line are blank (stated inline); they are populated on Linux/macOS.

5.2 Filtering, grouping, network (client-side display)

Reconstruction always returns the full tree; display filtering is client-side (instant, no re-dispatch — each row carries data-state/data-anom):

5.3 Layout & detail panel

Two-column: a scrollable tree on the left and a sticky, always-visible detail panel on the right (stacks below on narrow viewports). Clicking any process row hx-gets /fragments/echo/process-tree/detail into the right panel — name, PID, parent PID, user, running/exited + start time, path + command line (blank-with-note on Windows), the process’s connections, and anomaly evidence. The detail renders from a server-side reconstruction cache (bounded LRU, 180 s TTL, keyed by a CSPRNG token — secure_random::random_hex, never mt19937) so row clicks need no further agent round-trip. The cache entry is bound to the operator who created it: detail fails closed unless the requesting session matches, so a predicted or leaked token can’t be replayed by a different operator (transparent to the normal same-session flow). If secure token generation fails (entropy exhaustion), result returns an in-panel error and caches nothing rather than minting a weak token.

5.4 Routes

RoutePurpose
GET /fragments/echo/process-treeFrame body: host picker (operator-scoped) + timescale + filter bar + tree/detail targets
GET /fragments/echo/process-tree/run?device=&preset=&from=&to=Resolve window, dispatch the two echo.sql, return the polling fragment
GET /fragments/echo/process-tree/result?…&pcmd=&tcmd=&n=Poll both commands, reconstruct, cache, render the tree
GET /fragments/echo/process-tree/detail?token=&node=Render one node’s detail from the cache (re-checks scope + Execute + originating principal)

5.5 Permissions & audit

5.6 Deferred

5.7 Timeline renderers (building blocks, not yet wired)

server/core/src/echo_timeline_view.hpp defines the model of a per-device timeline read, and echo_timeline_render.{hpp,cpp} holds the pure renderers that turn it into the device bar, the “what is kept” chart, the lanes, the moment and selected panels and the “What this record cannot tell you” list. No page, route or API uses them yet, so nothing is operator-visible.

The data that fills the model (the read, its permissions, audit and the API twins) is separate work; this section records the contract it must meet.

5.8 Timeline page (hidden until the timeline fragments land)

GET /echo/timeline?id=<agent_id>[&at=<epoch seconds>] is the page that will host the 5.7 renderers, with static/yuzu-echo-timeline.js beside it. Both routes are registered only when kEchoTimelineUi (echo_timeline_page_ui.hpp, copied into page::Deps::echo_timeline_ui) is true. It ships false: the paths do not exist, nothing links to them, and nothing is operator-visible. A signed-out request is redirected to sign in; a missing id is a 400.

The fragments themselves, their permissions and audit are separate work. What the page assumes of them:


Original seed-based design (superseded — kept for history)

5.1 Reconstruction model

Per the architectural direction set in the parent conversation:

  • The agent runs from boot or first install, hardened against tampering (a separate roadmap track — see docs/yuzu-guardian-windows-implementation-plan.md and the open Guardian PR ladder).
  • On first start, the agent runs echo.process_tree to produce a seed snapshot — every PID currently running, with PPID, name, cmdline, user, start_time. Walks /proc (Linux), CreateToolhelp32Snapshot + Process32FirstW/NextW (Windows), proc_listallpids + proc_pidinfo(PROC_PIDTBSDINFO|PIDPATHINFO) (macOS). Stored in process_live with a tagged action='seed' row per PID.
  • From the seed onward, regular collect_fast cycles emit action='started' / action='stopped' events. These are appended to process_live and are the canonical mutation stream for the tree.
  • At render time, the server queries process_live for one device’s full history (or a time-bounded slice), replays the seed + events, and produces a tree JSON.

The dashboard rendered the seed timestamp prominently (Tree as observed since … (agent install)). Time slicing was via ?as_of=<ts>. Mitigations for replay cost were a per-device LRU cache (5-min TTL), 24 h __checkpoint__ rows, and a 50K-node render cap. A GET /api/v1/echo/process-tree/{device_id} JSON surface was planned alongside the fragment, and the renderer was to apply the Echo redaction patterns before serialisation.

6. Permissions matrix

SurfaceRequiredNotes
/echo page loadInfrastructure:ReadSame as Devices/Inventory pages
Retention-paused list viewInfrastructure:Read
Scan fleet (Phase 15.A)Execution:ExecuteDispatches echo.status fleet-wide; same tier as run-instruction/echo-execute
Re-enableExecution:ExecutePer-device RBAC visibility check; out-of-scope collapses to same 404 as not-connected (no enumeration oracle)
PurgeInfrastructure:DeletePer-device RBAC visibility check + typed-hostname confirmation (native prompt(), CSP-safe). Agent-side source_not_paused / config_unreadable guard is authoritative (no server-side SOURCE_NOT_PAUSED check)
Echo SQL submitInfrastructure:Read (today) → may tighten laterThe current grant is read-only because Echo SQL is bounded to the agent’s own DB and runs SELECT-only on a read-only connection behind a SQLite authorizer that allows only registry-known warehouse tables (#760/#631)
Save SQL result as result setInfrastructure:Read + result-set creation quotaPer scope-walking-design §3.3
Process tree view (frame only)Infrastructure:ReadFrame, host picker, filter bar
Process tree reconstructInfrastructure:Read + Execution:ExecuteDispatches two read-only echo.sql queries to one device; per-device management scope required; emits echo.process_tree.read

7. Observability

Per docs/observability-conventions.md:

MetricTypeLabelsStatus
yuzu_echo_dashboard_view_totalcounterframe (retention/sql/tree), resultshipped (PR-A.A)
yuzu_echo_retention_paused_devicesgaugesourceshipped (PR-A.A). Set from the rows of the page’s own scan read. A cut scan (result_truncated_by_cap, see the bounded-read note in §3.1) does not update it, because its rows would under-count; the gauge keeps the last value from an uncut scan, so it can be stale. It is a per-render view, not a fleet total.
yuzu_echo_source_purge_totalcounterresultshipped (Phase 15.A — dashboard fragment + POST /api/v1/echo/retention-paused/purge)
yuzu_echo_source_reenable_totalcounterresultshipped (PR-A.A — dashboard fragment only, no REST twin)
yuzu_echo_scan_dispatched_totalcounterresultshipped (PR-A.A)
yuzu_echo_process_tree_render_secondshistogramnode_count_bucketplanned — not yet emitted (SRE follow-up)

Do not sum yuzu_echo_source_purge_total across surfaces yet. The dashboard fragment and the REST twin write the same series with different result tokens: the fragment distinguishes scope_violation from denied, while REST folds per-device scope denial, missing wiring, and audit failure all into denied and otherwise only ever writes requested. success and agent_not_connected are comparable; nothing else is, and there is no surface label to split them.

The PR-H viewer ships with no process-tree metrics yet (a dashboard read surface); the render-duration histogram + a dispatch counter + a node-count histogram are a tracked SRE follow-up. The seed-age gauge from the original design is removed (there is no seed).

Audit actions: echo.status.scan (Scan fleet — PR-A.A), echo.source.reenable (Re-enable a row — PR-A.A), echo.source.purge (purge — shipped Phase 15.A; also csrf.denied-style cross-origin refusals are audited under echo.source.purge/reenable with detail=csrf_cross_origin), echo.process_tree.read (PR-H — emitted at dispatch and on a successful reconstruction; the device is target_id, never in detail; see docs/user-manual/audit-log.md). The seed-based echo.process_tree.reseed verb is removed (no seed).

8. Cross-references

9. Sequencing

PRScopeStatus
PR-APage shell + retention-paused list (this doc §3)Shipped — PR-A.A (paused_at extension + dashboard page + retention-paused list) + typed-confirmation Purge action (§3.4, 2026-07-01).
PR-BResult-set store + REST API (scope-walking-design.md PR-B)Pending
PR-CScope chip + sidebar + breadcrumb (scope-walking-design.md PR-C)Pending
PR-DEcho SQL frame migrated and scope-walking-aware (this doc §4)Pending
PR-EDSL fromResultSet: (scope-walking-design.md PR-E)Pending
PR-FReference IR walkthrough integration test (scope-walking-design.md PR-F)Pending
PR-GOperational hardening (live re-eval, GC, metrics)Pending
PR-HProcess tree viewer (this doc §5)Shipped (as-built §5 — local-Echo-data-only reconstruction; no seed). REST/MCP parity deferred (§5.6).

10. Open questions