feat(ui): migrate dashboard runtime to React #28

Merged
vince merged 10 commits from codex/react-migration into main 2026-06-20 00:15:09 +02:00
11 changed files with 196 additions and 8 deletions
Showing only changes of commit b8e39348e8 - Show all commits

View file

@ -18,7 +18,7 @@ This MVP uses Drizzle with Bun SQLite for local file-backed persistence.
## Scripts
- `bun run dev`: start the local development server.
- `bun run dev`: start the local Vite development server with a Bun API proxy.
- `bun run check`: run TypeScript checks.
- `bun run test`: run Vitest.
- `bun run test:unit`: run Vitest explicitly as the unit test stage.
@ -63,10 +63,11 @@ can replace them without changing presentation components.
## Runtime Shape
The browser app is built with Vite and React. Production uses a small Bun HTTP
server at `build/index.js` to serve the Vite `dist/` assets and JSON API routes.
The current persistence runtime is Bun because the MVP SQLite driver is
`bun:sqlite`.
The browser app is built with Vite and React. Local development starts Vite for
HMR and a loopback Bun API server for `/api/*` routes. Production uses a small
Bun HTTP server at `build/index.js` to serve the Vite `dist/` assets and JSON
API routes. The current persistence runtime is Bun because the MVP SQLite
driver is `bun:sqlite`.
## Storybook

View file

@ -3,6 +3,7 @@
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="description" content="Dimension Lab dashboard runtime" />
<title>Dimension Lab</title>
</head>
<body>

View file

@ -4,7 +4,7 @@
"private": true,
"type": "module",
"scripts": {
"dev": "vite --host 0.0.0.0",
"dev": "bun src/server/dev.ts",
"build": "vite build && bun build src/server/index.ts --target bun --outdir build",
"preview": "HOST=0.0.0.0 PORT=4173 bun build/index.js",
"storybook": "storybook dev -p 6006 --host 0.0.0.0",

View file

@ -49,10 +49,46 @@ export function AppStateView({
);
}
export function resolveDocumentMetadata(dashboard: DashboardRuntimeState): {
description: string;
title: string;
} {
if (dashboard.state === "ready") {
const uiDashboard = dashboardDocumentToUiDashboard(dashboard.document);
return {
title: uiDashboard.title,
description:
uiDashboard.subtitle ||
dashboard.document.metadata.description ||
uiDashboard.title,
};
}
return {
title: dashboard.title,
description: dashboard.subtitle || dashboard.message,
};
}
export default function App() {
const [dashboard, setDashboard] =
useState<DashboardRuntimeState>(loadingDashboardState);
useEffect(() => {
const metadata = resolveDocumentMetadata(dashboard);
document.title = metadata.title;
let description = document.querySelector<HTMLMetaElement>(
'meta[name="description"]',
);
if (!description) {
description = document.createElement("meta");
description.name = "description";
document.head.append(description);
}
description.content = metadata.description;
}, [dashboard]);
useEffect(() => {
let cancelled = false;
let refreshTimer: number | undefined;

View file

@ -1,5 +1,6 @@
@import "tailwindcss";
@import "./lib/ui/tokens.css";
@import "./lib/ui/components/styles.css";
@import "tw-animate-css";
@import "shadcn/tailwind.css";
@import "@fontsource-variable/geist";

View file

@ -4,7 +4,6 @@ import { ModuleCard } from "./ModuleCard";
import { ServicePanel } from "./ServicePanel";
import { StatusStrip } from "./StatusStrip";
import { TelemetryGrid } from "./TelemetryGrid";
import "./styles.css";
export interface DashboardFrameProps {
dashboard: UiDashboardPreview;

View file

@ -63,6 +63,17 @@ describe("Storybook inventory", () => {
}
});
test("loads dashboard component styles through the global app stylesheet", () => {
const appStyles = readFileSync(join(root, "src/app.css"), "utf8");
const dashboardFrame = readFileSync(
join(componentsDir, "DashboardFrame.tsx"),
"utf8",
);
expect(appStyles).toContain('./lib/ui/components/styles.css');
expect(dashboardFrame).not.toContain('./styles.css');
});
test("keeps component and story files paired as the UI inventory changes", () => {
const componentStoryFiles = readdirSync(componentsDir)
.filter((filename) => filename.endsWith(".tsx") && !filename.endsWith(".test.tsx"))

View file

@ -1,7 +1,7 @@
import { renderToString } from "react-dom/server";
import { describe, expect, test } from "vitest";
import { genericDashboardFixture } from "$lib/model/fixtures/generic";
import { AppStateView } from "./App";
import { AppStateView, resolveDocumentMetadata } from "./App";
describe("home page model renderer", () => {
test("renders the active dashboard model from the runtime state", () => {
@ -43,6 +43,30 @@ describe("home page model renderer", () => {
expect(body).toContain("/metadata/title is required");
});
test("derives browser metadata from ready and fallback runtime states", () => {
const ready = resolveDocumentMetadata({
state: "ready",
document: genericDashboardFixture,
schemaVersion: "dashboard.v1",
currentRevisionId: "revision-1234567890",
});
const empty = resolveDocumentMetadata({
state: "empty",
title: "No Dashboard Model",
subtitle: "No active document",
message: "No validated dashboard document is active yet.",
});
expect(ready).toEqual({
title: "Operations Console",
description: "Generic environment",
});
expect(empty).toEqual({
title: "No Dashboard Model",
description: "No active document",
});
});
test("renders empty and loading model states without crashing", () => {
const empty = renderToString(
<AppStateView

25
src/server/dev.test.ts Normal file
View file

@ -0,0 +1,25 @@
import { readFileSync } from "node:fs";
import { join } from "node:path";
import { describe, expect, test } from "vitest";
import { createDevServerConfig } from "../../vite.config";
describe("local development runtime", () => {
test("starts the Bun API server together with the Vite dev server", () => {
const packageJson = JSON.parse(
readFileSync(join(process.cwd(), "package.json"), "utf8"),
) as { scripts?: Record<string, string> };
expect(packageJson.scripts?.dev).toBe("bun src/server/dev.ts");
});
test("proxies dashboard API requests from Vite to the Bun API server", () => {
const server = createDevServerConfig({
DASHBOARD_DEV_API_TARGET: "http://127.0.0.1:5174",
});
expect(server?.proxy?.["/api"]).toMatchObject({
target: "http://127.0.0.1:5174",
changeOrigin: true,
});
});
});

72
src/server/dev.ts Normal file
View file

@ -0,0 +1,72 @@
const webHost = process.env.HOST || "0.0.0.0";
const webPort = process.env.PORT || "5173";
const apiHost = process.env.DASHBOARD_DEV_API_HOST || "127.0.0.1";
const apiPort = process.env.DASHBOARD_DEV_API_PORT || "5174";
const apiTarget = `http://${apiHost}:${apiPort}`;
if (import.meta.main) {
runDevServers();
}
export function runDevServers(): void {
const children: Array<ReturnType<typeof Bun.spawn>> = [];
let shuttingDown = false;
function spawn(
label: string,
command: string[],
env: Record<string, string> = {},
): void {
const child = Bun.spawn(command, {
env: {
...process.env,
...env,
},
stdin: "inherit",
stdout: "inherit",
stderr: "inherit",
});
children.push(child);
void child.exited.then((code) => {
if (shuttingDown) return;
console.error(`${label} exited with status ${code}`);
shutdown(code || 1);
});
}
function shutdown(code = 0): void {
if (shuttingDown) return;
shuttingDown = true;
for (const child of children) {
child.kill();
}
void Promise.allSettled(children.map((child) => child.exited)).then(() => {
process.exit(code);
});
}
process.on("SIGINT", () => shutdown(0));
process.on("SIGTERM", () => shutdown(0));
spawn("api server", [process.execPath, "src/server/index.ts"], {
HOST: apiHost,
PORT: apiPort,
});
spawn("vite dev server", [
process.execPath,
"x",
"vite",
"--host",
webHost,
"--port",
webPort,
], {
DASHBOARD_DEV_API_TARGET: apiTarget,
});
console.info(`Dashboard API proxy target: ${apiTarget}`);
}

View file

@ -2,6 +2,23 @@ import { fileURLToPath } from "node:url";
import tailwindcss from "@tailwindcss/vite";
import react from "@vitejs/plugin-react";
import { defineConfig, configDefaults } from "vitest/config";
import type { UserConfig } from "vite";
export function createDevServerConfig(
env: NodeJS.ProcessEnv = process.env,
): UserConfig["server"] {
const apiTarget = env.DASHBOARD_DEV_API_TARGET;
if (!apiTarget) return undefined;
return {
proxy: {
"/api": {
target: apiTarget,
changeOrigin: true,
},
},
};
}
export default defineConfig({
plugins: [react(), tailwindcss()],
@ -10,6 +27,7 @@ export default defineConfig({
$lib: fileURLToPath(new URL("./src/lib", import.meta.url)),
},
},
server: createDevServerConfig(),
test: {
exclude: [...configDefaults.exclude, "tests/e2e/**"],
},