fix(ui): restore react dashboard visual parity

This commit is contained in:
vince 2026-06-19 23:56:49 +02:00
parent 72d56cb3c9
commit b1d196e4ca
5 changed files with 362 additions and 4 deletions

View file

@ -1,4 +1,5 @@
import "uplot/dist/uPlot.min.css"; import "uplot/dist/uPlot.min.css";
import { useEffect, useRef } from "react";
import type { UiSeverity } from "../types"; import type { UiSeverity } from "../types";
export interface LineChartProps { export interface LineChartProps {
@ -12,6 +13,38 @@ export function LineChart({
severity = "neutral", severity = "neutral",
label = "Telemetry trend", label = "Telemetry trend",
}: LineChartProps) { }: LineChartProps) {
const chartElementRef = useRef<HTMLDivElement>(null);
const valuesKey = values.join(",");
useEffect(() => {
const chartElement = chartElementRef.current;
if (!chartElement) return;
let chart: ChartInstance | null = null;
let resizeObserver: ResizeObserver | null = null;
let disposed = false;
void import("uplot").then((module) => {
if (disposed || !chartElementRef.current) return;
const element = chartElementRef.current;
chart = new module.default(chartOptions(element, severity), chartData(values), element);
if (typeof ResizeObserver !== "undefined") {
resizeObserver = new ResizeObserver(() => {
chart?.setSize(chartSize(element));
});
resizeObserver.observe(element);
}
});
return () => {
disposed = true;
resizeObserver?.disconnect();
chart?.destroy();
};
}, [severity, valuesKey, values]);
return ( return (
<div <div
className="line-chart" className="line-chart"
@ -21,7 +54,61 @@ export function LineChart({
role="img" role="img"
aria-label={label} aria-label={label}
> >
<div className="line-chart__canvas" aria-hidden="true" /> <div ref={chartElementRef} className="line-chart__canvas" aria-hidden="true" />
</div> </div>
); );
} }
type ChartInstance = {
destroy(): void;
setSize(size: { width: number; height: number }): void;
};
function chartData(values: number[]): import("uplot").AlignedData {
const normalized = values.length ? values : [0, 0];
return [
normalized.map((_, index) => index),
normalized.map((value) => Math.max(0, Number(value) || 0)),
];
}
function chartOptions(
element: HTMLElement,
severity: UiSeverity,
): import("uplot").Options {
return {
...chartSize(element),
cursor: { show: false },
legend: { show: false },
padding: [2, 0, 2, 0],
scales: {
x: { time: false },
y: { auto: true },
},
axes: [{ show: false }, { show: false }],
series: [
{},
{
stroke: chartStroke(element, severity),
width: 2,
points: { show: false },
},
],
};
}
function chartSize(element?: HTMLElement): { width: number; height: number } {
return {
width: Math.max(80, Math.round(element?.clientWidth || 120)),
height: Math.max(20, Math.round(element?.clientHeight || 24)),
};
}
function chartStroke(element: HTMLElement, severity: UiSeverity): string {
const styles = window.getComputedStyle(element.closest(".line-chart") || element);
const currentColor = styles.color;
if (currentColor) return currentColor;
if (severity === "danger") return "#ff1744";
if (severity === "warning") return "#ffb020";
return "#d7ff00";
}

View file

@ -107,9 +107,28 @@
display: grid; display: grid;
min-height: 5.15rem; min-height: 5.15rem;
gap: 0.25rem; gap: 0.25rem;
background: linear-gradient(180deg, rgba(13, 15, 14, 0.82), rgba(2, 3, 2, 0.92));
padding: 0.45rem 0.55rem 0.42rem; padding: 0.45rem 0.55rem 0.42rem;
} }
.telemetry-card[data-severity="warning"] {
border-color: color-mix(in srgb, var(--ui-color-warning), transparent 42%);
}
.telemetry-card[data-severity="danger"] {
border-color: color-mix(in srgb, var(--ui-color-danger), transparent 22%);
color: var(--ui-color-danger);
}
.telemetry-card[data-severity="stale"],
.telemetry-card[data-severity="unavailable"] {
color: var(--ui-color-stale);
}
.telemetry-card[data-severity="loading"] {
color: var(--ui-color-accent);
}
.telemetry-card header { .telemetry-card header {
display: flex; display: flex;
min-width: 0; min-width: 0;
@ -117,6 +136,10 @@
gap: 0.35rem; gap: 0.35rem;
} }
.telemetry-card header .icon-glyph {
display: none;
}
.telemetry-card h3, .telemetry-card h3,
.telemetry-card p, .telemetry-card p,
.module-card h2, .module-card h2,
@ -132,7 +155,6 @@
.telemetry-card h3, .telemetry-card h3,
.service-row h3 { .service-row h3 {
overflow: hidden; overflow: hidden;
font-size: 0.62rem;
font-weight: 850; font-weight: 850;
letter-spacing: 0; letter-spacing: 0;
line-height: 1.05; line-height: 1.05;
@ -141,6 +163,19 @@
white-space: nowrap; white-space: nowrap;
} }
.telemetry-card h3 {
overflow-wrap: anywhere;
color: inherit;
font-size: 0.58rem;
font-weight: 800;
line-height: 1.02;
white-space: normal;
}
.service-row h3 {
font-size: 0.72rem;
}
.telemetry-card strong, .telemetry-card strong,
.module-card strong { .module-card strong {
display: block; display: block;
@ -157,13 +192,22 @@
.service-row p { .service-row p {
overflow: hidden; overflow: hidden;
color: var(--ui-color-muted); color: var(--ui-color-muted);
font-size: 0.52rem;
line-height: 1.08; line-height: 1.08;
text-overflow: ellipsis; text-overflow: ellipsis;
text-transform: uppercase; text-transform: uppercase;
white-space: nowrap; white-space: nowrap;
} }
.telemetry-card p {
font-size: 0.49rem;
line-height: 1.05;
}
.module-card p,
.service-row p {
font-size: 0.56rem;
}
.telemetry-card__bar, .telemetry-card__bar,
.progress-meter { .progress-meter {
height: 0.25rem; height: 0.25rem;
@ -179,6 +223,10 @@
background: currentColor; background: currentColor;
} }
.telemetry-card[data-severity="loading"] .telemetry-card__bar span {
min-width: 1.25rem;
}
.line-chart { .line-chart {
position: relative; position: relative;
min-width: 0; min-width: 0;
@ -186,11 +234,36 @@
color: var(--ui-color-accent); color: var(--ui-color-accent);
} }
.line-chart[data-severity="warning"] {
color: var(--ui-color-warning);
}
.line-chart[data-severity="danger"] {
color: var(--ui-color-danger);
}
.line-chart[data-severity="stale"],
.line-chart[data-severity="unavailable"] {
color: var(--ui-color-stale);
}
.line-chart__canvas { .line-chart__canvas {
width: 100%; width: 100%;
height: 100%; height: 100%;
} }
.line-chart__canvas .uplot {
width: 100% !important;
height: 100% !important;
background: transparent;
font-family: var(--ui-font-mono);
}
.line-chart__canvas .u-over,
.line-chart__canvas .u-under {
overflow: visible;
}
.module-card { .module-card {
display: grid; display: grid;
grid-template-columns: minmax(0, 1fr) auto; grid-template-columns: minmax(0, 1fr) auto;
@ -198,6 +271,7 @@
align-items: start; align-items: start;
min-width: 0; min-width: 0;
min-height: 4.15rem; min-height: 4.15rem;
background: rgba(6, 8, 7, 0.82);
padding: 0.5rem; padding: 0.5rem;
} }
@ -208,6 +282,43 @@
text-transform: uppercase; text-transform: uppercase;
} }
.module-card strong {
margin-top: 0.15rem;
font-size: clamp(1.25rem, 1.75vw, 1.72rem);
line-height: 0.82;
overflow-wrap: anywhere;
}
.module-card p {
margin-top: 0.2rem;
font-size: 0.49rem;
line-height: 1.05;
}
.module-card[data-severity="ok"] .icon-glyph {
background: var(--ui-color-accent);
color: #050605;
}
.module-card[data-severity="warning"] {
border-color: color-mix(in srgb, var(--ui-color-warning), transparent 42%);
color: var(--ui-color-warning);
}
.module-card[data-severity="danger"] {
border-color: color-mix(in srgb, var(--ui-color-danger), transparent 32%);
color: var(--ui-color-danger);
}
.module-card[data-severity="stale"],
.module-card[data-severity="unavailable"] {
color: var(--ui-color-stale);
}
.module-card[data-severity="loading"] {
color: var(--ui-color-accent);
}
.service-row { .service-row {
display: grid; display: grid;
grid-template-columns: auto minmax(0, 1fr) auto; grid-template-columns: auto minmax(0, 1fr) auto;
@ -222,6 +333,24 @@
text-decoration: none; text-decoration: none;
} }
.service-row[data-severity="warning"] {
border-bottom-color: color-mix(in srgb, var(--ui-color-warning), transparent 54%);
}
.service-row[data-severity="danger"],
.service-row[data-severity="unavailable"] {
border-bottom-color: color-mix(in srgb, var(--ui-color-danger), transparent 50%);
}
.service-row[data-severity="loading"] {
border-bottom-color: color-mix(in srgb, var(--ui-color-accent), transparent 56%);
}
.service-row:where(a):hover {
border-bottom-color: var(--ui-color-accent);
background: rgba(244, 244, 244, 0.035);
}
.service-row__main { .service-row__main {
min-width: 0; min-width: 0;
} }
@ -240,6 +369,30 @@
white-space: nowrap; white-space: nowrap;
} }
.status-badge[data-severity="ok"] {
border-color: color-mix(in srgb, var(--ui-color-ok), transparent 42%);
color: var(--ui-color-ok);
}
.status-badge[data-severity="warning"] {
border-color: color-mix(in srgb, var(--ui-color-warning), transparent 35%);
color: var(--ui-color-warning);
}
.status-badge[data-severity="danger"] {
border-color: color-mix(in srgb, var(--ui-color-danger), transparent 30%);
color: var(--ui-color-danger);
}
.status-badge[data-severity="stale"],
.status-badge[data-severity="unavailable"] {
color: var(--ui-color-stale);
}
.status-badge[data-severity="loading"] {
color: var(--ui-color-accent);
}
.footer-cell { .footer-cell {
display: grid; display: grid;
grid-template-columns: auto minmax(0, 1fr); grid-template-columns: auto minmax(0, 1fr);
@ -258,6 +411,45 @@
text-transform: uppercase; text-transform: uppercase;
} }
.footer-cell span {
height: 100%;
border-right: var(--ui-border);
color: var(--ui-color-muted);
font-size: 0.52rem;
font-weight: 800;
}
.footer-cell strong {
color: var(--ui-color-text);
font-size: 0.58rem;
}
.footer-cell[data-severity="ok"] strong {
background: var(--ui-color-accent);
color: #060706;
}
.footer-cell[data-severity="warning"] strong {
color: var(--ui-color-warning);
}
.footer-cell[data-severity="danger"] strong {
color: var(--ui-color-danger);
}
.footer-cell[data-severity="stale"] strong,
.footer-cell[data-severity="unavailable"] strong {
color: var(--ui-color-stale);
}
.footer-cell[data-severity="loading"] strong {
color: var(--ui-color-accent);
}
.footer-cell:where(a):hover strong {
color: var(--ui-color-accent);
}
.status-strip { .status-strip {
display: grid; display: grid;
grid-template-columns: repeat(auto-fit, minmax(9rem, 1fr)); grid-template-columns: repeat(auto-fit, minmax(9rem, 1fr));
@ -266,16 +458,51 @@
background: var(--ui-color-line); background: var(--ui-color-line);
} }
.status-strip[data-compact="true"] {
grid-template-columns: repeat(auto-fit, minmax(6rem, 1fr));
}
.panel { .panel {
position: relative; position: relative;
min-width: 0; min-width: 0;
box-shadow: var(--ui-shadow-hard); box-shadow: var(--ui-shadow-hard);
} }
.panel::before,
.panel::after {
position: absolute;
width: 0.55rem;
height: 0.55rem;
border-color: var(--ui-color-line-strong);
content: "";
}
.panel::before {
top: 0.2rem;
right: 0.2rem;
border-top: 1px solid;
border-right: 1px solid;
}
.panel::after {
right: 0.2rem;
bottom: 0.2rem;
border-right: 1px solid;
border-bottom: 1px solid;
}
.panel__header { .panel__header {
padding: 0.42rem 0.55rem 0; padding: 0.42rem 0.55rem 0;
} }
.panel h2 {
font-family: var(--ui-font-display);
font-size: clamp(1.25rem, 1.8vw, 1.75rem);
font-weight: 800;
line-height: 0.92;
text-transform: uppercase;
}
.panel__body { .panel__body {
display: grid; display: grid;
gap: 0.25rem; gap: 0.25rem;
@ -333,6 +560,16 @@
height: 0.95rem; height: 0.95rem;
} }
.icon-glyph[data-size="sm"] {
width: 1.1rem;
height: 1.1rem;
}
.icon-glyph[data-size="lg"] {
width: 2.35rem;
height: 2.35rem;
}
.system-state { .system-state {
display: grid; display: grid;
grid-template-columns: auto minmax(0, 1fr); grid-template-columns: auto minmax(0, 1fr);
@ -390,6 +627,14 @@
.dashboard-header h1 { .dashboard-header h1 {
white-space: normal; white-space: normal;
} }
.dashboard-frame__panels {
overflow: visible;
}
.dashboard-frame__panels .service-panel[data-layout="grid"] .panel__body {
grid-template-columns: 1fr;
}
} }
@media (max-width: 700px) { @media (max-width: 700px) {
@ -397,3 +642,14 @@
grid-template-columns: repeat(2, minmax(0, 1fr)); grid-template-columns: repeat(2, minmax(0, 1fr));
} }
} }
@media (max-width: 580px) {
.service-row {
grid-template-columns: auto minmax(0, 1fr);
}
.service-row .status-badge {
grid-column: 2;
justify-self: start;
}
}

