Skip to content
Open
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
1 change: 1 addition & 0 deletions apps/web/playwright.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ export default defineConfig({
timeout: 5_000
},
fullyParallel: true,
workers: 4,
forbidOnly: Boolean(process.env.CI),
retries: process.env.CI ? 2 : 0,
reporter: process.env.CI ? [['html', { open: 'never' }], ['list']] : 'list',
Expand Down
17 changes: 16 additions & 1 deletion apps/web/src/lib/components/charts/ChartCard.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,22 @@
} = $props();
</script>

<Card.Root size="sm" class="gap-0 py-0">
<Card.Root
size="sm"
class="gap-0 py-0"
data-testid="chart-card-state"
data-state={unavailableCopy || selectionUnavailableCopy
? 'unavailable'
: loading
? 'loading'
: error
? 'error'
: noMetrics
? 'no-metrics'
: empty
? 'empty'
: 'ready'}
>
<Card.Header
class="border-border relative cursor-grab border-b py-4 select-none active:cursor-grabbing"
draggable="true"
Expand Down
3 changes: 2 additions & 1 deletion apps/web/src/lib/components/charts/ChartContainer.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -767,7 +767,8 @@
onmouseup={finishRangeSelection}
onmouseleave={finishRangeSelection}
>
<canvas bind:this={chartCanvas} class="h-full w-full"></canvas>
<canvas bind:this={chartCanvas} class="h-full w-full" aria-label="Traffic overview chart"
></canvas>
{#if rangeDrag.isDraggingRange && selectionWidth >= MIN_DRAG_PIXELS}
<div
class="border-muted-foreground/70 bg-muted/20 pointer-events-none absolute border"
Expand Down
17 changes: 16 additions & 1 deletion apps/web/src/lib/components/charts/CoverageStrip.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -333,6 +333,11 @@
const datasets = visibleTimelines.map((timeline, sourceIndex) => ({
label: timeline.sourceId,
data: timeline.buckets.map(() => sourceIndex),
chartContractValues: timeline.buckets.map((bucket) =>
bucket.coverage.state === 'unknown'
? null
: bucket.coverage.observedUnits / bucket.coverage.expectedUnits
),
borderColor: 'transparent',
backgroundColor: 'transparent',
borderWidth: 0,
Expand Down Expand Up @@ -493,7 +498,17 @@
});
</script>

<div class="bg-card rounded-lg border shadow-sm" data-testid="coverage-strip-card">
<div
class="bg-card rounded-lg border shadow-sm"
data-testid="coverage-strip-card"
data-state={loading
? 'loading'
: error
? 'error'
: visibleTimelines.length === 0
? 'empty'
: 'ready'}
>
<div
class="relative cursor-grab border-b p-3 select-none active:cursor-grabbing"
draggable="true"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -238,6 +238,6 @@
{/if}
</div>
<div class="relative h-72 w-full min-w-0 sm:h-96">
<canvas bind:this={chartCanvas}></canvas>
<canvas bind:this={chartCanvas} aria-label="Structure function chart"></canvas>
</div>
</div>
80 changes: 80 additions & 0 deletions apps/web/src/lib/components/charts/chart-contract.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
export type RenderedSeries = {
label: string;
visible: boolean;
count: number;
min: number | null;
max: number | null;
total: number;
point?: { x: number; y: number };
legend?: { x: number; y: number };
};

export type RenderedChart = {
name: string;
kind: string;
axes: string[];
series: RenderedSeries[];
};

export function createChartContract(surface: HTMLElement | SVGElement) {
const summary = document.createElement('div');
summary.className = 'sr-only';
summary.dataset.testid = 'chart-render-state';
summary.setAttribute('role', 'group');
const tooltip = document.createElement('span');
tooltip.dataset.testid = 'chart-tooltip';
tooltip.hidden = true;
surface.dataset.testid = 'chart-surface';
surface.setAttribute('role', 'img');
surface.after(summary);

return {
render(snapshot: RenderedChart) {
const visible = snapshot.series.filter((series) => series.visible);
const marks = visible.reduce((count, series) => count + series.count, 0);
summary.dataset.state = marks > 0 ? 'ready' : 'empty';
surface.dataset.chartRendered = 'true';
summary.dataset.kind = snapshot.kind;
summary.dataset.seriesCount = String(visible.length);
summary.dataset.markCount = String(marks);
const label = `${snapshot.name}: ${visible.length} series, ${marks} data points. ${snapshot.axes.join(', ')}.`;
summary.setAttribute('aria-label', 'Rendered chart summary');
surface.setAttribute('aria-description', label);
const entries = snapshot.series.map((series) => {
const entry = document.createElement('span');
entry.dataset.testid = 'chart-series';
entry.dataset.visible = String(series.visible);
entry.dataset.count = String(series.count);
entry.dataset.min = String(series.min);
entry.dataset.max = String(series.max);
entry.dataset.total = String(series.total);
if (series.point) {
entry.dataset.pointX = String(series.point.x);
entry.dataset.pointY = String(series.point.y);
}
if (series.legend) {
entry.dataset.legendX = String(series.legend.x);
entry.dataset.legendY = String(series.legend.y);
}
entry.textContent = series.label;
return entry;
});
const axes = snapshot.axes.map((label) => {
const axis = document.createElement('span');
axis.dataset.testid = 'chart-axis';
axis.textContent = label;
return axis;
});
summary.replaceChildren(...axes, ...entries, tooltip);
},
tooltip(text: string) {
if (tooltip.textContent !== text) tooltip.textContent = text;
tooltip.hidden = text.length === 0;
},
destroy() {
summary.remove();
delete surface.dataset.chartRendered;
surface.removeAttribute('aria-description');
}
};
}
4 changes: 3 additions & 1 deletion apps/web/src/lib/components/charts/chart-registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import {
Tooltip
} from 'chart.js';
import { verticalCrosshairPlugin } from './crosshair-plugin';
import { chartContractPlugin } from './chartjs-contract';

let registered = false;

Expand All @@ -29,7 +30,8 @@ export function ensureChartRegistry(): void {
PointElement,
ScatterController,
Tooltip,
verticalCrosshairPlugin
verticalCrosshairPlugin,
chartContractPlugin
);
registered = true;
}
Expand Down
118 changes: 118 additions & 0 deletions apps/web/src/lib/components/charts/chartjs-contract.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
import type { Chart, Plugin } from 'chart.js';
import { createChartContract, type RenderedChart } from './chart-contract';

function isHitBox(
value: unknown
): value is { left: number; top: number; width: number; height: number } {
return (
typeof value === 'object' &&
value !== null &&
['left', 'top', 'width', 'height'].every(
(key) => key in value && typeof Reflect.get(value, key) === 'number'
)
);
}

const contracts = new WeakMap<Chart, ReturnType<typeof createChartContract>>();

export const chartContractPlugin: Plugin = {
id: 'chartContract',
afterRender(chart) {
const series = chart.data.datasets.map((dataset, index) => {
const meta = chart.getDatasetMeta(index);
let count = 0;
let min = Infinity;
let max = -Infinity;
let total = 0;
let target: { x: number; y: number } | undefined;
for (let point = 0; point < dataset.data.length; point += 1) {
const parsed = meta._parsed[point];
if (
!parsed ||
typeof parsed !== 'object' ||
!('x' in parsed) ||
!('y' in parsed) ||
typeof parsed.x !== 'number' ||
typeof parsed.y !== 'number' ||
!Number.isFinite(parsed.y)
)
continue;
if (meta.xScale && (parsed.x < meta.xScale.min || parsed.x > meta.xScale.max)) continue;
const element = meta.data[point];
if (!element || !Number.isFinite(element.x) || !Number.isFinite(element.y)) continue;
const values = 'chartContractValues' in dataset ? dataset.chartContractValues : null;
const value = Array.isArray(values) ? values[point] : parsed.y;
if (typeof value !== 'number' || !Number.isFinite(value)) continue;
if (!target) target = { x: element.x / chart.width, y: element.y / chart.height };
count += 1;
min = Math.min(min, value);
max = Math.max(max, value);
total += value;
}
const legendIndex =
chart.legend?.legendItems?.findIndex((item) => item.datasetIndex === index) ?? -1;
const hitBoxes =
chart.legend && 'legendHitBoxes' in chart.legend ? chart.legend.legendHitBoxes : null;
const candidate =
chart.options.plugins?.legend?.display && Array.isArray(hitBoxes)
? hitBoxes[legendIndex]
: null;
const box = isHitBox(candidate) ? candidate : null;
return {
point: chart.isDatasetVisible(index) ? target : undefined,
legend: box
? {
x: (box.left + box.width / 2) / chart.width,
y: (box.top + box.height / 2) / chart.height
}
: undefined,
label: dataset.label ?? 'Spectrum',
visible: chart.isDatasetVisible(index),
count,
min: count ? min : null,
max: count ? max : null,
total
};
});
const snapshot: RenderedChart = {
name: chart.canvas.getAttribute('aria-label') ?? 'Chart',
kind: chart.data.datasets.some((dataset) => 'fill' in dataset && dataset.fill)
? 'stacked'
: (chart.getDatasetMeta(0)?.type ?? 'line'),
axes: Object.values(chart.scales).flatMap((scale) => {
const title = 'title' in scale.options ? scale.options.title : null;
return title &&
typeof title === 'object' &&
'display' in title &&
title.display &&
'text' in title
? [String(title.text)]
: [];
}),
series
};
let contract = contracts.get(chart);
if (!contract) {
contract = createChartContract(chart.canvas);
contracts.set(chart, contract);
}
contract.render(snapshot);
},
afterDraw(chart) {
const tooltip = chart.tooltip;
const text = tooltip?.opacity
? [
...tooltip.title,
...tooltip.beforeBody,
...tooltip.body.flatMap((body) => [...body.before, ...body.lines, ...body.after]),
...tooltip.afterBody,
...tooltip.footer
].join('\n')
: '';
contracts.get(chart)?.tooltip(text);
},
afterDestroy(chart) {
contracts.get(chart)?.destroy();
contracts.delete(chart);
}
};
7 changes: 6 additions & 1 deletion apps/web/src/lib/components/netflow/NetflowDashboard.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -313,7 +313,12 @@
});
</script>

