From 6087f1db5d03325760faf75c37f816762654a57a Mon Sep 17 00:00:00 2001 From: sepehr-safari Date: Sun, 12 Jul 2026 17:50:39 +0300 Subject: [PATCH] =?UTF-8?q?feat(ui):=20live-capture=20view=20=E2=80=94=20s?= =?UTF-8?q?treaming=20timeline,=20controls,=20status?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Part 2 of #59: the interactive live-capture surface, on top of the pt1 streaming model (update_fx + LiveCapture, ADR-0009). A new `Surface` toggle (traces | live) puts a live-capture surface beside the opened-trace workspace, reached from an always-present "Live" tab (and the empty state). It renders through the SAME timeline / detail / failure widgets as an opened trace, driven off `model.live.trace` — which the model re-derives from the streamed NDJSON — so a live session correlates, detects, and stays viewport-bounded for free. Selection/toggle mutations route to whichever surface is shown (`Model.mutTrace`). The control strip: two endpoint fields (listen / upstream, seeded with example defaults and edited in place; read-only while capturing), a Start/Stop button (Start gated on both endpoints being set), a colored status dot + label (Ready / Capturing… / Stopped / Failed(reason)), and an "N events · M KB recorded" readout. Empty/error states show a state-appropriate note; the live status bar mirrors the trace one. Tests: view tests drive the surface through the real dispatch path — Start/Stop toggles, streamed events append to the live timeline, a streamed Invalid Authorize lights up FAILED_AUTHORIZATION, the timeline stays under the widget budget at 500 streamed events, and the surface passes the a11y sweep (idle + capturing). Model tests cover surface routing and endpoint editing/freeze. 161/161 green; ReleaseFast build clean. Closes #59. --- CURRENT_STATE.md | 25 +++-- src/tests.zig | 145 ++++++++++++++++++++++++ src/ui/inspector.zig | 254 ++++++++++++++++++++++++++++++++++++++----- src/ui/workspace.zig | 171 ++++++++++++++++++++++++++--- 4 files changed, 541 insertions(+), 54 deletions(-) diff --git a/CURRENT_STATE.md b/CURRENT_STATE.md index c24b4cf..0898664 100644 --- a/CURRENT_STATE.md +++ b/CURRENT_STATE.md @@ -10,11 +10,12 @@ **S5 — Live capture (0.4.0): in progress.** S0–S4 are done — analysis parity, a headless CLI, and the native inspector. S5's flagship — a live WebSocket MITM -proxy between a charge point and its CSMS — now works end to end from the -terminal: WS codec (#54), decode (#55), proxy (#56), and the `studio capture` -CLI (#57). The effects-channel spike (#58, ADR-0009) confirmed the live GUI -(#59/#60) is feasible **in-runner** (via `update_fx` + `fx.spawn`), not a -runner-eject; see [ROADMAP.md](ROADMAP.md). +proxy between a charge point and its CSMS — works end to end from the terminal +(WS codec #54, decode #55, proxy #56, the `studio capture` CLI #57) and now in +the GUI: a live-capture surface (#59) streams the proxy's events into the +inspector through the effects channel (#58, ADR-0009) — `update_fx` + `fx.spawn`, +**in-runner**, not a runner-eject. Only OS notifications on critical live +failures (#60) remain; see [ROADMAP.md](ROADMAP.md). ## What's done @@ -162,10 +163,12 @@ Every issue landed: ## What's next -**S5 — Live capture ⭐ (0.4.0)** — the flagship: a live WebSocket proxy between a -charge point and its CSMS, decoding OCPP frames in flight, running detection as -events stream, recording to the canonical trace format, and surfacing it in a -live timeline with OS notifications on critical failures. +**S5 — Live capture ⭐ (0.4.0)** — one issue remains: OS notifications on +critical live failures (#60). The flagship is otherwise landed — the WS proxy, +the `studio capture` CLI, and the live inspector surface (#54–#59): a live +WebSocket proxy between a charge point and its CSMS, decoding OCPP frames in +flight, running detection as events stream, recording to the canonical trace +format, and surfacing it in a live timeline. ## Known blockers / decisions pending @@ -178,7 +181,7 @@ live timeline with OS notifications on critical failures. | `repo` (tooling, CI) | ✅ done for S0 | | `docs` (docs, ADRs) | ✅ done for S0 | | `ocpp` (engine) | ✅ S2 + ingestion (#29) + reports (#41) + anonymize (#42) + diff (#43) + replay core (#44); O(n) detection pending (#36) | -| `ui` (native views) | ✅ S3 inspector (#27–#32) + replay transport (#44); 🔨 live-capture streaming model + `update_fx`/`fx.spawn` (#59 pt1) | -| `capture` (live proxy) | 🔨 S5 in progress: WS transport (#54) + frame decode (#55) + MITM proxy (#56) | +| `ui` (native views) | ✅ S3 inspector (#27–#32) + replay transport (#44) + live-capture view (#59) | +| `capture` (live proxy) | ✅ S5: WS transport (#54) + frame decode (#55) + MITM proxy (#56) | | `cli` (headless) | ✅ inspect/report/diff/anonymize/ci/scenario (#45) + live `capture` (#57) | | `conformance` | ✅ done for S2 (15/15, `contract-v1`) | diff --git a/src/tests.zig b/src/tests.zig index a14e83a..8344a86 100644 --- a/src/tests.zig +++ b/src/tests.zig @@ -576,3 +576,148 @@ test "the replay transport steps over the filtered set, skipping hidden events" try testing.expectEqual(types.MessageType.call_result, model.activeTrace().?.events[second_sel].message_type); try testing.expect(second_sel > first_sel); } + +// --- live-capture surface (#59 pt2) ---------------------------------------- +// +// Drive the live surface through the same typed dispatch the runtime uses: the +// controls are pressed via `msgForPointer`, and streamed events arrive as the +// `capture_line` Msgs the worker's stdout produces (see proxy.zig / ADR-0009). + +/// One NDJSON event line as the capture worker emits it, dispatched at the +/// current session key. +fn streamLine(model: *Model, line: []const u8) void { + workspace.update(model, .{ .capture_line = .{ .key = model.live.key, .line = line } }); +} + +test "the empty state's Live capture button switches to the live surface" { + var arena_state = std.heap.ArenaAllocator.init(testing.allocator); + defer arena_state.deinit(); + const arena = arena_state.allocator(); + + var model = Model{ .backing = testing.allocator }; + defer model.deinitAll(); + + // From the empty workspace, "Live capture" opens the live surface. + var tree = try buildTree(arena, &model); + const live_btn = try expectByText(tree.root, .button, "Live capture"); + main.update(&model, tree.msgForPointer(live_btn.id, .up).?); + try testing.expectEqual(workspace.Surface.live, model.surface); + + // The live surface shows its two endpoint fields and the Start control. + tree = try buildTree(arena, &model); + _ = try expectByText(tree.root, .button, "Start capture"); + try testing.expect(findKind(tree.root, .text_field) != null); + _ = try expectByText(tree.root, .text, "Ready to capture"); +} + +test "the live surface starts a capture, streams events, and toggles Start/Stop" { + var arena_state = std.heap.ArenaAllocator.init(testing.allocator); + defer arena_state.deinit(); + const arena = arena_state.allocator(); + + var model = Model{ .backing = testing.allocator }; + defer model.deinitAll(); + workspace.update(&model, .show_live); + + // Idle: Start is present, Stop is not, and the empty-state note shows. + var tree = try buildTree(arena, &model); + const start = try expectByText(tree.root, .button, "Start capture"); + try testing.expect(findByText(tree.root, .button, "Stop") == null); + + // Press Start (enabled by the seeded default endpoints): status flips and the + // button becomes Stop. + main.update(&model, tree.msgForPointer(start.id, .up).?); + try testing.expectEqual(workspace.CaptureStatus.capturing, model.live.status); + tree = try buildTree(arena, &model); + _ = try expectByText(tree.root, .button, "Stop"); + _ = try expectByText(tree.root, .text, "Capturing\u{2026}"); + + // Stream a BootNotification exchange: the live timeline renders it and the + // status bar counts it. + streamLine(&model, "{\"timestamp\":1705312800000,\"direction\":\"CS_TO_CSMS\",\"message\":[2,\"m1\",\"BootNotification\",{}]}"); + streamLine(&model, "{\"timestamp\":1705312800500,\"direction\":\"CSMS_TO_CS\",\"message\":[3,\"m1\",{\"status\":\"Accepted\"}]}"); + tree = try buildTree(arena, &model); + _ = try expectByText(tree.root, .text, "BootNotification"); + try testing.expect(std.mem.indexOf(u8, statusText(tree), "2 events") != null); + + // Press Stop. + const stop = try expectByText(tree.root, .button, "Stop"); + main.update(&model, tree.msgForPointer(stop.id, .up).?); + try testing.expectEqual(workspace.CaptureStatus.stopped, model.live.status); +} + +test "the live surface surfaces detected failures as the stream arrives" { + var arena_state = std.heap.ArenaAllocator.init(testing.allocator); + defer arena_state.deinit(); + const arena = arena_state.allocator(); + + var model = Model{ .backing = testing.allocator }; + defer model.deinitAll(); + workspace.update(&model, .show_live); + workspace.update(&model, .start_capture); + + // The Authorize Call alone: no authorization failure yet (the response that + // would reject it hasn't arrived). + streamLine(&model, "{\"direction\":\"CS_TO_CSMS\",\"message\":[2,\"m1\",\"Authorize\",{\"idTag\":\"TAG-BAD\"}]}"); + var tree = try buildTree(arena, &model); + _ = try expectByText(tree.root, .text, "Authorize"); // the event is on the timeline + try testing.expect(findByText(tree.root, .text, "FAILED_AUTHORIZATION") == null); + + // The Invalid response arrives → FAILED_AUTHORIZATION surfaces in the live + // failure panel without leaving the surface. + streamLine(&model, "{\"direction\":\"CSMS_TO_CS\",\"message\":[3,\"m1\",{\"idTagInfo\":{\"status\":\"Invalid\"}}]}"); + tree = try buildTree(arena, &model); + _ = try expectByText(tree.root, .text, "FAILED_AUTHORIZATION"); +} + +test "the live timeline stays viewport-sized under a busy stream" { + var arena_state = std.heap.ArenaAllocator.init(testing.allocator); + defer arena_state.deinit(); + + var model = Model{ .backing = testing.allocator }; + defer model.deinitAll(); + workspace.update(&model, .show_live); + workspace.update(&model, .start_capture); + + // A steady stream of Heartbeat exchanges — 500 events, far past a viewport. + // The timeline window derives from the viewport, not the event count. + var i: usize = 0; + while (i < 250) : (i += 1) { + streamLine(&model, "{\"direction\":\"CS_TO_CSMS\",\"message\":[2,\"m\",\"Heartbeat\",{}]}"); + streamLine(&model, "{\"direction\":\"CSMS_TO_CS\",\"message\":[3,\"m\",{}]}"); + } + try testing.expectEqual(@as(usize, 500), model.live.trace.events.len); + + const tree = try buildTree(arena_state.allocator(), &model); + try testing.expect(std.mem.indexOf(u8, statusText(tree), "500 events") != null); + // No materialization of off-screen rows: the whole tree stays viewport-sized. + try testing.expect(countNodes(tree.root) < 1024); +} + +test "the live-capture surface passes the accessibility sweep" { + var arena_state = std.heap.ArenaAllocator.init(testing.allocator); + defer arena_state.deinit(); + const arena = arena_state.allocator(); + + const sweep = canvas.a11y.A11yAuditSweepOptions{ + .min_size = .{ .width = 900, .height = 560 }, + .default_size = .{ .width = 1200, .height = 800 }, + }; + + var model = Model{ .backing = testing.allocator }; + defer model.deinitAll(); + + // Idle: the control strip (editable fields + Start) and the empty-state note. + workspace.update(&model, .show_live); + var tree = try buildTree(arena, &model); + try canvas.expectA11yAuditSweepClean(testing.allocator, tree.root, sweep); + + // Capturing: the fields go read-only, and a streamed failing exchange lights + // up the timeline, failure panel, and detail pane over the live trace. + workspace.update(&model, .start_capture); + streamLine(&model, "{\"direction\":\"CS_TO_CSMS\",\"message\":[2,\"m1\",\"Authorize\",{\"idTag\":\"BAD\"}]}"); + streamLine(&model, "{\"direction\":\"CSMS_TO_CS\",\"message\":[3,\"m1\",{\"idTagInfo\":{\"status\":\"Invalid\"}}]}"); + workspace.update(&model, .{ .select_event = 0 }); + tree = try buildTree(arena, &model); + try canvas.expectA11yAuditSweepClean(testing.allocator, tree.root, sweep); +} diff --git a/src/ui/inspector.zig b/src/ui/inspector.zig index 03efc38..bc2d2ba 100644 --- a/src/ui/inspector.zig +++ b/src/ui/inspector.zig @@ -17,6 +17,9 @@ const types = ocpp.types; const Model = workspace.Model; const Msg = workspace.Msg; const LoadedTrace = workspace.LoadedTrace; +const Surface = workspace.Surface; +const CaptureStatus = workspace.CaptureStatus; +const LiveCapture = workspace.LiveCapture; pub const Ui = canvas.Ui(Msg); const Node = Ui.Node; @@ -27,6 +30,7 @@ const Node = Ui.Node; const row_extent: f32 = 44; pub fn view(ui: *Ui, model: *const Model) Node { + if (model.surface == .live) return liveView(ui, model); if (!model.hasTraces()) return emptyState(ui); const t = model.activeTrace().?; if (t.isError()) { @@ -47,11 +51,31 @@ pub fn view(ui: *Ui, model: *const Model) Node { ui.separator(.{}), filterBar(ui, model), ui.separator(.{}), - activeBody(ui, model, filtered), + traceBody(ui, model, t, filtered), statusBar(ui, model, filtered), }); } +// --- live-capture surface (#59) -------------------------------------------- +// +// The live-capture session shown as a first-class inspector: a control strip +// (endpoint config, start/stop, status, recording size) over the SAME timeline / +// detail / failure widgets as an opened trace, driven off `model.live.trace`. +// That trace is re-derived from the streamed NDJSON (workspace.zig), so it +// correlates, detects, and stays viewport-bounded exactly like any other. + +fn liveView(ui: *Ui, model: *const Model) Node { + const t = &model.live.trace; + return ui.column(.{ .grow = 1 }, .{ + topBar(ui, model), + ui.separator(.{}), + captureControls(ui, model), + ui.separator(.{}), + liveBody(ui, model, t), + liveStatusBar(ui, t), + }); +} + // --- empty state ----------------------------------------------------------- fn emptyState(ui: *Ui) Node { @@ -59,53 +83,87 @@ fn emptyState(ui: *Ui) Node { ui.text(.{ .size = .heading }, "No trace open"), ui.text(.{}, "Open the built-in sample, or pass a trace file on the command line:"), ui.text(.{}, "studio path/to/trace.json"), - ui.button(.{ .on_press = .open_sample, .variant = .primary }, "Open sample"), + ui.row(.{ .gap = 8 }, .{ + ui.button(.{ .on_press = .open_sample, .variant = .primary }, "Open sample"), + ui.button(.{ .on_press = .show_live, .variant = .secondary }, "Live capture"), + }), }); } -// --- top bar: tabs / single-trace name + close ----------------------------- +// --- top bar: trace tabs + live toggle + close ----------------------------- fn topBar(ui: *Ui, model: *const Model) Node { - const tabs = model.open(); - const left: Node = if (tabs.len > 1) tabStrip(ui, model) else ui.text(.{ .grow = 1 }, tabs[0].name); return ui.row(.{ .gap = 8, .cross = .center, .padding = 8 }, .{ - left, - ui.spacer(1), - ui.button(.{ .on_press = .{ .close_trace = model.active }, .variant = .ghost, .size = .sm }, "Close"), + traceTabs(ui, model), + liveTab(ui, model), + closeControl(ui, model), }); } -fn tabStrip(ui: *Ui, model: *const Model) Node { +/// The workspace tabs: one selectable button per open trace (empty-safe — a +/// muted note when none are open, which the user only sees from the live +/// surface). A tab is selected when it is the active trace AND the traces +/// surface is showing, so the highlight moves to the Live tab in live mode. +fn traceTabs(ui: *Ui, model: *const Model) Node { const tabs = model.open(); + if (tabs.len == 0) + return ui.text(.{ .grow = 1, .style_tokens = .{ .foreground = .text_muted } }, "No traces open"); const nodes = ui.arena.alloc(Node, tabs.len) catch { ui.failed = true; - return ui.row(.{}, .{}); + return ui.row(.{ .grow = 1 }, .{}); }; + const on_traces = model.surface == .traces; for (tabs, 0..) |*t, i| { + const active = on_traces and i == model.active; nodes[i] = ui.button(.{ .on_press = .{ .select_trace = i }, - .selected = (i == model.active), - .variant = if (i == model.active) .secondary else .ghost, + .selected = active, + .variant = if (active) .secondary else .ghost, .size = .sm, }, t.name); } return ui.row(.{ .gap = 4, .cross = .center, .grow = 1 }, nodes); } +/// The always-present Live toggle. Carries a dot while a capture is running so +/// the session state is legible even from the traces surface. +fn liveTab(ui: *Ui, model: *const Model) Node { + const selected = model.surface == .live; + const capturing = model.live.status == .capturing; + return ui.button(.{ + .on_press = .show_live, + .selected = selected, + .variant = if (selected) .secondary else .ghost, + .size = .sm, + .icon = if (capturing) "circle-dot" else "", + }, "Live"); +} + +/// Close acts on the active workspace tab — shown only on the traces surface +/// with at least one trace open. +fn closeControl(ui: *Ui, model: *const Model) Node { + if (model.surface != .traces or !model.hasTraces()) return ui.spacer(0); + return ui.button(.{ .on_press = .{ .close_trace = model.active }, .variant = .ghost, .size = .sm }, "Close"); +} + // --- active trace body: overview or error ---------------------------------- -fn activeBody(ui: *Ui, model: *const Model, filtered: ?[]const usize) Node { - const t = model.activeTrace().?; - // Timeline (left) / detail (right) fill the space above a fixed-height - // failure drawer. `split` is horizontal-only, so the vertical stack is a - // column: the split grows, the drawer keeps its height. +/// The timeline / detail / failures body over trace `t`. Shared by the traces +/// surface and the live surface (each passes its own trace); the virtual-list id +/// keys off the surface so the two timelines keep independent scroll state. +/// +/// Timeline (left) / detail (right) fill the space above a fixed-height failure +/// drawer. `split` is horizontal-only, so the vertical stack is a column: the +/// split grows, the drawer keeps its height. +fn traceBody(ui: *Ui, model: *const Model, t: *const LoadedTrace, filtered: ?[]const usize) Node { + const list_id: []const u8 = if (model.surface == .live) "live-timeline" else "event-timeline"; return ui.column(.{ .grow = 1 }, .{ ui.split(.{ .grow = 1, .value = model.timeline_split, .on_resize = Ui.valueMsg(.timeline_resized), }, .{ - timelinePane(ui, t, filtered), + timelinePane(ui, t, filtered, list_id), detailPane(ui, t), }), ui.separator(.{}), @@ -320,7 +378,7 @@ fn transportButton(ui: *Ui, label: []const u8, target: ?usize) Node { }, label); } -fn timelinePane(ui: *Ui, t: *const LoadedTrace, filtered: ?[]const usize) Node { +fn timelinePane(ui: *Ui, t: *const LoadedTrace, filtered: ?[]const usize, list_id: []const u8) Node { const count = if (filtered) |fi| fi.len else t.events.len; if (filtered != null and count == 0) { return ui.column(.{ .min_width = 360, .grow = 1 }, .{ @@ -332,7 +390,7 @@ fn timelinePane(ui: *Ui, t: *const LoadedTrace, filtered: ?[]const usize) Node { }); } const opts = Ui.VirtualListOptions{ - .id = "event-timeline", + .id = list_id, .item_count = count, .item_extent = row_extent, .overscan = 6, @@ -1063,6 +1121,129 @@ fn failuresSummary(arena: std.mem.Allocator, failures: []const types.Failure) [] return buf.items; } +// --- live-capture controls ------------------------------------------------- + +/// The control strip: the two endpoint fields, the start/stop button, and a +/// status line (state + recording size). Endpoints are read-only while a +/// capture runs (the session is bound to them for its life). +fn captureControls(ui: *Ui, model: *const Model) Node { + const capturing = model.live.status == .capturing; + return ui.column(.{ .padding = 8, .gap = 8 }, .{ + ui.row(.{ .gap = 10, .cross = .end }, .{ + configField(ui, "Listen (host:port)", model.live.listen(), workspace.default_listen, Ui.inputMsg(.listen_input), capturing, "listen address"), + configField(ui, "Upstream (ws:// URL)", model.live.upstream(), workspace.default_upstream, Ui.inputMsg(.upstream_input), capturing, "upstream CSMS URL"), + startStopButton(ui, model), + }), + captureStatusRow(ui, model), + }); +} + +/// A labeled endpoint field. `disabled` renders it read-only (while capturing). +fn configField( + ui: *Ui, + label: []const u8, + value: []const u8, + placeholder: []const u8, + on_input: Ui.InputMsgFn, + disabled: bool, + a11y_label: []const u8, +) Node { + return ui.column(.{ .grow = 1, .gap = 4 }, .{ + ui.text(.{ .style_tokens = .{ .foreground = .text_muted } }, label), + ui.el(.text_field, .{ + .grow = 1, + .min_width = 180, + .text = value, + .placeholder = placeholder, + .on_input = on_input, + .disabled = disabled, + .semantics = .{ .label = a11y_label }, + }, .{}), + }); +} + +/// Start (primary; disabled until both endpoints are set) or Stop (destructive), +/// depending on whether a capture is running. +fn startStopButton(ui: *Ui, model: *const Model) Node { + if (model.live.status == .capturing) + return ui.button(.{ .on_press = .stop_capture, .variant = .destructive }, "Stop"); + const ready = model.live.configured(); + return ui.button(.{ + .on_press = if (ready) .start_capture else null, + .variant = .primary, + }, "Start capture"); +} + +/// The status line under the fields: a colored dot + state label on the left, a +/// recording-size readout on the right. +fn captureStatusRow(ui: *Ui, model: *const Model) Node { + const color = captureStatusColor(model.live.status); + return ui.row(.{ .gap = 8, .cross = .center }, .{ + ui.icon(.{ .width = 16, .style_tokens = .{ .foreground = color }, .semantics = .{ .label = "capture status" } }, "circle-dot"), + ui.text(.{ .style_tokens = .{ .foreground = color } }, captureStatusLabel(ui.arena, &model.live)), + ui.spacer(1), + ui.text(.{ .style_tokens = .{ .foreground = .text_muted } }, recordingSummary(ui.arena, model)), + }); +} + +fn captureStatusColor(s: CaptureStatus) canvas.ColorTokenName { + return switch (s) { + .idle => .text_muted, + .capturing => .info, + .stopped => .text_muted, + .failed => .destructive, + }; +} + +/// Human-readable capture state; a failure carries its exit reason. +fn captureStatusLabel(arena: std.mem.Allocator, live: *const LiveCapture) []const u8 { + return switch (live.status) { + .idle => "Ready", + .capturing => "Capturing\u{2026}", + .stopped => "Stopped", + .failed => if (live.exit_reason) |r| + (std.fmt.allocPrint(arena, "Failed ({s})", .{@tagName(r)}) catch "Failed") + else + "Failed", + }; +} + +/// "N events · M KB recorded" — the size of the in-memory NDJSON recording. +fn recordingSummary(arena: std.mem.Allocator, model: *const Model) []const u8 { + const events = model.live.trace.events.len; + const kb = model.live.ndjson.items.len / 1024; + return std.fmt.allocPrint(arena, "{d} event{s} \u{00B7} {d} KB recorded", .{ + events, if (events == 1) "" else "s", kb, + }) catch ""; +} + +// --- live-capture body ----------------------------------------------------- + +/// The live timeline over `model.live.trace`: the shared timeline/detail/failure +/// body once events arrive, or a state-appropriate note while empty / on error. +/// The live surface never filters (the filter bar belongs to the traces +/// surface), so the body is always unfiltered. +fn liveBody(ui: *Ui, model: *const Model, t: *const LoadedTrace) Node { + if (t.isError()) return errorPanel(ui, t); + if (t.events.len == 0) return liveEmptyNote(ui, model.live.status); + return traceBody(ui, model, t, null); +} + +const LiveNote = struct { title: []const u8, hint: []const u8 }; + +fn liveEmptyNote(ui: *Ui, status: CaptureStatus) Node { + const n: LiveNote = switch (status) { + .idle => .{ .title = "Ready to capture", .hint = "Set the endpoints above, then press Start capture." }, + .capturing => .{ .title = "Capturing\u{2026}", .hint = "Waiting for the first message. Point your charge point at the listen address." }, + .stopped => .{ .title = "Capture stopped", .hint = "No messages were recorded." }, + .failed => .{ .title = "Capture failed", .hint = "Check the endpoints and try again." }, + }; + return ui.column(.{ .grow = 1, .main = .center, .cross = .center, .gap = 6, .padding = 24 }, .{ + ui.text(.{ .size = .heading }, n.title), + ui.text(.{ .style_tokens = .{ .foreground = .text_muted } }, n.hint), + }); +} + // --- status bar ------------------------------------------------------------ fn statusBar(ui: *Ui, model: *const Model, filtered: ?[]const usize) Node { @@ -1071,20 +1252,35 @@ fn statusBar(ui: *Ui, model: *const Model, filtered: ?[]const usize) Node { const msg = std.fmt.allocPrint(ui.arena, "Failed to load {s}: {s}", .{ t.name, t.load_error orelse "unknown error" }) catch "load failed"; return ui.statusBar(.{}, msg); } + return ui.statusBar(.{}, traceStatusText(ui.arena, t, filtered)); +} + +/// The live status bar mirrors the trace one over `model.live.trace` (always +/// unfiltered); an error surfaces the parse reason. +fn liveStatusBar(ui: *Ui, t: *const LoadedTrace) Node { + if (t.isError()) { + const msg = std.fmt.allocPrint(ui.arena, "Live capture error: {s}", .{t.load_error orelse "unknown error"}) catch "live capture error"; + return ui.statusBar(.{}, msg); + } + return ui.statusBar(.{}, traceStatusText(ui.arena, t, null)); +} + +/// The summary line for a loaded trace: event count (match subset when +/// filtered), sessions, failure breakdown (or the skipped-detection note), and +/// parse warnings. Shared by the trace and live status bars. +fn traceStatusText(arena: std.mem.Allocator, t: *const LoadedTrace, filtered: ?[]const usize) []const u8 { // When a filter is active the count reflects matches out of the whole trace. const events_seg = if (filtered) |fi| - std.fmt.allocPrint(ui.arena, "{d} of {d} events", .{ fi.len, t.eventCount() }) catch "" + std.fmt.allocPrint(arena, "{d} of {d} events", .{ fi.len, t.eventCount() }) catch "" else - std.fmt.allocPrint(ui.arena, "{d} events", .{t.eventCount()}) catch ""; - const text = if (t.detection_skipped) - std.fmt.allocPrint(ui.arena, "{s} \u{00B7} {d} sessions \u{00B7} detection skipped (large trace) \u{00B7} {d} parse warnings", .{ + std.fmt.allocPrint(arena, "{d} events", .{t.eventCount()}) catch ""; + if (t.detection_skipped) + return std.fmt.allocPrint(arena, "{s} \u{00B7} {d} sessions \u{00B7} detection skipped (large trace) \u{00B7} {d} parse warnings", .{ events_seg, t.sessionCount(), t.warningCount(), - }) catch "" - else - std.fmt.allocPrint(ui.arena, "{s} \u{00B7} {d} sessions \u{00B7} {s} \u{00B7} {d} parse warnings", .{ - events_seg, t.sessionCount(), failuresSummary(ui.arena, t.failures), t.warningCount(), }) catch ""; - return ui.statusBar(.{}, text); + return std.fmt.allocPrint(arena, "{s} \u{00B7} {d} sessions \u{00B7} {s} \u{00B7} {d} parse warnings", .{ + events_seg, t.sessionCount(), failuresSummary(arena, t.failures), t.warningCount(), + }) catch ""; } // --------------------------------------------------------------------------- diff --git a/src/ui/workspace.zig b/src/ui/workspace.zig index 00fd3a8..4caded6 100644 --- a/src/ui/workspace.zig +++ b/src/ui/workspace.zig @@ -48,6 +48,17 @@ pub const max_live_bytes: usize = 8 * 1024 * 1024; pub const live_config_len: usize = 128; /// Display name for the live-capture trace. pub const live_trace_name = "live capture"; +/// First-run defaults for the live-capture endpoints, so the Start control is +/// usable immediately (the user edits them in place). Loopback + a plausible +/// CSMS URL — nothing is contacted until Start. +pub const default_listen = "127.0.0.1:9000"; +pub const default_upstream = "ws://127.0.0.1:8080/ocpp"; + +/// Which surface the inspector is showing: the opened-trace workspace, or the +/// live-capture session. They render through the same timeline/detail/failure +/// widgets over different `LoadedTrace`s; selection mutations route to whichever +/// is active (`Model.mutTrace`). +pub const Surface = enum { traces, live }; /// Timeline search + filter state. Facets AND-compose; an inactive filter shows /// the whole timeline. Lives on the `Model` (never memcpy'd) and stores the @@ -172,6 +183,12 @@ pub const Msg = union(enum) { toggle_severity_filter: types.FailureSeverity, /// Reset every facet and the search query. clear_filters, + /// Switch the inspector to the live-capture surface. + show_live, + /// A text edit (`insert`, `delete`, …) from the listen-endpoint field. + listen_input: canvas.TextInputEvent, + /// A text edit from the upstream-endpoint field. + upstream_input: canvas.TextInputEvent, /// Start a live capture using the model's configured endpoints. start_capture, /// Stop the running live capture. @@ -185,6 +202,14 @@ pub const Msg = union(enum) { /// The lifecycle of a live-capture session. pub const CaptureStatus = enum { idle, capturing, stopped, failed }; +/// Copy a comptime string into a fresh endpoint buffer — used for the field +/// defaults, so a new `LiveCapture` starts pre-filled with the example endpoints. +fn seedConfig(comptime s: []const u8) [live_config_len]u8 { + var b: [live_config_len]u8 = undefined; + @memcpy(b[0..s.len], s); + return b; +} + /// Live-capture state: a streaming proxy session recorded into a growing trace. /// The worker's NDJSON stream accumulates in `ndjson`; the live `trace` is /// re-derived from it (reusing the whole engine — `loadTrace`), so the inspector @@ -194,10 +219,15 @@ pub const LiveCapture = struct { status: CaptureStatus = .idle, /// `fx.spawn` identity for this session (for `fx.cancel`); bumped each start. key: u64 = 0, - listen_buf: [live_config_len]u8 = undefined, - listen_len: usize = 0, - upstream_buf: [live_config_len]u8 = undefined, - upstream_len: usize = 0, + listen_buf: [live_config_len]u8 = seedConfig(default_listen), + listen_len: usize = default_listen.len, + /// Caret/selection over `listen` — tracked model-side so text edits apply at + /// the right offset (the field widget itself is stateless; see the search + /// field in `Filter`). + listen_sel: canvas.TextSelection = .{}, + upstream_buf: [live_config_len]u8 = seedConfig(default_upstream), + upstream_len: usize = default_upstream.len, + upstream_sel: canvas.TextSelection = .{}, /// Accumulated NDJSON recording (bounded to `max_live_bytes`). ndjson: std.ArrayList(u8) = .empty, /// The live trace, re-derived from `ndjson`; owns its own arena. @@ -211,6 +241,10 @@ pub const LiveCapture = struct { pub fn upstream(self: *const LiveCapture) []const u8 { return self.upstream_buf[0..self.upstream_len]; } + /// True once both endpoints are set — the Start control gates on it. + pub fn configured(self: *const LiveCapture) bool { + return self.listen_len > 0 and self.upstream_len > 0; + } }; pub const Model = struct { @@ -230,6 +264,9 @@ pub const Model = struct { filter: Filter = .{}, /// Live-capture state (streaming proxy session). live: LiveCapture = .{}, + /// Which surface is showing — the opened-trace workspace or the live + /// capture. Selection mutations route to the matching trace (`mutTrace`). + surface: Surface = .traces, /// The running executable's path (argv[0]), for self-spawning the capture /// worker; set once at startup. self_exe_buf: [1024]u8 = undefined, @@ -260,6 +297,7 @@ pub const Model = struct { self.traces[self.trace_count] = loadTrace(self.backing, name, bytes); self.active = self.trace_count; self.trace_count += 1; + self.surface = .traces; // show the trace just opened } /// Append a trace that failed before parsing (e.g. the file could not be @@ -269,18 +307,36 @@ pub const Model = struct { self.traces[self.trace_count] = errorTrace(self.backing, name, reason); self.active = self.trace_count; self.trace_count += 1; + self.surface = .traces; } pub fn selectTrace(self: *Model, index: usize) void { - if (index < self.trace_count) self.active = index; + if (index < self.trace_count) { + self.active = index; + self.surface = .traces; + } + } + + /// Show the live-capture surface. + pub fn showLive(self: *Model) void { + self.surface = .live; + } + + /// The trace the user is currently interacting with: the live trace on the + /// live surface, else the active workspace tab (null when none are open). + /// Selection/toggle mutations route through this so the same timeline + /// widgets drive whichever surface is shown. + fn mutTrace(self: *Model) ?*LoadedTrace { + if (self.surface == .live) return &self.live.trace; + if (self.trace_count == 0) return null; + return &self.traces[self.active]; } /// Select a timeline row (event index) in the active trace. Out-of-range /// indices are ignored. Changing the selection resets the payload-tree /// collapse state, so the newly selected event opens fully expanded. pub fn selectEvent(self: *Model, index: usize) void { - if (self.trace_count == 0) return; - const t = &self.traces[self.active]; + const t = self.mutTrace() orelse return; if (index >= t.events.len) return; if (t.selected_event != index) t.payload_collapsed = PayloadCollapse.initEmpty(); t.selected_event = index; @@ -289,9 +345,9 @@ pub const Model = struct { /// Toggle the collapsed state of payload-tree node `id` for the active /// trace's selected event. Ids at or past the tracked range are ignored. pub fn togglePayloadNode(self: *Model, id: usize) void { - if (self.trace_count == 0) return; if (id >= max_payload_tree_nodes) return; - self.traces[self.active].payload_collapsed.toggle(id); + const t = self.mutTrace() orelse return; + t.payload_collapsed.toggle(id); } /// Jump to session `index` in the active trace by selecting its first @@ -299,8 +355,7 @@ pub const Model = struct { /// so the first event maps back to a timeline row by its stable event id. /// Out-of-range indices and empty sessions are ignored. pub fn selectSession(self: *Model, index: usize) void { - if (self.trace_count == 0) return; - const t = &self.traces[self.active]; + const t = self.mutTrace() orelse return; if (index >= t.sessions.len) return; const s = t.sessions[index]; if (s.events.len == 0) return; @@ -312,8 +367,7 @@ pub const Model = struct { /// open failure just collapses it, leaving the selection put. Out-of-range /// indices are ignored. pub fn selectFailure(self: *Model, index: usize) void { - if (self.trace_count == 0) return; - const t = &self.traces[self.active]; + const t = self.mutTrace() orelse return; if (index >= t.failures.len) return; if (t.expanded_failure == index) { t.expanded_failure = null; @@ -326,7 +380,7 @@ pub const Model = struct { /// Select the timeline row whose event id equals `id`, if present. fn selectEventById(self: *Model, id: []const u8) void { - const t = &self.traces[self.active]; + const t = self.mutTrace() orelse return; for (t.events, 0..) |e, i| { if (std.mem.eql(u8, e.id, id)) { self.selectEvent(i); @@ -367,6 +421,19 @@ pub const Model = struct { self.live.upstream_len = un; } + /// Apply a text edit from the listen-endpoint field. Ignored while capturing + /// — the endpoints are frozen for the life of a session. + pub fn applyListenInput(self: *Model, ev: canvas.TextInputEvent) void { + if (self.live.status == .capturing) return; + applyConfigField(&self.live.listen_buf, &self.live.listen_len, &self.live.listen_sel, ev); + } + + /// Apply a text edit from the upstream-endpoint field (frozen while capturing). + pub fn applyUpstreamInput(self: *Model, ev: canvas.TextInputEvent) void { + if (self.live.status == .capturing) return; + applyConfigField(&self.live.upstream_buf, &self.live.upstream_len, &self.live.upstream_sel, ev); + } + /// Record the running executable's path (argv[0]) for self-spawning. pub fn setSelfExe(self: *Model, path: []const u8) void { const n = @min(path.len, self.self_exe_buf.len); @@ -491,6 +558,9 @@ pub fn update(model: *Model, msg: Msg) void { .toggle_type_filter => |t| model.filter.message_type = if (model.filter.message_type == t) null else t, .toggle_severity_filter => |s| model.filter.severity = if (model.filter.severity == s) null else s, .clear_filters => model.clearFilters(), + .show_live => model.showLive(), + .listen_input => |ev| model.applyListenInput(ev), + .upstream_input => |ev| model.applyUpstreamInput(ev), .start_capture => model.startCapture(), .stop_capture => model.stopCapture(), .capture_line => |l| model.appendCaptureLine(l.line), @@ -498,6 +568,23 @@ pub fn update(model: *Model, msg: Msg) void { } } +/// Apply one text-edit event to a fixed endpoint buffer + caret. The edit runs +/// through a scratch copy (no aliasing with the live buffer), then is copied +/// back and the caret re-clamped; an edit that would overflow the buffer is +/// dropped. Mirrors `Model.applySearchInput` for the config fields. +fn applyConfigField(buf: []u8, len: *usize, sel: *canvas.TextSelection, ev: canvas.TextInputEvent) void { + var scratch: [live_config_len]u8 = undefined; + const cur = canvas.TextEditState{ .text = buf[0..len.*], .selection = sel.* }; + const next = canvas.applyTextInputEvent(cur, ev, &scratch) catch return; + const n = @min(next.text.len, buf.len); + @memcpy(buf[0..n], next.text[0..n]); + len.* = n; + sel.* = .{ + .anchor = @min(next.selection.anchor, n), + .focus = @min(next.selection.focus, n), + }; +} + /// Run the whole engine pipeline for one trace into a fresh arena. On any engine /// error the trace is returned in its error state (arena retained so `name` and /// the reason survive on screen); only an arena-allocation failure yields the @@ -875,3 +962,59 @@ test "live capture status transitions and gates lines when not capturing" { try testing.expect(model.live.key != prev_key); try testing.expectEqual(@as(usize, 0), model.live.ndjson.items.len); } + +test "the live surface routes selection to the live trace, leaving the workspace put" { + var model = Model{ .backing = testing.allocator }; + defer model.deinitAll(); + + // A workspace trace and a live session coexist. + update(&model, .open_sample); + try testing.expectEqual(Surface.traces, model.surface); // opening shows the trace + update(&model, .start_capture); + for ([_][]const u8{ + "{\"timestamp\":1705312800000,\"direction\":\"CS_TO_CSMS\",\"message\":[2,\"m1\",\"BootNotification\",{}]}", + "{\"timestamp\":1705312800500,\"direction\":\"CSMS_TO_CS\",\"message\":[3,\"m1\",{\"status\":\"Accepted\"}]}", + }) |line| update(&model, .{ .capture_line = .{ .key = model.live.key, .line = line } }); + + // On the traces surface, select_event lands on the workspace trace. + update(&model, .{ .select_event = 3 }); + try testing.expectEqual(@as(?usize, 3), model.traces[0].selected_event); + try testing.expectEqual(@as(?usize, null), model.live.trace.selected_event); + + // Switch to live: the same message now drives the live trace; the workspace + // selection is untouched. + update(&model, .show_live); + try testing.expectEqual(Surface.live, model.surface); + update(&model, .{ .select_event = 1 }); + try testing.expectEqual(@as(?usize, 1), model.live.trace.selected_event); + try testing.expectEqual(@as(?usize, 3), model.traces[0].selected_event); + + // Selecting a workspace tab returns to the traces surface. + update(&model, .{ .select_trace = 0 }); + try testing.expectEqual(Surface.traces, model.surface); +} + +test "endpoint fields edit through the input path and freeze while capturing" { + var model = Model{ .backing = testing.allocator }; + defer model.deinitAll(); + + // A fresh model is seeded with the example defaults, so Start is usable. + try testing.expectEqualStrings(default_listen, model.live.listen()); + try testing.expectEqualStrings(default_upstream, model.live.upstream()); + try testing.expect(model.live.configured()); + + // Clearing and typing a new listen endpoint round-trips through update. + update(&model, .{ .listen_input = .clear }); + try testing.expectEqualStrings("", model.live.listen()); + try testing.expect(!model.live.configured()); // one endpoint empty + update(&model, .{ .listen_input = .{ .insert_text = "0.0.0.0:7000" } }); + try testing.expectEqualStrings("0.0.0.0:7000", model.live.listen()); + try testing.expect(model.live.configured()); + + // While capturing, the endpoints are frozen for the life of the session. + update(&model, .start_capture); + update(&model, .{ .listen_input = .{ .insert_text = "XXX" } }); + update(&model, .{ .upstream_input = .clear }); + try testing.expectEqualStrings("0.0.0.0:7000", model.live.listen()); + try testing.expectEqualStrings(default_upstream, model.live.upstream()); +}