View file

@ -1,5 +1,5 @@
import AxeBuilder from "@axe-core/playwright"; import AxeBuilder from "@axe-core/playwright";
import { expect, test } from "@playwright/test"; import { expect, test, type Page } from "@playwright/test";
const linkedServiceIds = [ const linkedServiceIds = [
"vaultwarden", "vaultwarden",
@ -33,6 +33,7 @@ test.describe("dashboard page QA gate", () => {
test.skip(testInfo.project.name !== "chromium-desktop"); test.skip(testInfo.project.name !== "chromium-desktop");
await page.goto("/"); await page.goto("/");
await waitForDashboardReady(page);
await expect(page.getByRole("main")).toHaveAttribute( await expect(page.getByRole("main")).toHaveAttribute(
"aria-labelledby", "aria-labelledby",
@ -61,6 +62,7 @@ test.describe("dashboard page QA gate", () => {
await page.setViewportSize({ width: 1470, height: 956 }); await page.setViewportSize({ width: 1470, height: 956 });
await page.goto("/"); await page.goto("/");
await waitForDashboardReady(page);
const metrics = await page.evaluate(() => { const metrics = await page.evaluate(() => {
const footer = document.querySelector("[data-model-id='footer-status']"); const footer = document.querySelector("[data-model-id='footer-status']");
@ -111,6 +113,7 @@ test.describe("dashboard page QA gate", () => {
test.skip(testInfo.project.name !== "chromium-desktop"); test.skip(testInfo.project.name !== "chromium-desktop");
await page.goto("/"); await page.goto("/");
await waitForDashboardReady(page);
const bookmarkDensity = await page.evaluate(() => { const bookmarkDensity = await page.evaluate(() => {
const panelBody = document.querySelector(".service-panel .panel__body"); const panelBody = document.querySelector(".service-panel .panel__body");
@ -155,6 +158,7 @@ test.describe("dashboard page QA gate", () => {
await page.setViewportSize({ width: 1470, height: 956 }); await page.setViewportSize({ width: 1470, height: 956 });
await page.goto("/"); await page.goto("/");
await waitForDashboardReady(page);
const typeScale = await page.evaluate(() => { const typeScale = await page.evaluate(() => {
const fontSize = (selector: string) => { const fontSize = (selector: string) => {
@ -185,6 +189,7 @@ test.describe("dashboard page QA gate", () => {
test.skip(testInfo.project.name !== "chromium-mobile"); test.skip(testInfo.project.name !== "chromium-mobile");
await page.goto("/"); await page.goto("/");
await waitForDashboardReady(page);
await expect( await expect(
page.getByRole("heading", { level: 1, name: "System Overview" }), page.getByRole("heading", { level: 1, name: "System Overview" }),
@ -201,6 +206,7 @@ test.describe("dashboard page QA gate", () => {
page, page,
}) => { }) => {
await page.goto("/"); await page.goto("/");
await waitForDashboardReady(page);
await expect(page.getByRole("main")).toHaveCount(1); await expect(page.getByRole("main")).toHaveCount(1);
for (const serviceId of linkedServiceIds) { for (const serviceId of linkedServiceIds) {
@ -218,6 +224,7 @@ test.describe("dashboard page QA gate", () => {
test("passes automated accessibility checks", async ({ page }) => { test("passes automated accessibility checks", async ({ page }) => {
await page.goto("/"); await page.goto("/");
await waitForDashboardReady(page);
const results = await new AxeBuilder({ page }).analyze(); const results = await new AxeBuilder({ page }).analyze();
expect(results.violations).toEqual([]); expect(results.violations).toEqual([]);
@ -227,6 +234,7 @@ test.describe("dashboard page QA gate", () => {
await page.emulateMedia({ reducedMotion: "reduce" }); await page.emulateMedia({ reducedMotion: "reduce" });
await page.goto("/"); await page.goto("/");
await waitForDashboardReady(page);
const durations = await page.evaluate(() => { const durations = await page.evaluate(() => {
const element = document.createElement("div"); const element = document.createElement("div");
element.style.animation = "qa-motion-check 10s infinite"; element.style.animation = "qa-motion-check 10s infinite";
@ -249,6 +257,13 @@ test.describe("dashboard page QA gate", () => {
}); });
}); });
async function waitForDashboardReady(page: Page): Promise<void> {
await expect(
page.getByRole("heading", { level: 1, name: "System Overview" }),
).toBeVisible();
await expect(page.locator("[data-model-id='vaultwarden']")).toBeVisible();
}
function cssDurationToMilliseconds(duration: string): number { function cssDurationToMilliseconds(duration: string): number {
if (duration.endsWith("ms")) return Number.parseFloat(duration); if (duration.endsWith("ms")) return Number.parseFloat(duration);
if (duration.endsWith("s")) return Number.parseFloat(duration) * 1000; if (duration.endsWith("s")) return Number.parseFloat(duration) * 1000;

Binary file not shown.

Before

Width:  |  Height:  |  Size: 251 KiB

After

Width:  |  Height:  |  Size: 252 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 276 KiB

After

Width:  |  Height:  |  Size: 273 KiB

Before After
Before After