feat(ui): migrate dashboard runtime to React #28
11 changed files with 196 additions and 8 deletions
11
README.md
11
README.md
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
36
src/App.tsx
36
src/App.tsx
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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"))
|
||||
|
|
|
|||
|
|
@ -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
25
src/server/dev.test.ts
Normal 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
72
src/server/dev.ts
Normal 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}`);
|
||||
}
|
||||
|
|
@ -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/**"],
|
||||
},
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue