Skip to content

Commit 96278e0

Browse files
committed
finalize tracing rewrite
1 parent 653d675 commit 96278e0

5 files changed

Lines changed: 529 additions & 60 deletions

File tree

AGENTS.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
- You MUST use Red/Green TDD.
44
- BEFORE COMMITING: `cargo nextest run --stress-duration 20s`. The package has no flaky tests. If you find a flaky test, you created it.
5+
- **JS/HTML-only changes** (no `.rs` files touched, no trace format changes): you do NOT need to run the full Rust test suite or the stress test. Run the relevant JS tests under `dial9-viewer/ui/test_*.js` with `node <test>` and a quick `cargo build -p dial9-viewer` to confirm `rust-embed` picks up any new files. Skip `cargo nextest` / stress run.
56

67
## API Design
78

dial9-viewer/design/architecture.md

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,24 @@ The server exposes a REST API under `/api/` for browsing S3 prefixes, searching
1515

1616
Traces can also be loaded by drag-and-drop or file picker without a server (static file mode).
1717

18+
### Time-panel layout invariant
19+
20+
Every time-based panel in the viewer — timeline header, worker lanes, span panel, task detail, and queue chart — shares the same horizontal layout so its time axis lines up vertically with every other panel:
21+
22+
```
23+
┌──────────────┬──────────────────────────────┬────────┐
24+
│ label area │ draw area │ scroll │
25+
│ LABEL_W │ drawW = W - LABEL_W - sb │ sb │
26+
└──────────────┴──────────────────────────────┴────────┘
27+
x=0 x=LABEL_W x=W-sb x=W
28+
```
29+
30+
`LABEL_W = 100` is the canonical left-gutter width. The invariant is enforced by the shared helper in `ui/panel_layout.js` (`makeTimePanelLayout`), which produces the coordinate-conversion functions (`nsToPanelX`, `panelXToNs`) used by every panel. The browser-side wrapper `timePanelLayout(panel, scrollbarW)` in `viewer.html` adds DOM-reading and canvas-sizing on top.
31+
32+
Worker lanes are a slight exception: they use a DOM flex layout (`lane-label` div of width `LABEL_W`, then a `lane-content` div hosting the canvas) rather than a single canvas with an internal offset. The end result — time axis starts at x=LABEL_W — is identical; new panels should prefer the `timePanelLayout` pattern.
33+
34+
Regression history: the span panel was once built with `padding-left: 200px` instead of `100px`, shifting its time axis ~100px right of every other panel. `ui/test_panel_layout.js` now guards the invariant with unit tests.
35+
1836
## Agent skills (steering)
1937

2038
The viewer bundles markdown "skills" that teach AI agents how to use the toolkit. These are compiled into the binary at build time by `build.rs`:

