Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 16 additions & 13 deletions CURRENT_STATE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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`) |
1 change: 1 addition & 0 deletions scripts/smoke.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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'

Expand Down
62 changes: 58 additions & 4 deletions src/tests.zig
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand All @@ -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();
Expand All @@ -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();
Expand Down
216 changes: 200 additions & 16 deletions src/ui/inspector.zig
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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 }, .{
Expand Down Expand Up @@ -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 {
Expand All @@ -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),
});
}

Expand Down
Loading
Loading