diff --git a/CURRENT_STATE.md b/CURRENT_STATE.md index 3125b52..ad9bfdb 100644 --- a/CURRENT_STATE.md +++ b/CURRENT_STATE.md @@ -97,8 +97,14 @@ toolkit's: input), and the raw OCPP-J array pretty-printed. Jump selects the session's first event (highlighting it and driving the panels); the literal timeline viewport scroll rides the same runtime-eject as #33. - -Still ahead in S3: the failure panel (#31) and search / filter (#32). +- **Failure panel (#31)** — a fixed-height drawer under the timeline lists every + detected failure, ranked critical → warning → info (then by first event), each + with its severity, code, and description. Selecting one expands its remediation + steps and affected events (accordion) and jumps to its primary event, so a + failure and its evidence line up. A clean trace shows a positive + "no failures detected" state; the status bar carries the severity breakdown. + +Still ahead in S3: search / filter (#32), which closes the milestone. Interactive open (native dialog + drag-drop) is deferred to #33 — it needs an ejected runner (see ADR-0006). @@ -118,7 +124,7 @@ wall-clock replay, and a headless CLI mode. | `repo` (tooling, CI) | ✅ done for S0 | | `docs` (docs, ADRs) | ✅ done for S0 | | `ocpp` (engine) | ✅ S2 + trusted ingestion (#29); O(n) detection pending (#36) | -| `ui` (native views) | 🚧 shell + timeline + message inspector (S3, #27–#28, #30); failure panel + search next | +| `ui` (native views) | 🚧 shell + timeline + inspector + failure panel (S3, #27–#28, #30–#31); search next | | `capture` (live proxy) | ⬜ not started (S5) | | `cli` (headless) | ⬜ not started (S4) | | `conformance` | ✅ done for S2 (15/15, `contract-v1`) | diff --git a/src/tests.zig b/src/tests.zig index 653834a..a9736c9 100644 --- a/src/tests.zig +++ b/src/tests.zig @@ -269,6 +269,45 @@ test "toggling a payload container collapses its children" { try testing.expect(findByText(tree.root, .text, "timestamp") == null); } +test "the failure panel shows the clean-trace positive state" { + 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, .open_sample); // the sample is a clean session + + const tree = try buildTree(arena_state.allocator(), &model); + _ = try expectByText(tree.root, .text, "Failures"); // the drawer header + _ = try expectByText(tree.root, .text, "No failures detected"); +} + +test "the failure panel lists failures, expands steps, and jumps to the event" { + 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(); + // The failed-auth conformance fixture detects one FAILED_AUTHORIZATION. + model.openBytes("failed-auth.json", @embedFile("ocpp/conformance/fixtures/failed-auth.json")); + + var tree = try buildTree(arena, &model); + _ = try expectByText(tree.root, .text, "FAILED_AUTHORIZATION"); + // Collapsed: remediation is hidden until the row is opened. + try testing.expect(findByText(tree.root, .text, "Suggested steps") == null); + + // Clicking the failure row expands it and jumps to its primary event. + const row = findRowWithText(tree.root, "FAILED_AUTHORIZATION") orelse return error.WidgetNotFound; + main.update(&model, tree.msgForPointer(row.id, .up).?); + try testing.expectEqual(@as(?usize, 0), model.activeTrace().?.expanded_failure); + try testing.expect(model.activeTrace().?.selected_event != null); + + tree = try buildTree(arena, &model); + _ = try expectByText(tree.root, .text, "Suggested steps"); + _ = try expectByText(tree.root, .text, "Affected"); +} + test "the virtual window stays viewport-sized at dataset scale" { var arena_state = std.heap.ArenaAllocator.init(testing.allocator); defer arena_state.deinit(); @@ -344,6 +383,14 @@ test "the inspector view passes the accessibility sweep (empty and loaded)" { workspace.update(&model, .{ .select_event = 5 }); // detail pane: tree, session, raw tree = try buildTree(arena, &model); try canvas.expectA11yAuditSweepClean(testing.allocator, tree.root, sweep); + + // A trace with an expanded failure exercises the failure drawer too. + var failing = Model{ .backing = testing.allocator }; + defer failing.deinitAll(); + failing.openBytes("failed-auth.json", @embedFile("ocpp/conformance/fixtures/failed-auth.json")); + workspace.update(&failing, .{ .select_failure = 0 }); + tree = try buildTree(arena, &failing); + try canvas.expectA11yAuditSweepClean(testing.allocator, tree.root, sweep); } test "the inspector view lays out through the canvas engine" { diff --git a/src/ui/inspector.zig b/src/ui/inspector.zig index 506ae32..18d665c 100644 --- a/src/ui/inspector.zig +++ b/src/ui/inspector.zig @@ -81,15 +81,20 @@ 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); - // 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), + // 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. + return ui.column(.{ .grow = 1 }, .{ + ui.split(.{ + .grow = 1, + .value = model.timeline_split, + .on_resize = Ui.valueMsg(.timeline_resized), + }, .{ + timelinePane(ui, t), + detailPane(ui, t), + }), + ui.separator(.{}), + failuresPanel(ui, t), }); } @@ -675,6 +680,163 @@ fn append(a: std.mem.Allocator, buf: *std.ArrayList(u8), s: []const u8) void { buf.appendSlice(a, s) catch {}; } +// --- failure panel (#31) --------------------------------------------------- +// +// Every detected failure for the active trace, ranked critical -> warning -> +// info (then by first event), in a fixed-height drawer beneath the timeline. +// Selecting a failure expands its remediation steps (accordion, at most one +// open) and jumps to its primary event so the failure and its evidence align. + +const failures_panel_height: f32 = 240; +const max_failures_shown: usize = 50; + +fn failuresPanel(ui: *Ui, t: *const LoadedTrace) Node { + return ui.column(.{ .height = failures_panel_height }, .{ + failuresHeader(ui, t), + ui.separator(.{}), + failuresBody(ui, t), + }); +} + +fn failuresHeader(ui: *Ui, t: *const LoadedTrace) Node { + return ui.row(.{ .padding = 8, .gap = 8, .cross = .center }, .{ + ui.text(.{}, "Failures"), + ui.spacer(1), + ui.text(.{ .style_tokens = .{ .foreground = .text_muted } }, failuresSummary(ui.arena, t.failures)), + }); +} + +fn failuresBody(ui: *Ui, t: *const LoadedTrace) Node { + if (t.detection_skipped) + return centeredNote(ui, "Failure detection was skipped for this large trace"); + if (t.failures.len == 0) + return centeredNote(ui, "No failures detected"); + + const order = sortedFailureIndices(ui.arena, t.failures); + const shown = @min(order.len, max_failures_shown); + const extra: usize = if (order.len > shown) 1 else 0; + const rows = ui.arena.alloc(Node, shown + extra) catch { + ui.failed = true; + return centeredNote(ui, ""); + }; + for (0..shown) |k| rows[k] = failureRow(ui, t, order[k]); + if (extra == 1) { + const label = std.fmt.allocPrint(ui.arena, "... {d} more", .{order.len - shown}) catch "... more"; + rows[shown] = moreRow(ui, label, 0); + } + return ui.scroll(.{ .grow = 1 }, .{ + ui.column(.{ .gap = 4, .padding = 8 }, rows), + }); +} + +fn centeredNote(ui: *Ui, text: []const u8) Node { + return ui.column(.{ .grow = 1, .main = .center, .cross = .center, .padding = 16 }, .{ + ui.text(.{ .style_tokens = .{ .foreground = .text_muted } }, text), + }); +} + +fn failureRow(ui: *Ui, t: *const LoadedTrace, i: usize) Node { + const f = t.failures[i]; + const expanded = (t.expanded_failure == i); + const header = ui.row(.{ + .on_press = .{ .select_failure = i }, + .selected = expanded, + .padding = 6, + .gap = 8, + .cross = .center, + .semantics = .{ .label = f.code.toWire() }, + }, .{ + severityDot(ui, f.severity), + ui.text(.{ .style_tokens = .{ .foreground = severityColor(f.severity) } }, f.code.toWire()), + ui.text(.{ .grow = 1 }, f.description), + disclosureGlyph(ui, true, expanded), + }); + if (!expanded) return header; + return ui.column(.{ .gap = 4 }, .{ + header, + failureDetail(ui, f), + }); +} + +fn failureDetail(ui: *Ui, f: types.Failure) Node { + var items: std.ArrayList(Node) = .empty; + items.append(ui.arena, detailRow(ui, "Affected", joinEventIds(ui.arena, f.event_ids))) catch { + ui.failed = true; + }; + if (f.suggested_steps.len > 0) { + items.append(ui.arena, ui.text(.{ .style_tokens = .{ .foreground = .text_muted } }, "Suggested steps")) catch {}; + for (f.suggested_steps) |step| { + items.append(ui.arena, stepRow(ui, step)) catch {}; + } + } + return ui.column(.{ .gap = 4, .padding = 8 }, items.items); +} + +fn stepRow(ui: *Ui, step: []const u8) Node { + return ui.row(.{ .gap = 6, .cross = .start }, .{ + ui.text(.{ .width = 16, .style_tokens = .{ .foreground = .text_muted } }, "\u{00B7}"), + ui.text(.{ .grow = 1 }, step), + }); +} + +/// Affected event ids as a compact comma-joined list, capped so a many-event +/// failure stays readable. +fn joinEventIds(arena: std.mem.Allocator, ids: []const []const u8) []const u8 { + if (ids.len == 0) return "none"; + const cap: usize = 6; + const shown = @min(ids.len, cap); + var buf: std.ArrayList(u8) = .empty; + for (ids[0..shown], 0..) |id, i| { + if (i > 0) buf.appendSlice(arena, ", ") catch {}; + buf.appendSlice(arena, id) catch {}; + } + if (ids.len > shown) { + buf.appendSlice(arena, std.fmt.allocPrint(arena, ", +{d} more", .{ids.len - shown}) catch "") catch {}; + } + return buf.items; +} + +/// Display order for the failure list: severity (critical -> warning -> info), +/// then first affected event id. Returns indices into `failures`; the engine's +/// slice is never mutated. +fn sortedFailureIndices(arena: std.mem.Allocator, failures: []const types.Failure) []usize { + const idx = arena.alloc(usize, failures.len) catch return &.{}; + for (idx, 0..) |*v, i| v.* = i; + std.mem.sort(usize, idx, failures, lessFailure); + return idx; +} + +fn lessFailure(failures: []const types.Failure, a: usize, b: usize) bool { + const fa = failures[a]; + const fb = failures[b]; + const ra = severityRank(fa.severity); + const rb = severityRank(fb.severity); + if (ra != rb) return ra < rb; + const ea = if (fa.event_ids.len > 0) fa.event_ids[0] else ""; + const eb = if (fb.event_ids.len > 0) fb.event_ids[0] else ""; + return std.mem.order(u8, ea, eb) == .lt; +} + +/// "N failures: C critical, W warning, I info" (nonzero severities only), or +/// "no failures". Shared by the failure-panel header and the status bar. +fn failuresSummary(arena: std.mem.Allocator, failures: []const types.Failure) []const u8 { + if (failures.len == 0) return "no failures"; + var counts = [_]usize{ 0, 0, 0 }; // critical, warning, info + for (failures) |f| counts[severityRank(f.severity)] += 1; + + var buf: std.ArrayList(u8) = .empty; + buf.appendSlice(arena, std.fmt.allocPrint(arena, "{d} failure{s}", .{ failures.len, if (failures.len == 1) "" else "s" }) catch "") catch {}; + var first = true; + for ([_][]const u8{ "critical", "warning", "info" }, 0..) |word, rank| { + if (counts[rank] > 0) { + const sep = if (first) ": " else ", "; + buf.appendSlice(arena, std.fmt.allocPrint(arena, "{s}{d} {s}", .{ sep, counts[rank], word }) catch "") catch {}; + first = false; + } + } + return buf.items; +} + // --- status bar ------------------------------------------------------------ fn statusBar(ui: *Ui, model: *const Model) Node { @@ -682,12 +844,64 @@ fn statusBar(ui: *Ui, model: *const Model) Node { const text = if (t.isError()) std.fmt.allocPrint(ui.arena, "Failed to load {s}: {s}", .{ t.name, t.load_error orelse "unknown error" }) catch "load failed" else if (t.detection_skipped) - std.fmt.allocPrint(ui.arena, "{d} events \u{00B7} {d} sessions \u{00B7} detection skipped (large trace) \u{00B7} {d} warnings", .{ + std.fmt.allocPrint(ui.arena, "{d} events \u{00B7} {d} sessions \u{00B7} detection skipped (large trace) \u{00B7} {d} parse warnings", .{ t.eventCount(), t.sessionCount(), t.warningCount(), }) catch "" else - std.fmt.allocPrint(ui.arena, "{d} events \u{00B7} {d} sessions \u{00B7} {d} failures \u{00B7} {d} warnings", .{ - t.eventCount(), t.sessionCount(), t.failureCount(), t.warningCount(), + std.fmt.allocPrint(ui.arena, "{d} events \u{00B7} {d} sessions \u{00B7} {s} \u{00B7} {d} parse warnings", .{ + t.eventCount(), t.sessionCount(), failuresSummary(ui.arena, t.failures), t.warningCount(), }) catch ""; return ui.statusBar(.{}, text); } + +// --------------------------------------------------------------------------- +// Tests — pure view helpers (widget-level behavior is covered in tests.zig) +// --------------------------------------------------------------------------- + +const testing = std.testing; + +fn testFailure(code: types.FailureCode, severity: types.FailureSeverity, comptime first_event: []const u8) types.Failure { + return .{ + .code = code, + .description = "test", + .severity = severity, + // `first_event` is comptime so `&.{first_event}` is a static array, not + // a dangling pointer into this frame. + .event_ids = &.{first_event}, + .suggested_steps = &.{}, + }; +} + +test "failures sort critical -> warning -> info, then by first event" { + const failures = [_]types.Failure{ + testFailure(.slow_response, .warning, "evt-0005"), + testFailure(.connector_fault, .critical, "evt-0009"), + testFailure(.heartbeat_interval_violation, .info, "evt-0002"), + testFailure(.failed_authorization, .warning, "evt-0003"), + testFailure(.station_offline_during_session, .critical, "evt-0001"), + }; + var arena_state = std.heap.ArenaAllocator.init(testing.allocator); + defer arena_state.deinit(); + + const order = sortedFailureIndices(arena_state.allocator(), &failures); + // critical (evt-0001, evt-0009), then warning (evt-0003, evt-0005), then info. + try testing.expectEqualSlices(usize, &.{ 4, 1, 3, 0, 2 }, order); +} + +test "failuresSummary breaks down nonzero severities" { + var arena_state = std.heap.ArenaAllocator.init(testing.allocator); + defer arena_state.deinit(); + const a = arena_state.allocator(); + + try testing.expectEqualStrings("no failures", failuresSummary(a, &.{})); + + const failures = [_]types.Failure{ + testFailure(.connector_fault, .critical, "evt-0001"), + testFailure(.slow_response, .warning, "evt-0002"), + testFailure(.failed_authorization, .warning, "evt-0003"), + }; + try testing.expectEqualStrings("3 failures: 1 critical, 2 warning", failuresSummary(a, &failures)); + + const one = [_]types.Failure{testFailure(.heartbeat_interval_violation, .info, "evt-0001")}; + try testing.expectEqualStrings("1 failure: 1 info", failuresSummary(a, &one)); +} diff --git a/src/ui/workspace.zig b/src/ui/workspace.zig index e02cd71..6dd985f 100644 --- a/src/ui/workspace.zig +++ b/src/ui/workspace.zig @@ -60,6 +60,9 @@ pub const LoadedTrace = struct { /// Reset whenever `selected_event` changes, so each event's tree opens /// fully expanded. payload_collapsed: PayloadCollapse = PayloadCollapse.initEmpty(), + /// The failure whose remediation steps are expanded in the failure panel + /// (an accordion — at most one open), as an index into `failures`. + expanded_failure: ?usize = null, pub fn isError(self: *const LoadedTrace) bool { return self.load_error != null; @@ -109,6 +112,9 @@ pub const Msg = union(enum) { /// Jump to session `payload` in the active trace by selecting its first /// event (drives the detail and session panels to that session). select_session: usize, + /// Toggle failure `payload`'s remediation steps in the failure panel and + /// jump to its primary event. + select_failure: usize, /// The timeline / detail splitter moved to fraction `payload`. timeline_resized: f32, }; @@ -195,9 +201,31 @@ pub const Model = struct { if (index >= t.sessions.len) return; const s = t.sessions[index]; if (s.events.len == 0) return; - const first_id = s.events[0].id; + self.selectEventById(s.events[0].id); + } + + /// Toggle failure `index`'s expansion in the failure panel (accordion: at + /// most one open) and jump to its primary event. A second activation of the + /// 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]; + if (index >= t.failures.len) return; + if (t.expanded_failure == index) { + t.expanded_failure = null; + return; + } + t.expanded_failure = index; + const f = t.failures[index]; + if (f.event_ids.len > 0) self.selectEventById(f.event_ids[0]); + } + + /// 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]; for (t.events, 0..) |e, i| { - if (std.mem.eql(u8, e.id, first_id)) { + if (std.mem.eql(u8, e.id, id)) { self.selectEvent(i); return; } @@ -248,6 +276,7 @@ pub fn update(model: *Model, msg: Msg) void { .select_event => |i| model.selectEvent(i), .toggle_payload_node => |id| model.togglePayloadNode(id), .select_session => |i| model.selectSession(i), + .select_failure => |i| model.selectFailure(i), .timeline_resized => |f| model.timeline_split = f, } } @@ -465,6 +494,35 @@ test "selecting a session jumps to its first event" { try testing.expectEqual(@as(?usize, selected), model.activeTrace().?.selected_event); } +test "selecting a failure expands it and jumps to its primary event" { + var model = Model{ .backing = testing.allocator }; + defer model.deinitAll(); + + // The failed-auth conformance fixture detects one FAILED_AUTHORIZATION. + model.openBytes("failed-auth.json", @embedFile("../ocpp/conformance/fixtures/failed-auth.json")); + const t = model.activeTrace().?; + try testing.expect(t.failureCount() > 0); + try testing.expect(t.failures[0].event_ids.len > 0); + try testing.expectEqual(@as(?usize, null), t.expanded_failure); + + update(&model, .{ .select_failure = 0 }); + const at = model.activeTrace().?; + try testing.expectEqual(@as(?usize, 0), at.expanded_failure); + // Jumped to (selected) the failure's primary event. + const primary_id = at.failures[0].event_ids[0]; + const sel = at.selected_event.?; + try testing.expectEqualStrings(primary_id, at.events[sel].id); + + // A second activation collapses the accordion; the selection stays put. + update(&model, .{ .select_failure = 0 }); + try testing.expectEqual(@as(?usize, null), model.activeTrace().?.expanded_failure); + try testing.expectEqual(@as(?usize, sel), model.activeTrace().?.selected_event); + + // Out-of-range failure index is ignored. + update(&model, .{ .select_failure = 999 }); + try testing.expectEqual(@as(?usize, sel), model.activeTrace().?.selected_event); +} + test "the splitter fraction is model-owned and echoed by update" { var model = Model{ .backing = testing.allocator }; defer model.deinitAll();