dial9-viewer/ui/panel_layout.js

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
// panel_layout.js — shared time-panel layout math.
2+
//
3+
// Every time-based panel in the viewer (timeline, worker lanes, span panel,
4+
// task detail, queue chart) must agree on the horizontal layout so their time
5+
// axes line up vertically:
6+
//
7+
// ┌──────────────┬──────────────────────────────┬────────┐
8+
// │ label area │ draw area │ scroll │
9+
// │ LABEL_W │ drawW = W - LABEL_W - sb │ sb │
10+
// └──────────────┴──────────────────────────────┴────────┘
11+
// x=0 x=LABEL_W x=W-sb x=W
12+
//
13+
// The bug this module prevents: a panel redefines the left-gutter width
14+
// (e.g. `padding-left: 200px`) or forgets to add LABEL_W when drawing. Either
15+
// mistake shifts the panel's time axis relative to every other panel — bars
16+
// no longer line up with their corresponding polls, confusing users who are
17+
// trying to correlate events across panels.
18+
//
19+
// Keep this file pure (no DOM access) so it's trivially unit-testable. The
20+
// browser-side `timePanelLayout(panel, scrollbarW)` in viewer.html wraps this
21+
// with DOM-reading and canvas-sizing on top.
22+
23+
(function (exports) {
24+
"use strict";
25+
26+
/**
27+
* Build a time-panel layout view. Pure function — no globals, no DOM.
28+
*
29+
* @param {number} pw Full panel/canvas width in CSS px.
30+
* @param {number} labelW Left gutter width reserved for labels
31+
* (by convention, LABEL_W = 100).
32+
* @param {number} scrollbarW Right gutter for the lane scrollbar. Pass 0
33+
* for panels that don't need to match the lane
34+
* right edge.
35+
* @param {number} viewStart Visible-range start timestamp (ns).
36+
* @param {number} viewEnd Visible-range end timestamp (ns).
37+
* @returns {{
38+
* pw: number, labelW: number, drawW: number,
39+
* nsToPanelX: (ns: number) => number,
40+
* nsToPanelXClamped: (ns: number) => number,
41+
* panelXToNs: (x: number) => number,
42+
* }}
43+
*/
44+
function makeTimePanelLayout(pw, labelW, scrollbarW, viewStart, viewEnd) {
45+
const sb = scrollbarW || 0;
46+
const drawW = pw - labelW - sb;
47+
const span = (viewEnd - viewStart) || 1;
48+
return {
49+
pw,
50+
labelW,
51+
drawW,
52+
/** Convert a timestamp to a canvas x-coordinate (labelW-shifted). */
53+
nsToPanelX(ns) {
54+
return labelW + ((ns - viewStart) / span) * drawW;
55+
},
56+
/** Like nsToPanelX, but clamped to [labelW, labelW+drawW]. */
57+
nsToPanelXClamped(ns) {
58+
const raw = ((ns - viewStart) / span) * drawW;
59+
return labelW + Math.max(0, Math.min(drawW, raw));
60+
},
61+
/** Convert a canvas x-coordinate back to a timestamp. */
62+
panelXToNs(x) {
63+
return viewStart + ((x - labelW) / drawW) * span;
64+
},
65+
};
66+
}
67+
68+
exports.makeTimePanelLayout = makeTimePanelLayout;
69+
})(
70+
typeof module !== "undefined" && module.exports
71+
? module.exports
72+
: (typeof window !== "undefined"
73+
? (window.PanelLayout = window.PanelLayout || {})
74+
: (this.PanelLayout = this.PanelLayout || {})),
75+
);
Lines changed: 166 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,166 @@
1+
#!/usr/bin/env node
2+
// test_panel_layout.js — unit tests for the shared time-panel layout math.
3+
//
4+
// This test protects the invariant that every time-based panel in the viewer
5+
// (timeline, worker lanes, span panel, task detail, queue chart) uses the
6+
// same label/draw area layout so their time axes line up vertically. The
7+
// bug that prompted this test was a regression where the span panel used a
8+
// 200px left gutter while every other panel used 100px, shifting the span
9+
// time axis ~100px to the right of the task detail view.
10+
"use strict";
11+
12+
const assert = require("assert");
13+
const { makeTimePanelLayout } = require("./panel_layout.js");
14+
15+
// The canonical left-gutter width used by every time-based panel. If we ever
16+
// want to make it configurable, update viewer.html's LABEL_W constant and the
17+
// call sites at the same time — but the invariant is that *every* time-based
18+
// panel uses the same value.
19+
const LABEL_W = 100;
20+
21+
let passed = 0, failed = 0;
22+
function test(name, fn) {
23+
try {
24+
fn();
25+
console.log(`✓ ${name}`);
26+
passed++;
27+
} catch (e) {
28+
console.error(`✗ ${name}: ${e.message}`);
29+
failed++;
30+
}
31+
}
32+
33+
test("nsToPanelX at viewStart lands exactly at labelW", () => {
34+
const l = makeTimePanelLayout(1000, LABEL_W, 0, 500, 1500);
35+
assert.strictEqual(l.nsToPanelX(500), LABEL_W);
36+
});
37+
38+
test("nsToPanelX at viewEnd lands at labelW+drawW", () => {
39+
const l = makeTimePanelLayout(1000, LABEL_W, 0, 500, 1500);
40+
assert.strictEqual(l.nsToPanelX(1500), LABEL_W + l.drawW);
41+
});
42+
43+
test("nsToPanelX is linear in the middle of the view", () => {
44+
const l = makeTimePanelLayout(1000, LABEL_W, 0, 0, 1000);
45+
// Midpoint of the view should land at labelW + drawW/2.
46+
assert.strictEqual(l.nsToPanelX(500), LABEL_W + l.drawW / 2);
47+
});
48+
49+
test("nsToPanelXClamped clamps values outside the view", () => {
50+
const l = makeTimePanelLayout(1000, LABEL_W, 0, 500, 1500);
51+
assert.strictEqual(l.nsToPanelXClamped(100), LABEL_W); // before
52+
assert.strictEqual(l.nsToPanelXClamped(2000), LABEL_W + l.drawW); // after
53+
// Values inside the view match nsToPanelX.
54+
assert.strictEqual(l.nsToPanelXClamped(1000), l.nsToPanelX(1000));
55+
});
56+
57+
test("panelXToNs round-trips with nsToPanelX", () => {
58+
const l = makeTimePanelLayout(1234, LABEL_W, 17, 1_000_000, 2_000_000);
59+
for (const ns of [1_000_000, 1_250_000, 1_500_000, 1_999_999]) {
60+
const x = l.nsToPanelX(ns);
61+
const back = l.panelXToNs(x);
62+
assert.ok(Math.abs(back - ns) < 1e-6, `round-trip ${ns} -> ${x} -> ${back}`);
63+
}
64+
});
65+
66+
test("scrollbarW shrinks drawW but does not move labelW", () => {
67+
const noSb = makeTimePanelLayout(1000, LABEL_W, 0, 0, 1);
68+
const withSb = makeTimePanelLayout(1000, LABEL_W, 17, 0, 1);
69+
assert.strictEqual(noSb.labelW, withSb.labelW);
70+
assert.strictEqual(withSb.drawW, noSb.drawW - 17);
71+
// viewStart still maps to x=LABEL_W in both.
72+
assert.strictEqual(noSb.nsToPanelX(0), LABEL_W);
73+
assert.strictEqual(withSb.nsToPanelX(0), LABEL_W);
74+
});
75+
76+
test("INVARIANT: all panels with the same pw share the same time axis", () => {
77+
// Simulate the four time-based panels in the viewer. Each uses its own
78+
// scrollbarW (some have the lane scrollbar gutter, some don't), but they
79+
// all must map the same timestamp to the same x-coordinate within the
80+
// [LABEL_W, LABEL_W+min(drawW)] overlap — i.e. the start of the draw area
81+
// is identical (x=LABEL_W) for every panel.
82+
const pw = 1200;
83+
const vStart = 1_000_000, vEnd = 5_000_000;
84+
85+
const timeline = makeTimePanelLayout(pw, LABEL_W, 17, vStart, vEnd);
86+
const spanPanel = makeTimePanelLayout(pw, LABEL_W, 17, vStart, vEnd);
87+
const taskDetail = makeTimePanelLayout(pw, LABEL_W, 17, vStart, vEnd);
88+
const queueChart = makeTimePanelLayout(pw, LABEL_W, 17, vStart, vEnd);
89+
90+
for (const ts of [vStart, vStart + 500_000, vStart + 2_500_000, vEnd - 1]) {
91+
const x = timeline.nsToPanelX(ts);
92+
assert.strictEqual(spanPanel.nsToPanelX(ts), x, `span panel diverges at ${ts}`);
93+
assert.strictEqual(taskDetail.nsToPanelX(ts), x, `task detail diverges at ${ts}`);
94+
assert.strictEqual(queueChart.nsToPanelX(ts), x, `queue chart diverges at ${ts}`);
95+
}
96+
// The start of the draw area is LABEL_W everywhere, not a panel-specific
97+
// value. (This is the assertion that would have failed for the span
98+
// panel when its left gutter was 200px.)
99+
assert.strictEqual(timeline.labelW, LABEL_W);
100+
assert.strictEqual(spanPanel.labelW, LABEL_W);
101+
assert.strictEqual(taskDetail.labelW, LABEL_W);
102+
assert.strictEqual(queueChart.labelW, LABEL_W);
103+
});
104+
105+
test("REGRESSION: hypothetical 200px-gutter panel would NOT align", () => {
106+
// This is a documentation test: if someone re-breaks the invariant by
107+
// building a panel with a non-LABEL_W gutter, the test ensures we notice.
108+
const pw = 1200;
109+
const vStart = 0, vEnd = 1_000_000;
110+
const good = makeTimePanelLayout(pw, 100, 0, vStart, vEnd);
111+
const bad = makeTimePanelLayout(pw, 200, 0, vStart, vEnd);
112+
// They disagree on where viewStart lands by exactly the gutter delta.
113+
assert.strictEqual(bad.nsToPanelX(0) - good.nsToPanelX(0), 100);
114+
});
115+
116+
test("Zero-width view (viewStart == viewEnd) does not divide by zero", () => {
117+
const l = makeTimePanelLayout(1000, LABEL_W, 0, 500, 500);
118+
assert.ok(Number.isFinite(l.nsToPanelX(500)));
119+
assert.ok(Number.isFinite(l.nsToPanelXClamped(500)));
120+
});
121+
122+
test("Tiny panel width produces negative drawW but does not throw", () => {
123+
// Caller is expected to early-return on drawW <= 0; just verify no NaN.
124+
const l = makeTimePanelLayout(50, LABEL_W, 17, 0, 1000);
125+
assert.ok(l.drawW < 0);
126+
assert.ok(Number.isFinite(l.nsToPanelX(500)));
127+
});
128+
129+
test("INVARIANT: no time-based panel declares its own left gutter in CSS", () => {
130+
// Cheap but effective guard: grep viewer.html for any time-based panel
131+
// defining a `padding-left` value. The whole point of the invariant is
132+
// that the canvas fills the panel and `timePanelLayout` handles the
133+
// LABEL_W offset. If a panel redefines `padding-left`, its time axis
134+
// will silently drift relative to every other panel.
135+
const fs = require("fs");
136+
const path = require("path");
137+
const html = fs.readFileSync(
138+
path.join(__dirname, "viewer.html"),
139+
"utf8",
140+
);
141+
142+
const PANELS = [
143+
"#timeline-header",
144+
"#span-panel",
145+
"#task-detail",
146+
"#queue-chart",
147+
];
148+
for (const panel of PANELS) {
149+
// Match: `<selector> { ... padding-left: ... }` across lines.
150+
const ruleRe = new RegExp(
151+
`${panel.replace(/[.*+?^${}()|[\\]\\\\]/g, "\\$&")}\\s*\\{([^}]*)\\}`,
152+
"g",
153+
);
154+
let m;
155+
while ((m = ruleRe.exec(html)) !== null) {
156+
const body = m[1];
157+
assert.ok(
158+
!/padding-left\s*:/.test(body),
159+
`${panel} must not declare padding-left (would break the time-axis alignment invariant). Use timePanelLayout() instead.`,
160+
);
161+
}
162+
}
163+
});
164+
165+
console.log(`\n${passed} passed, ${failed} failed`);
166+
process.exit(failed > 0 ? 1 : 0);

0 commit comments

Comments
 (0)