img:first-child]:pt-0 data-[size=sm]:[--card-spacing:--spacing(3)] data-[size=sm]:has-data-[slot=card-footer]:pb-0 *:[img:first-child]:rounded-t-xl *:[img:last-child]:rounded-b-xl",
+ className
+ )}
+ {...props}
+ />
+ )
+}
+
+function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+
+ )
+}
+
+function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+
+ )
+}
+
+function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+
+ )
+}
+
+function CardAction({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+
+ )
+}
+
+function CardContent({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+
+ )
+}
+
+function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+
+ )
+}
+
+export {
+ Card,
+ CardHeader,
+ CardFooter,
+ CardTitle,
+ CardAction,
+ CardDescription,
+ CardContent,
+}
diff --git a/src/lib/components/ui/progress.tsx b/src/lib/components/ui/progress.tsx
new file mode 100644
index 0000000..65d0a6b
--- /dev/null
+++ b/src/lib/components/ui/progress.tsx
@@ -0,0 +1,29 @@
+import * as React from "react"
+import { Progress as ProgressPrimitive } from "radix-ui"
+
+import { cn } from "$lib/utils"
+
+function Progress({
+ className,
+ value,
+ ...props
+}: React.ComponentProps
) {
+ return (
+
+
+
+ )
+}
+
+export { Progress }
diff --git a/src/lib/components/ui/separator.tsx b/src/lib/components/ui/separator.tsx
new file mode 100644
index 0000000..84e3c64
--- /dev/null
+++ b/src/lib/components/ui/separator.tsx
@@ -0,0 +1,28 @@
+"use client"
+
+import * as React from "react"
+import { Separator as SeparatorPrimitive } from "radix-ui"
+
+import { cn } from "$lib/utils"
+
+function Separator({
+ className,
+ orientation = "horizontal",
+ decorative = true,
+ ...props
+}: React.ComponentProps) {
+ return (
+
+ )
+}
+
+export { Separator }
diff --git a/src/lib/components/ui/skeleton.tsx b/src/lib/components/ui/skeleton.tsx
new file mode 100644
index 0000000..61466a8
--- /dev/null
+++ b/src/lib/components/ui/skeleton.tsx
@@ -0,0 +1,13 @@
+import { cn } from "$lib/utils"
+
+function Skeleton({ className, ...props }: React.ComponentProps<"div">) {
+ return (
+
+ )
+}
+
+export { Skeleton }
diff --git a/src/lib/utils.ts b/src/lib/utils.ts
new file mode 100644
index 0000000..bd0c391
--- /dev/null
+++ b/src/lib/utils.ts
@@ -0,0 +1,6 @@
+import { clsx, type ClassValue } from "clsx"
+import { twMerge } from "tailwind-merge"
+
+export function cn(...inputs: ClassValue[]) {
+ return twMerge(clsx(inputs))
+}
diff --git a/src/server/index.ts b/src/server/index.ts
new file mode 100644
index 0000000..860d042
--- /dev/null
+++ b/src/server/index.ts
@@ -0,0 +1,84 @@
+import { extname, normalize } from "node:path";
+import { handleAgentDashboardRoute } from "./routes/agent-dashboard";
+import { handleDashboardRoute } from "./routes/dashboard";
+
+const host = process.env.HOST || "0.0.0.0";
+const port = Number(process.env.PORT || 3000);
+const distRoot = `${process.cwd()}/dist`;
+
+const contentTypes = new Map([
+ [".css", "text/css; charset=utf-8"],
+ [".html", "text/html; charset=utf-8"],
+ [".js", "text/javascript; charset=utf-8"],
+ [".json", "application/json; charset=utf-8"],
+ [".svg", "image/svg+xml"],
+ [".wasm", "application/wasm"],
+]);
+
+export async function handleRequest(request: Request): Promise {
+ const url = new URL(request.url);
+
+ if (url.pathname === "/api/dashboard") {
+ if (request.method !== "GET") return methodNotAllowed(["GET"]);
+ return handleDashboardRoute();
+ }
+
+ if (url.pathname === "/api/agent/dashboard") {
+ if (request.method !== "POST") return methodNotAllowed(["POST"]);
+ return handleAgentDashboardRoute(request);
+ }
+
+ if (url.pathname.startsWith("/api/")) {
+ return Response.json({ ok: false, message: "Not found" }, { status: 404 });
+ }
+
+ return serveStaticAsset(url.pathname);
+}
+
+async function serveStaticAsset(pathname: string): Promise {
+ const safePath = normalize(pathname).replace(/^(\.\.(\/|\\|$))+/, "");
+ const assetPath = safePath === "/" || safePath === "." ? "/index.html" : safePath;
+ const file = Bun.file(`${distRoot}${assetPath}`);
+
+ if (await file.exists()) {
+ return new Response(file, {
+ headers: contentTypeHeaders(assetPath),
+ });
+ }
+
+ const fallback = Bun.file(`${distRoot}/index.html`);
+ if (await fallback.exists()) {
+ return new Response(fallback, {
+ headers: contentTypeHeaders(".html"),
+ });
+ }
+
+ return new Response("Build output not found", { status: 404 });
+}
+
+function methodNotAllowed(allowedMethods: string[]): Response {
+ return Response.json(
+ { ok: false, message: "Method not allowed" },
+ {
+ status: 405,
+ headers: {
+ Allow: allowedMethods.join(", "),
+ },
+ },
+ );
+}
+
+function contentTypeHeaders(pathname: string): HeadersInit {
+ const contentType = contentTypes.get(extname(pathname));
+ return contentType ? { "Content-Type": contentType } : {};
+}
+
+if (import.meta.main) {
+ Bun.serve({
+ hostname: host,
+ port,
+ fetch: handleRequest,
+ });
+
+ console.info(`Dimension Lab website listening on http://${host}:${port}`);
+}
diff --git a/src/server/routes/agent-dashboard.test.ts b/src/server/routes/agent-dashboard.test.ts
new file mode 100644
index 0000000..a3a6022
--- /dev/null
+++ b/src/server/routes/agent-dashboard.test.ts
@@ -0,0 +1,12 @@
+import { describe, expect, test } from "vitest";
+import { handleAgentDashboardRoute } from "./agent-dashboard";
+
+describe("agent dashboard API route", () => {
+ test("delegates unauthorized requests to the existing agent handler", async () => {
+ const response = await handleAgentDashboardRoute(
+ new Request("http://localhost/api/agent/dashboard", { method: "POST" }),
+ );
+
+ expect(response.status).toBe(401);
+ });
+});
diff --git a/src/server/routes/agent-dashboard.ts b/src/server/routes/agent-dashboard.ts
new file mode 100644
index 0000000..1ce77da
--- /dev/null
+++ b/src/server/routes/agent-dashboard.ts
@@ -0,0 +1,5 @@
+import { handleAgentDashboardRequest } from "$lib/server/agent-config";
+
+export function handleAgentDashboardRoute(request: Request): Promise {
+ return handleAgentDashboardRequest(request);
+}
diff --git a/src/server/routes/dashboard.test.ts b/src/server/routes/dashboard.test.ts
new file mode 100644
index 0000000..ebe575b
--- /dev/null
+++ b/src/server/routes/dashboard.test.ts
@@ -0,0 +1,19 @@
+import { describe, expect, test } from "vitest";
+import { dimensionLabDashboardFixture } from "$lib/model/fixtures/dimensionlab";
+import { loadDashboardResponse } from "./dashboard";
+
+describe("dashboard API route", () => {
+ test("returns ready dashboard runtime state from the existing model loader", async () => {
+ const response = await loadDashboardResponse({
+ disableLiveDatasources: true,
+ refreshSeedDocument: true,
+ seedIfEmpty: true,
+ });
+
+ expect(response.state).toBe("ready");
+ if (response.state !== "ready") throw new Error("expected ready dashboard");
+ expect(response.document.metadata.title).toBe(
+ dimensionLabDashboardFixture.metadata.title,
+ );
+ });
+});
diff --git a/src/server/routes/dashboard.ts b/src/server/routes/dashboard.ts
new file mode 100644
index 0000000..21c30e4
--- /dev/null
+++ b/src/server/routes/dashboard.ts
@@ -0,0 +1,41 @@
+import {
+ loadDashboardRuntime,
+ type DashboardRuntimeOptions,
+ type DashboardRuntimeState,
+} from "$lib/server/dashboard";
+import { resolveDashboardDatasources } from "$lib/server/datasources";
+
+export interface LoadDashboardResponseOptions
+ extends Pick<
+ DashboardRuntimeOptions,
+ "refreshSeedDocument" | "seedIfEmpty" | "seedDocument"
+ > {
+ disableLiveDatasources?: boolean;
+}
+
+export async function loadDashboardResponse(
+ options: LoadDashboardResponseOptions = {},
+): Promise {
+ const dashboard = loadDashboardRuntime(undefined, {
+ refreshSeedDocument: options.refreshSeedDocument ?? true,
+ seedIfEmpty: options.seedIfEmpty ?? true,
+ seedDocument: options.seedDocument,
+ });
+
+ if (dashboard.state !== "ready") {
+ return dashboard;
+ }
+
+ if (options.disableLiveDatasources || process.env.DISABLE_LIVE_DATASOURCES === "1") {
+ return dashboard;
+ }
+
+ return {
+ ...dashboard,
+ document: await resolveDashboardDatasources(dashboard.document),
+ };
+}
+
+export async function handleDashboardRoute(): Promise {
+ return Response.json(await loadDashboardResponse());
+}
diff --git a/vite.config.ts b/vite.config.ts
index ef82c8d..4c332c9 100644
--- a/vite.config.ts
+++ b/vite.config.ts
@@ -1,16 +1,17 @@
import { fileURLToPath } from "node:url";
+import tailwindcss from "@tailwindcss/vite";
import react from "@vitejs/plugin-react";
import { svelte } from "@sveltejs/vite-plugin-svelte";
import { defineConfig, configDefaults } from "vitest/config";
export default defineConfig({
- plugins: [react(), svelte()],
+ plugins: [react(), tailwindcss(), svelte()],
resolve: {
alias: {
$lib: fileURLToPath(new URL("./src/lib", import.meta.url)),
},
},
test: {
- exclude: [...configDefaults.exclude, "tests/e2e/**"],
+ exclude: [...configDefaults.exclude, "tests/e2e/**", "src/routes/**"],
},
});