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
2 changes: 1 addition & 1 deletion docs/user-guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -125,7 +125,7 @@ The top-right dock in the topology viewport contains:

- **Layout toggle**: left-to-right vs top-to-bottom
- **Theme toggle**: light vs dark
- **Global settings**: snapshot poll frequency, theme, default layout, edge connector type, default trace metrics, inspector width, legend/minimap visibility, and auto-fit/auto-focus behaviors
- **Global settings**: snapshot poll frequency, theme, default layout, edge connector type, default trace metrics, inspector width, legend/minimap visibility, auto-fit/auto-focus behaviors, and whether the Publishers pane shows settings channels

## Good Troubleshooting Patterns

Expand Down
25 changes: 15 additions & 10 deletions frontend/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -139,16 +139,21 @@ const DEFAULT_GLOBAL_SETTINGS: GlobalSettings = {
};
const CANONICAL_GRAPH_ADDRESS = "127.0.0.1:25978";
const INSPECTOR_WIDTH_MIN_PX = 360;
const INSPECTOR_WIDTH_MAX_PX = 900;
// The topology is the point of the page, so it -- not an arbitrary ceiling --
// is what bounds how wide the inspector may get. Mirrored in the grid template,
// which keeps a stale stored width from squeezing the graph after a resize.
const TOPOLOGY_MIN_WIDTH_PX = 480;
/** How far one arrow key press moves the inspector divider. */
const INSPECTOR_WIDTH_KEY_STEP_PX = 16;
type HealthTone = "ok" | "warn" | "err";

function maxInspectorWidth(): number {
const viewportWidth = typeof window === "undefined" ? Number.POSITIVE_INFINITY : window.innerWidth;
return Math.max(INSPECTOR_WIDTH_MIN_PX, viewportWidth - TOPOLOGY_MIN_WIDTH_PX);
}

function clampInspectorWidth(value: number): number {
return Math.min(
INSPECTOR_WIDTH_MAX_PX,
Math.max(INSPECTOR_WIDTH_MIN_PX, Math.round(value))
);
return Math.min(maxInspectorWidth(), Math.max(INSPECTOR_WIDTH_MIN_PX, Math.round(value)));
}

