Skip to content

Commit d151fdb

Browse files
feat(ui): failure panel (#39)
A fixed-height drawer under the timeline surfaces the engine's detected failures for the active trace: - Ranked critical -> warning -> info, then by first affected event, over a display-order index array (the engine's failure slice is never mutated). - Each row shows severity, code, and description; selecting one expands its remediation steps and affected events (accordion, at most one open) and jumps to its primary event, so a failure and its evidence line up. - A clean trace shows a positive "no failures detected" state; a detection-skipped large trace says so instead. - The status bar now carries the severity breakdown. split is horizontal-only, so the drawer is a fixed-height child of a vertical column beneath the timeline|detail split. Jump selects and highlights the event; the literal viewport scroll rides the runner eject (#33), like the session jump in #30. Tests: the ordering and severity-summary helpers (pure), and over the failed-auth conformance fixture the failure renders, expanding reveals its steps, and clicking jumps to the event; the a11y sweep now covers the drawer. Closes #31
1 parent 1c6373b commit d151fdb

4 files changed

Lines changed: 342 additions & 17 deletions

File tree

‎CURRENT_STATE.md‎

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -97,8 +97,14 @@ toolkit's:
9797
input), and the raw OCPP-J array pretty-printed. Jump selects the session's
9898
first event (highlighting it and driving the panels); the literal timeline
9999
viewport scroll rides the same runtime-eject as #33.
100-
101-
Still ahead in S3: the failure panel (#31) and search / filter (#32).
100+
- **Failure panel (#31)** — a fixed-height drawer under the timeline lists every
101+
detected failure, ranked critical → warning → info (then by first event), each
102+
with its severity, code, and description. Selecting one expands its remediation
103+
steps and affected events (accordion) and jumps to its primary event, so a
104+
failure and its evidence line up. A clean trace shows a positive
105+
"no failures detected" state; the status bar carries the severity breakdown.
106+
107+
Still ahead in S3: search / filter (#32), which closes the milestone.
102108
Interactive open (native dialog + drag-drop) is deferred to #33 — it needs an
103109
ejected runner (see ADR-0006).
104110

@@ -118,7 +124,7 @@ wall-clock replay, and a headless CLI mode.
118124
| `repo` (tooling, CI) | ✅ done for S0 |
119125
| `docs` (docs, ADRs) | ✅ done for S0 |
120126
| `ocpp` (engine) | ✅ S2 + trusted ingestion (#29); O(n) detection pending (#36) |
121-
| `ui` (native views) | 🚧 shell + timeline + message inspector (S3, #27–#28, #30); failure panel + search next |
127+
| `ui` (native views) | 🚧 shell + timeline + inspector + failure panel (S3, #27–#28, #30–#31); search next |
122128
| `capture` (live proxy) | ⬜ not started (S5) |
123129
| `cli` (headless) | ⬜ not started (S4) |
124130
| `conformance` | ✅ done for S2 (15/15, `contract-v1`) |

‎src/tests.zig‎

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -269,6 +269,45 @@ test "toggling a payload container collapses its children" {
269269
try testing.expect(findByText(tree.root, .text, "timestamp") == null);
270270
}
271271

272+
test "the failure panel shows the clean-trace positive state" {
273+
var arena_state = std.heap.ArenaAllocator.init(testing.allocator);
274+
defer arena_state.deinit();
275+
276+
var model = Model{ .backing = testing.allocator };
277+
defer model.deinitAll();
278+
workspace.update(&model, .open_sample); // the sample is a clean session
279+
280+
const tree = try buildTree(arena_state.allocator(), &model);
281+
_ = try expectByText(tree.root, .text, "Failures"); // the drawer header
282+
_ = try expectByText(tree.root, .text, "No failures detected");
283+
}
284+
285+
test "the failure panel lists failures, expands steps, and jumps to the event" {
286+
var arena_state = std.heap.ArenaAllocator.init(testing.allocator);
287+
defer arena_state.deinit();
288+
const arena = arena_state.allocator();
289+
290+
var model = Model{ .backing = testing.allocator };
291+
defer model.deinitAll();
292+
// The failed-auth conformance fixture detects one FAILED_AUTHORIZATION.
293+
model.openBytes("failed-auth.json", @embedFile("ocpp/conformance/fixtures/failed-auth.json"));
294+
295+
var tree = try buildTree(arena, &model);
296+
_ = try expectByText(tree.root, .text, "FAILED_AUTHORIZATION");
297+
// Collapsed: remediation is hidden until the row is opened.
298+
try testing.expect(findByText(tree.root, .text, "Suggested steps") == null);
299+
300+
// Clicking the failure row expands it and jumps to its primary event.
301+
const row = findRowWithText(tree.root, "FAILED_AUTHORIZATION") orelse return error.WidgetNotFound;
302+
main.update(&model, tree.msgForPointer(row.id, .up).?);
303+
try testing.expectEqual(@as(?usize, 0), model.activeTrace().?.expanded_failure);
304+
try testing.expect(model.activeTrace().?.selected_event != null);
305+
306+
tree = try buildTree(arena, &model);
307+
_ = try expectByText(tree.root, .text, "Suggested steps");
308+
_ = try expectByText(tree.root, .text, "Affected");
309+
}
310+
272311
test "the virtual window stays viewport-sized at dataset scale" {
273312
var arena_state = std.heap.ArenaAllocator.init(testing.allocator);
274313
defer arena_state.deinit();
@@ -344,6 +383,14 @@ test "the inspector view passes the accessibility sweep (empty and loaded)" {
344383
workspace.update(&model, .{ .select_event = 5 }); // detail pane: tree, session, raw
345384
tree = try buildTree(arena, &model);
346385
try canvas.expectA11yAuditSweepClean(testing.allocator, tree.root, sweep);
386+
387+
// A trace with an expanded failure exercises the failure drawer too.
388+
var failing = Model{ .backing = testing.allocator };
389+
defer failing.deinitAll();
390+
failing.openBytes("failed-auth.json", @embedFile("ocpp/conformance/fixtures/failed-auth.json"));
391+
workspace.update(&failing, .{ .select_failure = 0 });
392+
tree = try buildTree(arena, &failing);
393+
try canvas.expectA11yAuditSweepClean(testing.allocator, tree.root, sweep);
347394
}
348395

349396
test "the inspector view lays out through the canvas engine" {

‎src/ui/inspector.zig‎

Lines changed: 226 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -81,15 +81,20 @@ fn tabStrip(ui: *Ui, model: *const Model) Node {
8181
fn activeBody(ui: *Ui, model: *const Model) Node {
8282
const t = model.activeTrace().?;
8383
if (t.isError()) return errorPanel(ui, t);
84-
// Timeline (left) / detail (right), a model-owned splitter that echoes each
85-
// drag back through `timeline_split`.
86-
return ui.split(.{
87-
.grow = 1,
88-
.value = model.timeline_split,
89-
.on_resize = Ui.valueMsg(.timeline_resized),
90-
}, .{
91-
timelinePane(ui, t),
92-
detailPane(ui, t),
84+
// Timeline (left) / detail (right) fill the space above a fixed-height
85+
// failure drawer. `split` is horizontal-only, so the vertical stack is a
86+
// column: the split grows, the drawer keeps its height.
87+
return ui.column(.{ .grow = 1 }, .{
88+
ui.split(.{
89+
.grow = 1,
90+
.value = model.timeline_split,
91+
.on_resize = Ui.valueMsg(.timeline_resized),
92+
}, .{
93+
timelinePane(ui, t),
94+
detailPane(ui, t),
95+
}),
96+
ui.separator(.{}),
97+
failuresPanel(ui, t),
9398
});
9499
}
95100

@@ -675,19 +680,228 @@ fn append(a: std.mem.Allocator, buf: *std.ArrayList(u8), s: []const u8) void {
675680
buf.appendSlice(a, s) catch {};
676681
}
677682

683+
// --- failure panel (#31) ---------------------------------------------------
684+
//
685+
// Every detected failure for the active trace, ranked critical -> warning ->
686+
// info (then by first event), in a fixed-height drawer beneath the timeline.
687+
// Selecting a failure expands its remediation steps (accordion, at most one
688+
// open) and jumps to its primary event so the failure and its evidence align.
689+
690+
const failures_panel_height: f32 = 240;
691+
const max_failures_shown: usize = 50;
692+
693+
fn failuresPanel(ui: *Ui, t: *const LoadedTrace) Node {
694+
return ui.column(.{ .height = failures_panel_height }, .{
695+
failuresHeader(ui, t),
696+
ui.separator(.{}),
697+
failuresBody(ui, t),
698+
});
699+
}
700+
701+
fn failuresHeader(ui: *Ui, t: *const LoadedTrace) Node {
702+
return ui.row(.{ .padding = 8, .gap = 8, .cross = .center }, .{
703+
ui.text(.{}, "Failures"),
704+
ui.spacer(1),
705+
ui.text(.{ .style_tokens = .{ .foreground = .text_muted } }, failuresSummary(ui.arena, t.failures)),
706+
});
707+
}
708+
709+
fn failuresBody(ui: *Ui, t: *const LoadedTrace) Node {
710+
if (t.detection_skipped)
711+
return centeredNote(ui, "Failure detection was skipped for this large trace");
712+
if (t.failures.len == 0)
713+
return centeredNote(ui, "No failures detected");
714+
715+
const order = sortedFailureIndices(ui.arena, t.failures);
716+
const shown = @min(order.len, max_failures_shown);
717+
const extra: usize = if (order.len > shown) 1 else 0;
718+
const rows = ui.arena.alloc(Node, shown + extra) catch {
719+
ui.failed = true;
720+
return centeredNote(ui, "");
721+
};
722+
for (0..shown) |k| rows[k] = failureRow(ui, t, order[k]);
723+
if (extra == 1) {
724+
const label = std.fmt.allocPrint(ui.arena, "... {d} more", .{order.len - shown}) catch "... more";
725+
rows[shown] = moreRow(ui, label, 0);
726+
}
727+
return ui.scroll(.{ .grow = 1 }, .{
728+
ui.column(.{ .gap = 4, .padding = 8 }, rows),
729+
});
730+
}
731+
732+
fn centeredNote(ui: *Ui, text: []const u8) Node {
733+
return ui.column(.{ .grow = 1, .main = .center, .cross = .center, .padding = 16 }, .{
734+
ui.text(.{ .style_tokens = .{ .foreground = .text_muted } }, text),
735+
});
736+
}
737+
738+
fn failureRow(ui: *Ui, t: *const LoadedTrace, i: usize) Node {
739+
const f = t.failures[i];
740+
const expanded = (t.expanded_failure == i);
741+
const header = ui.row(.{
742+
.on_press = .{ .select_failure = i },
743+
.selected = expanded,
744+
.padding = 6,
745+
.gap = 8,
746+
.cross = .center,
747+
.semantics = .{ .label = f.code.toWire() },
748+
}, .{
749+
severityDot(ui, f.severity),
750+
ui.text(.{ .style_tokens = .{ .foreground = severityColor(f.severity) } }, f.code.toWire()),
751+
ui.text(.{ .grow = 1 }, f.description),
752+
disclosureGlyph(ui, true, expanded),
753+
});
754+
if (!expanded) return header;
755+
return ui.column(.{ .gap = 4 }, .{
756+
header,
757+
failureDetail(ui, f),
758+
});
759+
}
760+
761+
fn failureDetail(ui: *Ui, f: types.Failure) Node {
762+
var items: std.ArrayList(Node) = .empty;
763+
items.append(ui.arena, detailRow(ui, "Affected", joinEventIds(ui.arena, f.event_ids))) catch {
764+
ui.failed = true;
765+
};
766+
if (f.suggested_steps.len > 0) {
767+
items.append(ui.arena, ui.text(.{ .style_tokens = .{ .foreground = .text_muted } }, "Suggested steps")) catch {};
768+
for (f.suggested_steps) |step| {
769+
items.append(ui.arena, stepRow(ui, step)) catch {};
770+
}
771+
}
772+
return ui.column(.{ .gap = 4, .padding = 8 }, items.items);
773+
}
774+
775+
fn stepRow(ui: *Ui, step: []const u8) Node {
776+
return ui.row(.{ .gap = 6, .cross = .start }, .{
777+
ui.text(.{ .width = 16, .style_tokens = .{ .foreground = .text_muted } }, "\u{00B7}"),
778+
ui.text(.{ .grow = 1 }, step),
779+
});
780+
}
781+
782+
/// Affected event ids as a compact comma-joined list, capped so a many-event
783+
/// failure stays readable.
784+
fn joinEventIds(arena: std.mem.Allocator, ids: []const []const u8) []const u8 {
785+
if (ids.len == 0) return "none";
786+
const cap: usize = 6;
787+
const shown = @min(ids.len, cap);
788+
var buf: std.ArrayList(u8) = .empty;
789+
for (ids[0..shown], 0..) |id, i| {
790+
if (i > 0) buf.appendSlice(arena, ", ") catch {};
791+
buf.appendSlice(arena, id) catch {};
792+
}
793+
if (ids.len > shown) {
794+
buf.appendSlice(arena, std.fmt.allocPrint(arena, ", +{d} more", .{ids.len - shown}) catch "") catch {};
795+
}
796+
return buf.items;
797+
}
798+
799+
/// Display order for the failure list: severity (critical -> warning -> info),
800+
/// then first affected event id. Returns indices into `failures`; the engine's
801+
/// slice is never mutated.
802+
fn sortedFailureIndices(arena: std.mem.Allocator, failures: []const types.Failure) []usize {
803+
const idx = arena.alloc(usize, failures.len) catch return &.{};
804+
for (idx, 0..) |*v, i| v.* = i;
805+
std.mem.sort(usize, idx, failures, lessFailure);
806+
return idx;
807+
}
808+
809+
fn lessFailure(failures: []const types.Failure, a: usize, b: usize) bool {
810+
const fa = failures[a];
811+
const fb = failures[b];
812+
const ra = severityRank(fa.severity);
813+
const rb = severityRank(fb.severity);
814+
if (ra != rb) return ra < rb;
815+
const ea = if (fa.event_ids.len > 0) fa.event_ids[0] else "";
816+
const eb = if (fb.event_ids.len > 0) fb.event_ids[0] else "";
817+
return std.mem.order(u8, ea, eb) == .lt;
818+
}
819+
820+
/// "N failures: C critical, W warning, I info" (nonzero severities only), or
821+
/// "no failures". Shared by the failure-panel header and the status bar.
822+
fn failuresSummary(arena: std.mem.Allocator, failures: []const types.Failure) []const u8 {
823+
if (failures.len == 0) return "no failures";
824+
var counts = [_]usize{ 0, 0, 0 }; // critical, warning, info
825+
for (failures) |f| counts[severityRank(f.severity)] += 1;
826+
827+
var buf: std.ArrayList(u8) = .empty;
828+
buf.appendSlice(arena, std.fmt.allocPrint(arena, "{d} failure{s}", .{ failures.len, if (failures.len == 1) "" else "s" }) catch "") catch {};
829+
var first = true;
830+
for ([_][]const u8{ "critical", "warning", "info" }, 0..) |word, rank| {
831+
if (counts[rank] > 0) {
832+
const sep = if (first) ": " else ", ";
833+
buf.appendSlice(arena, std.fmt.allocPrint(arena, "{s}{d} {s}", .{ sep, counts[rank], word }) catch "") catch {};
834+
first = false;
835+
}
836+
}
837+
return buf.items;
838+
}
839+
678840
// --- status bar ------------------------------------------------------------
679841

680842
fn statusBar(ui: *Ui, model: *const Model) Node {
681843
const t = model.activeTrace().?;
682844
const text = if (t.isError())
683845
std.fmt.allocPrint(ui.arena, "Failed to load {s}: {s}", .{ t.name, t.load_error orelse "unknown error" }) catch "load failed"
684846
else if (t.detection_skipped)
685-
std.fmt.allocPrint(ui.arena, "{d} events \u{00B7} {d} sessions \u{00B7} detection skipped (large trace) \u{00B7} {d} warnings", .{
847+
std.fmt.allocPrint(ui.arena, "{d} events \u{00B7} {d} sessions \u{00B7} detection skipped (large trace) \u{00B7} {d} parse warnings", .{
686848
t.eventCount(), t.sessionCount(), t.warningCount(),
687849
}) catch ""
688850
else
689-
std.fmt.allocPrint(ui.arena, "{d} events \u{00B7} {d} sessions \u{00B7} {d} failures \u{00B7} {d} warnings", .{
690-
t.eventCount(), t.sessionCount(), t.failureCount(), t.warningCount(),
851+
std.fmt.allocPrint(ui.arena, "{d} events \u{00B7} {d} sessions \u{00B7} {s} \u{00B7} {d} parse warnings", .{
852+
t.eventCount(), t.sessionCount(), failuresSummary(ui.arena, t.failures), t.warningCount(),
691853
}) catch "";
692854
return ui.statusBar(.{}, text);
693855
}
856+
857+
// ---------------------------------------------------------------------------
858+
// Tests — pure view helpers (widget-level behavior is covered in tests.zig)
859+
// ---------------------------------------------------------------------------
860+
861+
const testing = std.testing;
862+
863+
fn testFailure(code: types.FailureCode, severity: types.FailureSeverity, comptime first_event: []const u8) types.Failure {
864+
return .{
865+
.code = code,
866+
.description = "test",
867+
.severity = severity,
868+
// `first_event` is comptime so `&.{first_event}` is a static array, not
869+
// a dangling pointer into this frame.
870+
.event_ids = &.{first_event},
871+
.suggested_steps = &.{},
872+
};
873+
}
874+
875+
test "failures sort critical -> warning -> info, then by first event" {
876+
const failures = [_]types.Failure{
877+
testFailure(.slow_response, .warning, "evt-0005"),
878+
testFailure(.connector_fault, .critical, "evt-0009"),
879+
testFailure(.heartbeat_interval_violation, .info, "evt-0002"),
880+
testFailure(.failed_authorization, .warning, "evt-0003"),
881+
testFailure(.station_offline_during_session, .critical, "evt-0001"),
882+
};
883+
var arena_state = std.heap.ArenaAllocator.init(testing.allocator);
884+
defer arena_state.deinit();
885+
886+
const order = sortedFailureIndices(arena_state.allocator(), &failures);
887+
// critical (evt-0001, evt-0009), then warning (evt-0003, evt-0005), then info.
888+
try testing.expectEqualSlices(usize, &.{ 4, 1, 3, 0, 2 }, order);
889+
}
890+
891+
test "failuresSummary breaks down nonzero severities" {
892+
var arena_state = std.heap.ArenaAllocator.init(testing.allocator);
893+
defer arena_state.deinit();
894+
const a = arena_state.allocator();
895+
896+
try testing.expectEqualStrings("no failures", failuresSummary(a, &.{}));
897+
898+
const failures = [_]types.Failure{
899+
testFailure(.connector_fault, .critical, "evt-0001"),
900+
testFailure(.slow_response, .warning, "evt-0002"),
901+
testFailure(.failed_authorization, .warning, "evt-0003"),
902+
};
903+
try testing.expectEqualStrings("3 failures: 1 critical, 2 warning", failuresSummary(a, &failures));
904+
905+
const one = [_]types.Failure{testFailure(.heartbeat_interval_violation, .info, "evt-0001")};
906+
try testing.expectEqualStrings("1 failure: 1 info", failuresSummary(a, &one));
907+
}

0 commit comments

Comments
 (0)