<Card.Root size="sm" class="gap-0 py-0">
<Card.Root
size="sm"
class="gap-0 py-0"
data-testid="chart-card-state"
data-state={loading ? 'loading' : error ? 'error' : results.length === 0 ? 'empty' : 'ready'}
>
<Card.Header
class="border-border relative cursor-grab border-b py-4 select-none active:cursor-grabbing"
draggable="true"
Expand Down
38 changes: 38 additions & 0 deletions apps/web/tests/e2e/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
# Dashboard e2e contract

Run `bun run test:e2e` at the repository root. Playwright builds and starts the normal web server at port 4173 and creates disposable SQLite products in the system temporary directory. Four workers limit browser and SQLite contention. The fixture contains both IP families, five granularities, directional traffic, characteristic averages, port cardinalities, IP/protocol counts, MAAD dimensions and spectra, a second populated day, and a separate alerts database. The external-side MAAD dataset also exercises unavailable selections.

The suite uses user-facing controls and rendered chart summaries. Request assertions supplement rendered-data checks for deferred fetching, cancellation, and drilldown. There are no pixel snapshots, canvas pixel reads, or intercepted drawing calls.

## Hooks to preserve during a renderer migration

Keep `data-chart-id`, `data-chart-activated`, `data-chart-sentinel`, deferred-card test IDs, existing canvas accessible names on the replacement graphics, and the named filter groups. The graphics element can become SVG while retaining the following contract:

