Compare commits
1 commit
496c85e89f
...
fb266cf17a
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fb266cf17a |
10 changed files with 257 additions and 15 deletions
|
|
@ -5,16 +5,24 @@
|
||||||
import StatusStrip from "./StatusStrip.svelte";
|
import StatusStrip from "./StatusStrip.svelte";
|
||||||
import TelemetryGrid from "./TelemetryGrid.svelte";
|
import TelemetryGrid from "./TelemetryGrid.svelte";
|
||||||
|
|
||||||
let { dashboard }: { dashboard: UiDashboardPreview } = $props();
|
const generatedTitleId = $props.id();
|
||||||
|
|
||||||
|
let {
|
||||||
|
dashboard,
|
||||||
|
titleId = `${generatedTitleId}-title`,
|
||||||
|
}: {
|
||||||
|
dashboard: UiDashboardPreview;
|
||||||
|
titleId?: string;
|
||||||
|
} = $props();
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<main class="dashboard-frame" aria-labelledby="dashboard-title">
|
<main class="dashboard-frame" aria-labelledby={titleId}>
|
||||||
<header class="dashboard-frame__header">
|
<header class="dashboard-frame__header">
|
||||||
<div>
|
<div>
|
||||||
{#if dashboard.eyebrow}
|
{#if dashboard.eyebrow}
|
||||||
<p>{dashboard.eyebrow}</p>
|
<p>{dashboard.eyebrow}</p>
|
||||||
{/if}
|
{/if}
|
||||||
<h1 id="dashboard-title">{dashboard.title}</h1>
|
<h1 id={titleId}>{dashboard.title}</h1>
|
||||||
{#if dashboard.subtitle}
|
{#if dashboard.subtitle}
|
||||||
<span>{dashboard.subtitle}</span>
|
<span>{dashboard.subtitle}</span>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
|
||||||
|
|
@ -2,12 +2,32 @@
|
||||||
import type { UiStatusItem } from "../types";
|
import type { UiStatusItem } from "../types";
|
||||||
|
|
||||||
let { item }: { item: UiStatusItem } = $props();
|
let { item }: { item: UiStatusItem } = $props();
|
||||||
|
|
||||||
|
let target = $derived(item.link?.external ? "_blank" : undefined);
|
||||||
|
let rel = $derived(item.link?.external ? "noreferrer" : undefined);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="footer-cell" data-severity={item.severity || "neutral"}>
|
{#snippet cellContent()}
|
||||||
<span>{item.label}</span>
|
<span>{item.label}</span>
|
||||||
<strong>{item.value}</strong>
|
<strong>{item.value}</strong>
|
||||||
</div>
|
{/snippet}
|
||||||
|
|
||||||
|
{#if item.link}
|
||||||
|
<a
|
||||||
|
class="footer-cell"
|
||||||
|
data-severity={item.severity || "neutral"}
|
||||||
|
href={item.link.href}
|
||||||
|
target={target}
|
||||||
|
rel={rel}
|
||||||
|
aria-label={item.link.label}
|
||||||
|
>
|
||||||
|
{@render cellContent()}
|
||||||
|
</a>
|
||||||
|
{:else}
|
||||||
|
<div class="footer-cell" data-severity={item.severity || "neutral"}>
|
||||||
|
{@render cellContent()}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
.footer-cell {
|
.footer-cell {
|
||||||
|
|
@ -16,6 +36,8 @@
|
||||||
min-height: 2.65rem;
|
min-height: 2.65rem;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
background: rgba(2, 3, 2, 0.92);
|
background: rgba(2, 3, 2, 0.92);
|
||||||
|
color: inherit;
|
||||||
|
text-decoration: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
span,
|
span,
|
||||||
|
|
@ -52,7 +74,16 @@
|
||||||
color: var(--ui-color-danger);
|
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 {
|
.footer-cell[data-severity="loading"] strong {
|
||||||
color: var(--ui-color-accent);
|
color: var(--ui-color-accent);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.footer-cell:where(a):hover strong {
|
||||||
|
color: var(--ui-color-accent);
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
|
||||||
|
|
@ -64,4 +64,23 @@
|
||||||
background: var(--ui-color-accent);
|
background: var(--ui-color-accent);
|
||||||
color: #050605;
|
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);
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
|
||||||
|
|
@ -4,9 +4,12 @@
|
||||||
import StatusBadge from "./StatusBadge.svelte";
|
import StatusBadge from "./StatusBadge.svelte";
|
||||||
|
|
||||||
let { service }: { service: UiServiceRow } = $props();
|
let { service }: { service: UiServiceRow } = $props();
|
||||||
|
|
||||||
|
let target = $derived(service.link?.external ? "_blank" : undefined);
|
||||||
|
let rel = $derived(service.link?.external ? "noreferrer" : undefined);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<article class="service-row" data-severity={service.severity}>
|
{#snippet rowContent()}
|
||||||
<IconGlyph name={service.icon} />
|
<IconGlyph name={service.icon} />
|
||||||
<div class="service-row__main">
|
<div class="service-row__main">
|
||||||
<h3>{service.label}</h3>
|
<h3>{service.label}</h3>
|
||||||
|
|
@ -15,7 +18,24 @@
|
||||||
{#if service.detail}
|
{#if service.detail}
|
||||||
<StatusBadge label={service.detail} severity={service.severity} />
|
<StatusBadge label={service.detail} severity={service.severity} />
|
||||||
{/if}
|
{/if}
|
||||||
</article>
|
{/snippet}
|
||||||
|
|
||||||
|
{#if service.link}
|
||||||
|
<a
|
||||||
|
class="service-row"
|
||||||
|
data-severity={service.severity}
|
||||||
|
href={service.link.href}
|
||||||
|
target={target}
|
||||||
|
rel={rel}
|
||||||
|
aria-label={service.link.label}
|
||||||
|
>
|
||||||
|
{@render rowContent()}
|
||||||
|
</a>
|
||||||
|
{:else}
|
||||||
|
<article class="service-row" data-severity={service.severity}>
|
||||||
|
{@render rowContent()}
|
||||||
|
</article>
|
||||||
|
{/if}
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
.service-row {
|
.service-row {
|
||||||
|
|
@ -26,7 +46,9 @@
|
||||||
min-height: 3.75rem;
|
min-height: 3.75rem;
|
||||||
border: var(--ui-border);
|
border: var(--ui-border);
|
||||||
background: rgba(12, 13, 12, 0.72);
|
background: rgba(12, 13, 12, 0.72);
|
||||||
|
color: inherit;
|
||||||
padding: var(--ui-space-2);
|
padding: var(--ui-space-2);
|
||||||
|
text-decoration: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.service-row[data-severity="warning"] {
|
.service-row[data-severity="warning"] {
|
||||||
|
|
@ -42,6 +64,10 @@
|
||||||
border-color: color-mix(in srgb, var(--ui-color-accent), transparent 56%);
|
border-color: color-mix(in srgb, var(--ui-color-accent), transparent 56%);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.service-row:where(a):hover {
|
||||||
|
border-color: var(--ui-color-accent);
|
||||||
|
}
|
||||||
|
|
||||||
.service-row__main {
|
.service-row__main {
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,11 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { clampPercent, formatMetricValue } from "../format";
|
import { formatMetricValue } from "../format";
|
||||||
import type { UiTelemetryCard } from "../types";
|
import type { UiTelemetryCard } from "../types";
|
||||||
import IconGlyph from "./IconGlyph.svelte";
|
import IconGlyph from "./IconGlyph.svelte";
|
||||||
|
|
||||||
let { card }: { card: UiTelemetryCard } = $props();
|
let { card }: { card: UiTelemetryCard } = $props();
|
||||||
|
|
||||||
let progress = $derived(clampPercent(card.progress ?? Number(card.value.value)));
|
let progress = $derived(resolveProgress(card));
|
||||||
let value = $derived(formatMetricValue(card.value));
|
let value = $derived(formatMetricValue(card.value));
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|
@ -17,9 +17,11 @@
|
||||||
<h3>{card.label}</h3>
|
<h3>{card.label}</h3>
|
||||||
</header>
|
</header>
|
||||||
<strong>{value}</strong>
|
<strong>{value}</strong>
|
||||||
<div class="telemetry-card__bar" aria-hidden="true">
|
{#if progress !== null}
|
||||||
<span style={`--metric-progress: ${progress}%`}></span>
|
<div class="telemetry-card__bar" aria-hidden="true">
|
||||||
</div>
|
<span style={`--metric-progress: ${progress}%`}></span>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
{#if card.sparkline?.length}
|
{#if card.sparkline?.length}
|
||||||
<svg class="telemetry-card__sparkline" viewBox="0 0 100 24" aria-hidden="true">
|
<svg class="telemetry-card__sparkline" viewBox="0 0 100 24" aria-hidden="true">
|
||||||
<polyline points={sparklinePoints(card.sparkline)} />
|
<polyline points={sparklinePoints(card.sparkline)} />
|
||||||
|
|
@ -128,6 +130,19 @@
|
||||||
</style>
|
</style>
|
||||||
|
|
||||||
<script lang="ts" module>
|
<script lang="ts" module>
|
||||||
|
import { clampPercent } from "../format";
|
||||||
|
|
||||||
|
function resolveProgress(card: {
|
||||||
|
progress?: number;
|
||||||
|
value: { kind: string; value: number | string };
|
||||||
|
}): number | null {
|
||||||
|
if (typeof card.progress === "number") return clampPercent(card.progress);
|
||||||
|
if (card.value.kind !== "percent") return null;
|
||||||
|
|
||||||
|
const progress = Number(card.value.value);
|
||||||
|
return Number.isFinite(progress) ? clampPercent(progress) : null;
|
||||||
|
}
|
||||||
|
|
||||||
function sparklinePoints(values: number[]): string {
|
function sparklinePoints(values: number[]): string {
|
||||||
const max = Math.max(...values, 1);
|
const max = Math.max(...values, 1);
|
||||||
const step = values.length > 1 ? 100 / (values.length - 1) : 100;
|
const step = values.length > 1 ? 100 / (values.length - 1) : 100;
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,9 @@
|
||||||
import { render } from "svelte/server";
|
import { render } from "svelte/server";
|
||||||
import { describe, expect, test } from "vitest";
|
import { describe, expect, test } from "vitest";
|
||||||
import DashboardFrame from "./DashboardFrame.svelte";
|
import DashboardFrame from "./DashboardFrame.svelte";
|
||||||
|
import FooterCell from "./FooterCell.svelte";
|
||||||
|
import ServiceRow from "./ServiceRow.svelte";
|
||||||
|
import TelemetryCard from "./TelemetryCard.svelte";
|
||||||
import { dashboardPreviewFixtures } from "../fixtures";
|
import { dashboardPreviewFixtures } from "../fixtures";
|
||||||
|
|
||||||
describe("dashboard UI components", () => {
|
describe("dashboard UI components", () => {
|
||||||
|
|
@ -16,6 +19,7 @@ describe("dashboard UI components", () => {
|
||||||
expect(body).toContain("Queue Workers");
|
expect(body).toContain("Queue Workers");
|
||||||
expect(body).toContain("data-icon-name=\"mdi:server-network\"");
|
expect(body).toContain("data-icon-name=\"mdi:server-network\"");
|
||||||
expect(body).toContain("data-severity=\"warning\"");
|
expect(body).toContain("data-severity=\"warning\"");
|
||||||
|
expect(body).not.toContain("dashboard-title");
|
||||||
});
|
});
|
||||||
|
|
||||||
test("renders another generic dashboard fixture through the same component", () => {
|
test("renders another generic dashboard fixture through the same component", () => {
|
||||||
|
|
@ -31,4 +35,63 @@ describe("dashboard UI components", () => {
|
||||||
expect(body).toContain("data-icon-name=\"mdi:headset\"");
|
expect(body).toContain("data-icon-name=\"mdi:headset\"");
|
||||||
expect(body).toContain("data-severity=\"loading\"");
|
expect(body).toContain("data-severity=\"loading\"");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test("renders optional service and status links as focusable anchors", () => {
|
||||||
|
const service = render(ServiceRow, {
|
||||||
|
props: {
|
||||||
|
service: {
|
||||||
|
id: "linked-service",
|
||||||
|
label: "Linked Service",
|
||||||
|
description: "Generic linked destination",
|
||||||
|
severity: "ok",
|
||||||
|
detail: "ready",
|
||||||
|
link: { href: "https://example.test/service", external: true },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const footer = render(FooterCell, {
|
||||||
|
props: {
|
||||||
|
item: {
|
||||||
|
id: "linked-status",
|
||||||
|
label: "Linked Status",
|
||||||
|
value: "open",
|
||||||
|
severity: "neutral",
|
||||||
|
link: { href: "https://example.test/status" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(service.body).toContain("<a ");
|
||||||
|
expect(service.body).toContain("href=\"https://example.test/service\"");
|
||||||
|
expect(service.body).toContain("target=\"_blank\"");
|
||||||
|
expect(footer.body).toContain("<a ");
|
||||||
|
expect(footer.body).toContain("href=\"https://example.test/status\"");
|
||||||
|
});
|
||||||
|
|
||||||
|
test("does not render progress bars for non-percent metrics without explicit progress", () => {
|
||||||
|
const withoutProgress = render(TelemetryCard, {
|
||||||
|
props: {
|
||||||
|
card: {
|
||||||
|
id: "bytes",
|
||||||
|
label: "Bytes Metric",
|
||||||
|
value: { kind: "bytes", value: 2048 },
|
||||||
|
severity: "neutral",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
const withProgress = render(TelemetryCard, {
|
||||||
|
props: {
|
||||||
|
card: {
|
||||||
|
id: "bytes-with-progress",
|
||||||
|
label: "Bytes With Progress",
|
||||||
|
value: { kind: "bytes", value: 2048 },
|
||||||
|
progress: 42,
|
||||||
|
severity: "neutral",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(withoutProgress.body).not.toContain("telemetry-card__bar");
|
||||||
|
expect(withProgress.body).toContain("--metric-progress: 42%");
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,7 @@ export { default as TelemetryGrid } from "./components/TelemetryGrid.svelte";
|
||||||
export { dashboardPreviewFixtures } from "./fixtures";
|
export { dashboardPreviewFixtures } from "./fixtures";
|
||||||
export type {
|
export type {
|
||||||
UiDashboardPreview,
|
UiDashboardPreview,
|
||||||
|
UiLink,
|
||||||
UiMetricValue,
|
UiMetricValue,
|
||||||
UiModuleBlock,
|
UiModuleBlock,
|
||||||
UiServiceGroup,
|
UiServiceGroup,
|
||||||
|
|
|
||||||
|
|
@ -22,6 +22,12 @@ export interface UiMetricValue {
|
||||||
precision?: number;
|
precision?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface UiLink {
|
||||||
|
href: string;
|
||||||
|
label?: string;
|
||||||
|
external?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
export interface UiTelemetryCard {
|
export interface UiTelemetryCard {
|
||||||
id: string;
|
id: string;
|
||||||
label: string;
|
label: string;
|
||||||
|
|
@ -41,6 +47,7 @@ export interface UiServiceRow {
|
||||||
icon?: string;
|
icon?: string;
|
||||||
severity: UiSeverity;
|
severity: UiSeverity;
|
||||||
detail?: string;
|
detail?: string;
|
||||||
|
link?: UiLink;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface UiServiceGroup {
|
export interface UiServiceGroup {
|
||||||
|
|
@ -55,6 +62,7 @@ export interface UiStatusItem {
|
||||||
label: string;
|
label: string;
|
||||||
value: string;
|
value: string;
|
||||||
severity?: UiSeverity;
|
severity?: UiSeverity;
|
||||||
|
link?: UiLink;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface UiModuleBlock {
|
export interface UiModuleBlock {
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,52 @@
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { DashboardFrame, dashboardPreviewFixtures } from "$lib/ui";
|
import { DashboardFrame, dashboardPreviewFixtures } from "$lib/ui";
|
||||||
|
import type { PageData } from "./$types";
|
||||||
|
|
||||||
const fixtureKeys = ["primary", "secondary"] as const;
|
let { data }: { data: PageData } = $props();
|
||||||
let selectedFixture: (typeof fixtureKeys)[number] = $state("primary");
|
|
||||||
let dashboard = $derived(dashboardPreviewFixtures[selectedFixture]);
|
const fixtureKeys = ["runtime", "primary", "secondary"] as const;
|
||||||
|
let selectedFixture: (typeof fixtureKeys)[number] = $state("runtime");
|
||||||
|
let runtimeDashboard = $derived({
|
||||||
|
...dashboardPreviewFixtures.primary,
|
||||||
|
eyebrow: data.dashboard.schemaVersion || "Runtime",
|
||||||
|
title: data.dashboard.title,
|
||||||
|
subtitle: data.dashboard.subtitle,
|
||||||
|
modules: [
|
||||||
|
{
|
||||||
|
id: "runtime-state",
|
||||||
|
title: "Runtime",
|
||||||
|
value: data.dashboard.status,
|
||||||
|
detail: data.dashboard.message,
|
||||||
|
icon: "mdi:database-check-outline",
|
||||||
|
severity: "loading" as const,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
statusItems: [
|
||||||
|
{
|
||||||
|
id: "runtime-status",
|
||||||
|
label: "Runtime",
|
||||||
|
value: data.dashboard.status,
|
||||||
|
severity: "loading" as const,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "schema-version",
|
||||||
|
label: "Schema",
|
||||||
|
value: data.dashboard.schemaVersion || "pending",
|
||||||
|
severity: "neutral" as const,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "revision",
|
||||||
|
label: "Revision",
|
||||||
|
value: data.dashboard.currentRevisionId?.slice(0, 12) || "pending",
|
||||||
|
severity: "stale" as const,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
let dashboard = $derived(
|
||||||
|
selectedFixture === "runtime"
|
||||||
|
? runtimeDashboard
|
||||||
|
: dashboardPreviewFixtures[selectedFixture],
|
||||||
|
);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<svelte:head>
|
<svelte:head>
|
||||||
|
|
|
||||||
28
src/routes/page.test.ts
Normal file
28
src/routes/page.test.ts
Normal file
|
|
@ -0,0 +1,28 @@
|
||||||
|
import { render } from "svelte/server";
|
||||||
|
import { describe, expect, test } from "vitest";
|
||||||
|
import Page from "./+page.svelte";
|
||||||
|
|
||||||
|
describe("home page design-system preview", () => {
|
||||||
|
test("renders the runtime dashboard data from the server load", () => {
|
||||||
|
const { body } = render(Page, {
|
||||||
|
props: {
|
||||||
|
data: {
|
||||||
|
dashboard: {
|
||||||
|
title: "Runtime Surface",
|
||||||
|
subtitle: "Loaded from persistence",
|
||||||
|
status: "building",
|
||||||
|
message: "Runtime model is available.",
|
||||||
|
schemaVersion: "dashboard.v1",
|
||||||
|
currentRevisionId: "revision-1234567890",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(body).toContain("Runtime Surface");
|
||||||
|
expect(body).toContain("Loaded from persistence");
|
||||||
|
expect(body).toContain("revision");
|
||||||
|
expect(body).toContain("primary");
|
||||||
|
expect(body).toContain("secondary");
|
||||||
|
});
|
||||||
|
});
|
||||||
Loading…
Add table
Add a link
Reference in a new issue