diff --git a/CURRENT_STATE.md b/CURRENT_STATE.md index 56b2e49..1f70cfe 100644 --- a/CURRENT_STATE.md +++ b/CURRENT_STATE.md @@ -72,18 +72,21 @@ toolkit's: ## What's in progress -**S3 — Inspector UI (0.2.0).** The inspector shell has landed (#27): the -placeholder counter is replaced by a Zig `canvas.Ui` builder view (ADR-0006), a -bounded multi-trace workspace `Model` / `Msg` / `update` (`src/ui/`), and trace -loading from command-line path arguments (read unbounded in `main` via -`init.io`) plus a built-in embedded sample. The window opens on an empty state or -the loaded trace's overview. - -Still ahead in S3: the virtualized event timeline (#28), the trusted-ingestion -capacity path for 500k-event / 100 MB traces (#29), the message inspector + -session panel (#30), the failure panel (#31), and search / filter (#32). -Interactive open (native dialog + drag-drop) is deferred to #33 — it needs an -ejected runner (see ADR-0006). +**S3 — Inspector UI (0.2.0).** Landed so far: + +- **Inspector shell (#27)** — the placeholder counter is replaced by a Zig + `canvas.Ui` builder view (ADR-0006), a bounded multi-trace workspace `Model` / + `Msg` / `update` (`src/ui/`), and trace loading from command-line path + arguments (read unbounded in `main` via `init.io`) plus a built-in sample. +- **Virtualized event timeline (#28)** — a windowed virtual list + (`ui.virtualList`) in a model-owned `split`: one row per event (severity dot, + time, direction, message, type), row selection, and a first-cut detail pane. + The window stays viewport-sized in widget nodes no matter the trace length. + +Still ahead in S3: the trusted-ingestion capacity path for 500k-event / 100 MB +traces (#29), the full message inspector + session panel (#30), the failure panel +(#31), and search / filter (#32). Interactive open (native dialog + drag-drop) is +deferred to #33 — it needs an ejected runner (see ADR-0006). ## What's next @@ -101,7 +104,7 @@ wall-clock replay, and a headless CLI mode. | `repo` (tooling, CI) | ✅ done for S0 | | `docs` (docs, ADRs) | ✅ done for S0 | | `ocpp` (engine) | ✅ done for S2 | -| `ui` (native views) | 🚧 shell landed (S3, #27); timeline + panes next | +| `ui` (native views) | 🚧 shell + timeline (S3, #27–#28); panes + search next | | `capture` (live proxy) | ⬜ not started (S5) | | `cli` (headless) | ⬜ not started (S4) | | `conformance` | ✅ done for S2 (15/15, `contract-v1`) | diff --git a/scripts/smoke.sh b/scripts/smoke.sh index 6d429d7..ef49400 100755 --- a/scripts/smoke.sh +++ b/scripts/smoke.sh @@ -55,6 +55,7 @@ native automate wait --timeout-ms 60000 native automate assert --timeout-ms 30000 \ 'ready=true' \ 'normal-session.json' \ + 'BootNotification' \ '22 events' \ 'OCPP DebugKit Studio' diff --git a/src/tests.zig b/src/tests.zig index 4eb5077..1579040 100644 --- a/src/tests.zig +++ b/src/tests.zig @@ -44,6 +44,16 @@ fn findKind(widget: canvas.Widget, kind: canvas.WidgetKind) ?canvas.Widget { return null; } +/// The first `row` widget whose subtree contains `text` — used to grab a +/// pressable timeline row by the message it shows. +fn findRowWithText(widget: canvas.Widget, text: []const u8) ?canvas.Widget { + if (widget.kind == .row and findByText(widget, .text, text) != null) return widget; + for (widget.children) |child| { + if (findRowWithText(child, text)) |found| return found; + } + return null; +} + test "the empty workspace offers the open-sample affordance" { var arena_state = std.heap.ArenaAllocator.init(testing.allocator); defer arena_state.deinit(); @@ -56,7 +66,7 @@ test "the empty workspace offers the open-sample affordance" { _ = try expectByText(tree.root, .button, "Open sample"); } -test "clicking Open sample loads the sample and the overview reflects it" { +test "clicking Open sample loads the sample and the timeline renders it" { var arena_state = std.heap.ArenaAllocator.init(testing.allocator); defer arena_state.deinit(); const arena = arena_state.allocator(); @@ -70,14 +80,58 @@ test "clicking Open sample loads the sample and the overview reflects it" { main.update(&model, tree.msgForPointer(open.id, .up).?); try testing.expect(model.hasTraces()); - // Rebuild: the overview shows the event count and the status bar summarizes. + // Rebuild: the timeline renders event rows (the first is a BootNotification + // Call) and the status bar summarizes the trace. tree = try buildTree(arena, &model); - _ = try expectByText(tree.root, .text, "22"); // events stat tile value - _ = try expectByText(tree.root, .text, "events"); + _ = try expectByText(tree.root, .text, "BootNotification"); const status = findKind(tree.root, .status_bar) orelse return error.WidgetNotFound; try testing.expect(std.mem.indexOf(u8, status.text, "22 events") != null); } +test "clicking a timeline row selects the event and the detail pane reflects it" { + 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, .open_sample); + + var tree = try buildTree(arena, &model); + _ = try expectByText(tree.root, .text, "Select an event to inspect it"); + + // Press the BootNotification row (event 0); selection follows through the + // pressable row's on_press. + const row = findRowWithText(tree.root, "BootNotification") orelse return error.WidgetNotFound; + main.update(&model, tree.msgForPointer(row.id, .up).?); + try testing.expectEqual(@as(?usize, 0), model.activeTrace().?.selected_event); + + // Rebuild: the detail pane shows the selected event's fields. + tree = try buildTree(arena, &model); + _ = try expectByText(tree.root, .text, "evt-0001"); // the event id + _ = try expectByText(tree.root, .text, "Message ID"); // a detail-row label +} + +test "the virtual window stays viewport-sized at dataset scale" { + var arena_state = std.heap.ArenaAllocator.init(testing.allocator); + defer arena_state.deinit(); + + // Half a million events, but the window derives from the viewport, not the + // count: the timeline materializes only a few dozen row nodes — far under + // the 1024-node per-view budget. This is the capacity claim in miniature + // (the engine side lands in #29). + var ui = Ui.init(arena_state.allocator()); + const window = ui.virtualWindow(.{ + .id = "event-timeline", + .item_count = 500_000, + .item_extent = 44, + .overscan = 6, + .viewport_fallback = 640, + }); + try testing.expect(window.itemCount() > 0); + try testing.expect(window.itemCount() < 64); +} + test "a second trace produces a tab strip that switches the active trace" { var arena_state = std.heap.ArenaAllocator.init(testing.allocator); defer arena_state.deinit(); diff --git a/src/ui/inspector.zig b/src/ui/inspector.zig index d5d42b7..251bcfe 100644 --- a/src/ui/inspector.zig +++ b/src/ui/inspector.zig @@ -11,6 +11,8 @@ const std = @import("std"); const native_sdk = @import("native_sdk"); const canvas = native_sdk.canvas; const workspace = @import("workspace.zig"); +const ocpp = @import("../ocpp/ocpp.zig"); +const types = ocpp.types; const Model = workspace.Model; const Msg = workspace.Msg; @@ -19,6 +21,11 @@ const LoadedTrace = workspace.LoadedTrace; pub const Ui = canvas.Ui(Msg); const Node = Ui.Node; +/// Fixed timeline row height (points). Uniform rows are the virtual list's fast +/// path: the visible window is pure arithmetic, so a 500k-event trace still +/// materializes only ~viewport/extent row nodes. +const row_extent: f32 = 44; + pub fn view(ui: *Ui, model: *const Model) Node { if (!model.hasTraces()) return emptyState(ui); return ui.column(.{ .grow = 1 }, .{ @@ -74,7 +81,16 @@ fn tabStrip(ui: *Ui, model: *const Model) Node { fn activeBody(ui: *Ui, model: *const Model) Node { const t = model.activeTrace().?; if (t.isError()) return errorPanel(ui, t); - return overview(ui, t); + // Timeline (left) / detail (right), a model-owned splitter that echoes each + // drag back through `timeline_split`. + return ui.split(.{ + .grow = 1, + .value = model.timeline_split, + .on_resize = Ui.valueMsg(.timeline_resized), + }, .{ + timelinePane(ui, t), + detailPane(ui, t), + }); } fn errorPanel(ui: *Ui, t: *const LoadedTrace) Node { @@ -84,24 +100,192 @@ fn errorPanel(ui: *Ui, t: *const LoadedTrace) Node { }); } -fn overview(ui: *Ui, t: *const LoadedTrace) Node { - return ui.column(.{ .grow = 1, .gap = 16, .padding = 24 }, .{ - ui.text(.{ .size = .heading }, t.name), - ui.row(.{ .gap = 12, .cross = .start }, .{ - statTile(ui, t.eventCount(), "events"), - statTile(ui, t.sessionCount(), "sessions"), - statTile(ui, t.failureCount(), "failures"), - statTile(ui, t.warningCount(), "warnings"), - }), - ui.text(.{}, "The event timeline and inspector panes arrive in the next step."), +// --- timeline pane: the windowed virtual list ------------------------------ + +fn timelinePane(ui: *Ui, t: *const LoadedTrace) Node { + const opts = Ui.VirtualListOptions{ + .id = "event-timeline", + .item_count = t.events.len, + .item_extent = row_extent, + .overscan = 6, + .grow = 1, + // Used only by bare `finalize` builds (tests); under UiApp the runtime + // window source uses the pane's real height instead. + .viewport_fallback = 640, + .semantics = .{ .label = "event timeline" }, + }; + const window = ui.virtualWindow(opts); + const rows = ui.arena.alloc(Node, window.itemCount()) catch { + ui.failed = true; + return ui.column(.{ .min_width = 360, .grow = 1 }, .{}); + }; + for (rows, 0..) |*row, offset| { + const index = window.start_index + offset; + var node = eventRow(ui, t, index); + node.key = .{ .int = @intCast(index) }; // identity = the event, not the slot + row.* = node; + } + return ui.column(.{ .min_width = 360, .grow = 1 }, .{ + timelineHeader(ui), + ui.separator(.{}), + ui.virtualList(opts, window, .{rows}), + }); +} + +fn timelineHeader(ui: *Ui) Node { + const muted = canvas.StyleTokenRefs{ .foreground = .text_muted }; + return ui.row(.{ .padding = 6, .gap = 8, .cross = .center }, .{ + ui.text(.{ .width = 16 }, ""), + ui.text(.{ .width = 44, .style_tokens = muted }, "#"), + ui.text(.{ .width = 100, .style_tokens = muted }, "Time"), + ui.text(.{ .width = 24 }, ""), + ui.text(.{ .grow = 1, .style_tokens = muted }, "Message"), + ui.text(.{ .width = 88, .style_tokens = muted }, "Type"), + }); +} + +fn eventRow(ui: *Ui, t: *const LoadedTrace, index: usize) Node { + const e = t.events[index]; + const number = std.fmt.allocPrint(ui.arena, "{d}", .{index + 1}) catch ""; + return ui.row(.{ + .on_press = .{ .select_event = index }, + .selected = (t.selected_event == index), + .height = row_extent, + .padding = 6, + .gap = 8, + .cross = .center, + }, .{ + severityDot(ui, eventSeverity(t, e.id)), + ui.text(.{ .width = 44, .style_tokens = .{ .foreground = .text_muted } }, number), + ui.text(.{ .width = 100 }, formatTime(ui.arena, e.timestamp)), + directionIcon(ui, e.direction), + ui.text(.{ .grow = 1 }, rowSummary(ui.arena, e)), + ui.text(.{ .width = 88, .style_tokens = .{ .foreground = .text_muted } }, e.message_type.toWire()), + }); +} + +fn severityDot(ui: *Ui, severity: ?types.FailureSeverity) Node { + const s = severity orelse return ui.text(.{ .width = 16 }, ""); + return ui.icon(.{ + .width = 16, + .style_tokens = .{ .foreground = severityColor(s) }, + .semantics = .{ .label = severityLabel(s) }, + }, "circle-dot"); +} + +fn directionIcon(ui: *Ui, direction: types.Direction) Node { + return switch (direction) { + .cs_to_csms => ui.icon(.{ .width = 24, .semantics = .{ .label = "charge point to CSMS" } }, "chevron-right"), + .csms_to_cs => ui.icon(.{ .width = 24, .semantics = .{ .label = "CSMS to charge point" } }, "chevron-left"), + .unknown => ui.icon(.{ .width = 24, .semantics = .{ .label = "unknown direction" } }, "circle-dot"), + }; +} + +/// The worst-severity failure the event participates in, or null. The scan is +/// over the trace's failures (few) for the built (visible) rows only. +fn eventSeverity(t: *const LoadedTrace, event_id: []const u8) ?types.FailureSeverity { + var worst: ?types.FailureSeverity = null; + for (t.failures) |f| { + for (f.event_ids) |eid| { + if (std.mem.eql(u8, eid, event_id)) { + if (worst == null or severityRank(f.severity) < severityRank(worst.?)) worst = f.severity; + } + } + } + return worst; +} + +fn severityRank(s: types.FailureSeverity) u8 { + return switch (s) { + .critical => 0, + .warning => 1, + .info => 2, + }; +} + +fn severityColor(s: types.FailureSeverity) canvas.ColorTokenName { + return switch (s) { + .critical => .destructive, + .warning => .warning, + .info => .info, + }; +} + +fn severityLabel(s: types.FailureSeverity) []const u8 { + return switch (s) { + .critical => "critical failure", + .warning => "warning", + .info => "info", + }; +} + +fn rowSummary(arena: std.mem.Allocator, e: types.Event) []const u8 { + return switch (e.message_type) { + .call => e.action orelse "(call)", + // A result's UniqueId is the call's — surfacing it lets the eye correlate. + .call_result => e.message_id, + .call_error => std.fmt.allocPrint(arena, "{s}: {s}", .{ + e.error_code orelse "error", e.error_description orelse "", + }) catch (e.error_code orelse "error"), + }; +} + +/// Epoch-ms → UTC time-of-day `HH:MM:SS.mmm`. Session timelines rarely cross a +/// day; the full date lives in the detail pane. Missing/invalid → a dash. +fn formatTime(arena: std.mem.Allocator, ts: ?i64) []const u8 { + const ms = ts orelse return "--"; + if (ms < 0) return "--"; + const total_s = @divFloor(ms, 1000); + return std.fmt.allocPrint(arena, "{d:0>2}:{d:0>2}:{d:0>2}.{d:0>3}", .{ + @mod(@divFloor(total_s, 3600), 24), + @mod(@divFloor(total_s, 60), 60), + @mod(total_s, 60), + @mod(ms, 1000), + }) catch "--"; +} + +// --- detail pane: minimal for now; #30 enriches it ------------------------- + +fn detailPane(ui: *Ui, t: *const LoadedTrace) Node { + if (t.selected_event) |idx| { + if (idx < t.events.len) return eventDetail(ui, t.events[idx]); + } + return ui.column(.{ .min_width = 280, .grow = 1, .main = .center, .cross = .center, .padding = 24 }, .{ + ui.text(.{ .style_tokens = .{ .foreground = .text_muted } }, "Select an event to inspect it"), + }); +} + +fn eventDetail(ui: *Ui, e: types.Event) Node { + var rows: [7]Node = undefined; + var n: usize = 0; + rows[n] = detailRow(ui, "Event", e.id); + n += 1; + rows[n] = detailRow(ui, "Message ID", e.message_id); + n += 1; + rows[n] = detailRow(ui, "Type", e.message_type.toWire()); + n += 1; + rows[n] = detailRow(ui, "Direction", e.direction.toWire()); + n += 1; + rows[n] = detailRow(ui, "Time", formatTime(ui.arena, e.timestamp)); + n += 1; + if (e.error_code) |code| { + rows[n] = detailRow(ui, "Error code", code); + n += 1; + } + if (e.error_description) |desc| { + rows[n] = detailRow(ui, "Error", desc); + n += 1; + } + return ui.column(.{ .min_width = 280, .grow = 1, .gap = 10, .padding = 16 }, .{ + ui.text(.{ .size = .heading }, e.action orelse e.message_type.toWire()), + ui.column(.{ .gap = 6 }, rows[0..n]), }); } -fn statTile(ui: *Ui, value: usize, label: []const u8) Node { - const value_str = std.fmt.allocPrint(ui.arena, "{d}", .{value}) catch "?"; - return ui.column(.{ .gap = 2, .padding = 12, .min_width = 92 }, .{ - ui.text(.{ .size = .heading }, value_str), - ui.text(.{}, label), +fn detailRow(ui: *Ui, label: []const u8, value: []const u8) Node { + return ui.row(.{ .gap = 8, .cross = .start }, .{ + ui.text(.{ .width = 96, .style_tokens = .{ .foreground = .text_muted } }, label), + ui.text(.{ .grow = 1 }, value), }); } diff --git a/src/ui/workspace.zig b/src/ui/workspace.zig index af3c42a..cfde6c8 100644 --- a/src/ui/workspace.zig +++ b/src/ui/workspace.zig @@ -79,6 +79,10 @@ pub const Msg = union(enum) { select_trace: usize, /// Close trace `payload`, freeing its arena. close_trace: usize, + /// Select event `payload` (a timeline row) in the active trace. + select_event: usize, + /// The timeline / detail splitter moved to fraction `payload`. + timeline_resized: f32, }; pub const Model = struct { @@ -90,6 +94,9 @@ pub const Model = struct { trace_count: usize = 0, /// Index of the active trace within `traces[0..trace_count]`. active: usize = 0, + /// The timeline / detail splitter fraction (first pane), model-owned so the + /// `split` reconcile echoes it back through `value` after each drag. + timeline_split: f32 = 0.62, // --- derived (never stored) ------------------------------------------- @@ -131,6 +138,14 @@ pub const Model = struct { if (index < self.trace_count) self.active = index; } + /// Select a timeline row (event index) in the active trace. Out-of-range + /// indices are ignored. + pub fn selectEvent(self: *Model, index: usize) void { + if (self.trace_count == 0) return; + const t = &self.traces[self.active]; + if (index < t.events.len) t.selected_event = index; + } + /// Close trace `index`, freeing its arena and compacting the tab list. pub fn closeTrace(self: *Model, index: usize) void { if (index >= self.trace_count) return; @@ -172,6 +187,8 @@ pub fn update(model: *Model, msg: Msg) void { .open_sample => model.openBytes(sample_name, sample_bytes), .select_trace => |i| model.selectTrace(i), .close_trace => |i| model.closeTrace(i), + .select_event => |i| model.selectEvent(i), + .timeline_resized => |f| model.timeline_split = f, } } @@ -305,6 +322,28 @@ test "a malformed trace opens in its error state without crashing" { try testing.expectEqual(@as(usize, 0), t.eventCount()); } +test "selecting an event records it on the active trace, bounds-checked" { + var model = Model{ .backing = testing.allocator }; + defer model.deinitAll(); + update(&model, .open_sample); + + try testing.expectEqual(@as(?usize, null), model.activeTrace().?.selected_event); + update(&model, .{ .select_event = 5 }); + try testing.expectEqual(@as(?usize, 5), model.activeTrace().?.selected_event); + + // Out of range is ignored (the sample has 22 events). + update(&model, .{ .select_event = 9999 }); + try testing.expectEqual(@as(?usize, 5), model.activeTrace().?.selected_event); +} + +test "the splitter fraction is model-owned and echoed by update" { + var model = Model{ .backing = testing.allocator }; + defer model.deinitAll(); + try testing.expectApproxEqAbs(@as(f32, 0.62), model.timeline_split, 0.0001); + update(&model, .{ .timeline_resized = 0.4 }); + try testing.expectApproxEqAbs(@as(f32, 0.4), model.timeline_split, 0.0001); +} + test "the workspace is bounded at max_open_traces" { var model = Model{ .backing = testing.allocator }; defer model.deinitAll();