| Hook | Meaning |
| -------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `data-testid="chart-surface"` | Visible graphic with `role="img"`, a chart-specific accessible name, and `aria-description` describing series, finite points, and displayed axes. |
| `data-chart-rendered="true"` | A completed renderer pass, including a pass with zero finite points. Also used by the performance harness. |
| `data-testid="chart-render-state"` | Sibling summary in the same graphics parent. Published after rendering, removed on destruction. |
| `data-state="ready\|empty"` | `ready` requires at least one finite point in a visible series inside the displayed x bounds. All-hidden or zero-point charts are `empty`. |
| `data-kind` | `line`, `stacked`, or `scatter` for the current chart choice. |
| `data-series-count`, `data-mark-count` | Visible series count and sum of finite visible-series vertices or scatter points. Null, unknown coverage, nonfinite and unpositioned observations are excluded. Coverage summaries report observed/expected ratios rather than lane coordinates. A line vertex counts even when its point glyph is hidden. |
| `data-testid="chart-axis"` | Text of each displayed axis title. Compact layouts may omit titles. |
| `data-testid="chart-series"` | Legend or series label with `data-visible`, `data-count`, `data-min`, `data-max`, and `data-total`. Summaries cover finite values inside displayed x bounds, including zero, and remain available for hidden legend entries. No finite values means count 0, min/max `null`, total 0. |
| `data-point-x/y`, `data-legend-x/y` | Optional interaction targets on a series summary, normalized to the graphics bounds. Point target is a finite plotted vertex, legend target is the center of the clickable legend entry. Used to drive real pointer events; tests do not assert coordinate values. Omit unavailable targets. |
| `data-testid="chart-tooltip"` | Current rendered tooltip text in the sibling summary, hidden and cleared when no tooltip is shown. Preserve the existing spectrum DOM `role="tooltip"` contract too. |
| `data-testid="chart-card-state"` | Card lifecycle: `loading`, `error`, `no-metrics`, `empty`, `unavailable`, or `ready`. Card `ready` means its content branch is active; the renderer summary proves finite data actually rendered. |
| `data-testid="coverage-strip-card"` | Coverage card retains its test ID and publishes `loading`, `error`, `empty`, or `ready`. |