function toEpochMillis(timestamp: number): number | null {
Expand Down Expand Up @@ -491,7 +496,7 @@ export function App() {
if (event.key === "Home" || event.key === "End") {
event.preventDefault();
commitInspectorWidth(
event.key === "Home" ? INSPECTOR_WIDTH_MAX_PX : INSPECTOR_WIDTH_MIN_PX
event.key === "Home" ? maxInspectorWidth() : INSPECTOR_WIDTH_MIN_PX
);
}
};
Expand Down Expand Up @@ -544,7 +549,7 @@ export function App() {
aria-orientation="vertical"
aria-label="Resize inspector"
aria-valuemin={INSPECTOR_WIDTH_MIN_PX}
aria-valuemax={INSPECTOR_WIDTH_MAX_PX}
aria-valuemax={maxInspectorWidth()}
aria-valuenow={inspectorWidthPx}
tabIndex={0}
onPointerDown={handleInspectorResizeStart}
Expand Down Expand Up @@ -905,8 +910,8 @@ export function App() {
<span>Inspector Width (px)</span>
<input
type="number"
min={360}
max={900}
min={INSPECTOR_WIDTH_MIN_PX}
max={maxInspectorWidth()}
step={10}
value={globalSettings.inspectorWidthPx}
onChange={(event) => {
Expand All @@ -916,7 +921,7 @@ export function App() {
}
setGlobalSettings((previous) => ({
...previous,
inspectorWidthPx: Math.max(360, Math.min(900, next)),
inspectorWidthPx: clampInspectorWidth(next),
}));
}}
/>
Expand Down
8 changes: 6 additions & 2 deletions frontend/src/components/ProfilingPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import {
parseTopicAndEndpoint,
streamAddressWithoutEndpoint,
} from "../utils/streamAddress";
import { metricNumber } from "../utils/nonFiniteNumbers";
import { isSettingsChannelTopic } from "../utils/settingsChannel";
import type {
GraphSnapshotPayload,
Expand Down Expand Up @@ -111,7 +112,7 @@ const TRACE_PUBLISHER_METRICS = new Set(["publish_delta_ns"]);
const TRACE_SUBSCRIBER_METRICS = new Set(["lease_time_ns", "user_span_ns"]);

function toNumber(value: unknown): number {
return typeof value === "number" && Number.isFinite(value) ? value : 0;
return metricNumber(value);
}

function formatRate(hz: number): string {
Expand Down Expand Up @@ -611,7 +612,9 @@ export function ProfilingPanel({
}
lastHandledFocusActionIdRef.current = focusActionId;
setSearchText("");
setExpandedIds(matchedIds);
// A row that is capturing stays expanded: collapsing it would hide the
// stop control while the capture kept running.
setExpandedIds([...new Set([...matchedIds, ...activeTraceRowIds])]);
setExpandedContributorEndpointByRowId((previous) => {
if (contributorEndpointId === null) {
return {};
Expand Down Expand Up @@ -677,6 +680,7 @@ export function ProfilingPanel({
focusUnitAddress,
focusActionId,
publisherRows,
activeTraceRowIds,
]);

useEffect(() => {
Expand Down
6 changes: 5 additions & 1 deletion frontend/src/styles.css
Original file line number Diff line number Diff line change
Expand Up @@ -219,7 +219,11 @@ body {

.dashboard-layout {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(360px, var(--inspector-width, 500px));
/* The topology keeps at least 480px: a stored inspector width from a wider
window must not squeeze it away after a resize. */
grid-template-columns:
minmax(0, 1fr)
minmax(360px, min(var(--inspector-width, 500px), calc(100vw - 480px)));
grid-template-rows: minmax(0, 1fr);
width: 100vw;
height: 100vh;
Expand Down
24 changes: 24 additions & 0 deletions frontend/src/utils/nonFiniteNumbers.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {
encodeNumericValue,
formatNumericValue,
isNonFiniteToken,
metricNumber,
parseNumericInput,
} from "./nonFiniteNumbers";

Expand Down Expand Up @@ -75,3 +76,26 @@ describe("non-finite number tokens", () => {
expect(parseNumericInput("alpha")).toBeNull();
});
});

describe("snapshot metrics", () => {
it("keeps non-finite metrics distinguishable from zero", () => {
expect(metricNumber(12.5)).toBe(12.5);
expect(metricNumber(0)).toBe(0);
expect(metricNumber("Infinity")).toBe(Number.POSITIVE_INFINITY);
expect(metricNumber("-Infinity")).toBe(Number.NEGATIVE_INFINITY);
expect(metricNumber("NaN")).toBeNaN();
});

it("falls back to zero for values that are not numbers at all", () => {
expect(metricNumber("alpha")).toBe(0);
expect(metricNumber(null)).toBe(0);
expect(metricNumber(undefined)).toBe(0);
expect(metricNumber({})).toBe(0);
});

it("formats a non-finite rate readably", () => {
// What ProfilingPanel's formatRate does with the decoded value.
expect(`${metricNumber("Infinity").toFixed(1)} Hz`).toBe("Infinity Hz");
expect(`${metricNumber("NaN").toFixed(1)} Hz`).toBe("NaN Hz");
});
});
11 changes: 11 additions & 0 deletions frontend/src/utils/nonFiniteNumbers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,3 +69,14 @@ export function parseNumericInput(raw: string): number | null {
const numeric = Number(trimmed);
return Number.isNaN(numeric) ? null : numeric;
}

/**
* Read a snapshot metric that may have arrived as a token.
*
* Non-finite values stay non-finite rather than flattening to zero: a rate of
* `NaN` shown as `0.0 Hz` is indistinguishable from an idle publisher, which is
* exactly the confusion that makes an empty profiling trace look broken.
*/
export function metricNumber(value: unknown): number {
return decodeNumericValue(value) ?? 0;
}
47 changes: 45 additions & 2 deletions frontend/tests/e2e/dashboard.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -604,15 +604,16 @@ test("the divider clamps the inspector width and resets on double click", async
const handleCenterY = handleBox.y + handleBox.height / 2;
const handleCenterX = handleBox.x + handleBox.width / 2;

// Drag far past the maximum; the width stops at the clamp.
// Drag far past the limit; the width stops where the topology's 480px
// minimum begins, rather than at a fixed ceiling.
await page.mouse.move(handleCenterX, handleCenterY);
await page.mouse.down();
await page.mouse.move(handleCenterX - 900, handleCenterY, { steps: 8 });
await page.mouse.up();

await expect
.poll(async () => Math.round((await inspector.boundingBox())?.width ?? 0))
.toBe(900);
.toBe(1400 - 480);

await handle.dblclick();
await expect
Expand Down Expand Up @@ -873,3 +874,45 @@ test("settings channels can be shown again from global settings", async ({ page
page.locator('.publisher-topic[title="SYSTEM/PING/INPUT_SETTINGS"]')
).toBeVisible();
});

test("a capturing publisher row stays expanded when a unit is selected", async ({
page,
}) => {
await page.goto("/?fixture=profiling-trace-rates");

const traceRow = page.locator(".publisher-row", {
has: page.locator('.publisher-topic[title="TRACE_LAB/DENSE_TOPIC"]'),
});
await traceRow.locator(".publisher-row__toggle").click();
await traceRow.locator(".publisher-trace-button").click();
await expect(traceRow.locator(".publisher-trace-button.is-stop")).toBeVisible();

// Selecting a unit refocuses the publishers list; the stop control for a
// running capture must not disappear with it.
await page.locator('[data-testid^="rf__node-unit:"]').first().click({
position: { x: 24, y: 18 },
});
await page.waitForTimeout(500);

await expect(traceRow.locator(".publisher-trace-button.is-stop")).toBeVisible();
});

test("the inspector cannot be dragged over the topology", async ({ page }) => {
await primeGlobalSettings(page, { inspectorWidthPx: 500 });
await page.setViewportSize({ width: 1000, height: 800 });
await page.goto("/?fixture=root-scope-navigation");

const handle = page.locator(".inspector-resize-handle");
const box = await handle.boundingBox();
if (!box) {
throw new Error("resize handle is not visible");
}
await page.mouse.move(box.x + box.width / 2, box.y + 300);
await page.mouse.down();
await page.mouse.move(10, box.y + 300, { steps: 10 }); // drag to the far left
await page.mouse.up();

const topologyWidth =
(await page.locator(".dashboard-main").boundingBox())?.width ?? 0;
expect(topologyWidth).toBeGreaterThanOrEqual(480);
});
Loading
Loading