feat(ui): migrate dashboard runtime to React #28
5 changed files with 362 additions and 4 deletions
|
|
@ -1,4 +1,5 @@
|
|||
import "uplot/dist/uPlot.min.css";
|
||||
import { useEffect, useRef } from "react";
|
||||
import type { UiSeverity } from "../types";
|
||||
|
||||
export interface LineChartProps {
|
||||
|
|
@ -12,6 +13,38 @@ export function LineChart({
|
|||
severity = "neutral",
|
||||
label = "Telemetry trend",
|
||||
}: 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 (
|
||||
<div
|
||||
className="line-chart"
|
||||
|
|
@ -21,7 +54,61 @@ export function LineChart({
|
|||
role="img"
|
||||
aria-label={label}
|
||||
>
|
||||
<div className="line-chart__canvas" aria-hidden="true" />
|
||||
<div ref={chartElementRef} className="line-chart__canvas" aria-hidden="true" />
|
||||
</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";
|
||||
}
|
||||
|
|
|
|||
|
|
@ -107,9 +107,28 @@
|
|||
display: grid;
|
||||
min-height: 5.15rem;
|
||||
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;
|
||||
}
|
||||
|
||||
.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 {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
|
|
@ -117,6 +136,10 @@
|
|||
gap: 0.35rem;
|
||||
}
|
||||
|
||||
.telemetry-card header .icon-glyph {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.telemetry-card h3,
|
||||
.telemetry-card p,
|
||||
.module-card h2,
|
||||
|
|
@ -132,7 +155,6 @@
|
|||
.telemetry-card h3,
|
||||
.service-row h3 {
|
||||
overflow: hidden;
|
||||
font-size: 0.62rem;
|
||||
font-weight: 850;
|
||||
letter-spacing: 0;
|
||||
line-height: 1.05;
|
||||
|
|
@ -141,6 +163,19 @@
|
|||
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,
|
||||
.module-card strong {
|
||||
display: block;
|
||||
|
|
@ -157,13 +192,22 @@
|
|||
.service-row p {
|
||||
overflow: hidden;
|
||||
color: var(--ui-color-muted);
|
||||
font-size: 0.52rem;
|
||||
line-height: 1.08;
|
||||
text-overflow: ellipsis;
|
||||
text-transform: uppercase;
|
||||
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,
|
||||
.progress-meter {
|
||||
height: 0.25rem;
|
||||
|
|
@ -179,6 +223,10 @@
|
|||
background: currentColor;
|
||||
}
|
||||
|
||||
.telemetry-card[data-severity="loading"] .telemetry-card__bar span {
|
||||
min-width: 1.25rem;
|
||||
}
|
||||
|
||||
.line-chart {
|
||||
position: relative;
|
||||
min-width: 0;
|
||||
|
|
@ -186,11 +234,36 @@
|
|||
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 {
|
||||
width: 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 {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
|
|
@ -198,6 +271,7 @@
|
|||
align-items: start;
|
||||
min-width: 0;
|
||||
min-height: 4.15rem;
|
||||
background: rgba(6, 8, 7, 0.82);
|
||||
padding: 0.5rem;
|
||||
}
|
||||
|
||||
|
|
@ -208,6 +282,43 @@
|
|||
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 {
|
||||
display: grid;
|
||||
grid-template-columns: auto minmax(0, 1fr) auto;
|
||||
|
|
@ -222,6 +333,24 @@
|
|||
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 {
|
||||
min-width: 0;
|
||||
}
|
||||
|
|
@ -240,6 +369,30 @@
|
|||
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 {
|
||||
display: grid;
|
||||
grid-template-columns: auto minmax(0, 1fr);
|
||||
|
|
@ -258,6 +411,45 @@
|
|||
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 {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(9rem, 1fr));
|
||||
|
|
@ -266,16 +458,51 @@
|
|||
background: var(--ui-color-line);
|
||||
}
|
||||
|
||||
.status-strip[data-compact="true"] {
|
||||
grid-template-columns: repeat(auto-fit, minmax(6rem, 1fr));
|
||||
}
|
||||
|
||||
.panel {
|
||||
position: relative;
|
||||
min-width: 0;
|
||||
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 {
|
||||
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 {
|
||||
display: grid;
|
||||
gap: 0.25rem;
|
||||
|
|
@ -333,6 +560,16 @@
|
|||
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 {
|
||||
display: grid;
|
||||
grid-template-columns: auto minmax(0, 1fr);
|
||||
|
|
@ -390,6 +627,14 @@
|
|||
.dashboard-header h1 {
|
||||
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) {
|
||||
|
|
@ -397,3 +642,14 @@
|
|||
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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import AxeBuilder from "@axe-core/playwright";
|
||||
import { expect, test } from "@playwright/test";
|
||||
import { expect, test, type Page } from "@playwright/test";
|
||||
|
||||
const linkedServiceIds = [
|
||||
"vaultwarden",
|
||||
|
|
@ -33,6 +33,7 @@ test.describe("dashboard page QA gate", () => {
|
|||
test.skip(testInfo.project.name !== "chromium-desktop");
|
||||
|
||||
await page.goto("/");
|
||||
await waitForDashboardReady(page);
|
||||
|
||||
await expect(page.getByRole("main")).toHaveAttribute(
|
||||
"aria-labelledby",
|
||||
|
|
@ -61,6 +62,7 @@ test.describe("dashboard page QA gate", () => {
|
|||
|
||||
await page.setViewportSize({ width: 1470, height: 956 });
|
||||
await page.goto("/");
|
||||
await waitForDashboardReady(page);
|
||||
|
||||
const metrics = await page.evaluate(() => {
|
||||
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");
|
||||
|
||||
await page.goto("/");
|
||||
await waitForDashboardReady(page);
|
||||
|
||||
const bookmarkDensity = await page.evaluate(() => {
|
||||
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.goto("/");
|
||||
await waitForDashboardReady(page);
|
||||
|
||||
const typeScale = await page.evaluate(() => {
|
||||
const fontSize = (selector: string) => {
|
||||
|
|
@ -185,6 +189,7 @@ test.describe("dashboard page QA gate", () => {
|
|||
test.skip(testInfo.project.name !== "chromium-mobile");
|
||||
|
||||
await page.goto("/");
|
||||
await waitForDashboardReady(page);
|
||||
|
||||
await expect(
|
||||
page.getByRole("heading", { level: 1, name: "System Overview" }),
|
||||
|
|
@ -201,6 +206,7 @@ test.describe("dashboard page QA gate", () => {
|
|||
page,
|
||||
}) => {
|
||||
await page.goto("/");
|
||||
await waitForDashboardReady(page);
|
||||
|
||||
await expect(page.getByRole("main")).toHaveCount(1);
|
||||
for (const serviceId of linkedServiceIds) {
|
||||
|
|
@ -218,6 +224,7 @@ test.describe("dashboard page QA gate", () => {
|
|||
|
||||
test("passes automated accessibility checks", async ({ page }) => {
|
||||
await page.goto("/");
|
||||
await waitForDashboardReady(page);
|
||||
|
||||
const results = await new AxeBuilder({ page }).analyze();
|
||||
expect(results.violations).toEqual([]);
|
||||
|
|
@ -227,6 +234,7 @@ test.describe("dashboard page QA gate", () => {
|
|||
await page.emulateMedia({ reducedMotion: "reduce" });
|
||||
|
||||
await page.goto("/");
|
||||
await waitForDashboardReady(page);
|
||||
const durations = await page.evaluate(() => {
|
||||
const element = document.createElement("div");
|
||||
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 {
|
||||
if (duration.endsWith("ms")) return Number.parseFloat(duration);
|
||||
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 |
Binary file not shown.
|
Before Width: | Height: | Size: 276 KiB After Width: | Height: | Size: 273 KiB |
Loading…
Add table
Add a link
Reference in a new issue