`chart-contract.ts` publishes a renderer-independent DOM summary with memory proportional to series and axes. `chartjs-contract.ts` is the temporary Chart.js adapter. Replace that adapter in the migration and call the publisher from the new renderer after marks have been drawn. Do not synthesize readiness solely from an API response or from a mounted graphics element. The existing spectrum tooltip and loading/empty/unavailable copy remain user-facing contracts.

## Numerical and interaction assertions

The tests verify TCP flows of 100 across both families, 80 for IPv4, 20 for IPv6, and 50 for ingress. Duration averages are 1750 ms combined, 2000 ms for IPv4, and 1000 ms for IPv6. Port counts change from 8 to 2 with family selection. TCP packets and bytes are 1000 and 102400. Daily TCP flow totals change from 100 to 300 as the second populated day enters the range, then to 200 when the first day is excluded. MAAD side/order/measure checks assert fixture values with float tolerance.

Preserve date/grouping/direction/MAAD URL state, history navigation, file context on drilldown and next-file links, brush drilldown, legend visibility, chart order persistence, deferred activation, cached-state recovery, and cancellation of obsolete requests. Missing and unknown coverage must retain the data contract; do not replace null observations with zero in the chart adapter.

Some baseline behavior needs later product work: sources and local metric selections reset on reload, some unknown windows produce blank plot areas, and charts currently lack keyboard equivalents for pointer drilldown and legends. The suite verifies useful behavior without requiring those rough edges to remain. Improve them with new assertions when their fixes land.

## Coverage limits

The fixture suite covers the dataset index, dashboard, alerts, file lookup, file detail, and error pages at narrow and wide viewports. Alert failure and clipboard denial tests inject failures; populated alerts use the real temporary alerts database. Browser coverage is Chromium. A separate manual audit of the large read-only performance product supplies the visual reference and exercises multiple real sources. These tests do not measure performance or certify accessibility compliance.
Loading
Loading