// The `dashboard` feature: the operator screen as a standalone vite SPA that // Studio serves per instance (funke's and probe's answer; see views.ts for the // other one). Four different paths name the same thing, and conflating them is // how a dashboard comes to build, pack and launch while serving a 404: // // repo (vite outDir) dashboards// // inside the tar dashboards// // inside the container studio-save-files//dashboards/ // URL /dashboard// // // The rename happens at ctl's mount, not here. The generated template ships // workflow.yml at the tar root, so the workflow is called "workflow" and the // directory below is not a free choice. import type { GeneratedFile, ShapeContext } from "../create-product.ts"; import { EXAMPLE_ID } from "./components.ts"; import { TEST_SETUP_TS, UI_TEST_DEV_DEPENDENCIES, VITEST_CONFIG_TS } from "./ui-tests.ts"; /** The tar ships workflow.yml at its root, so Studio knows the workflow by this name. */ export const WORKFLOW_NAME = "workflow"; export const DASHBOARD_RUNTIME_SCREEN = { url: `/dashboard/${WORKFLOW_NAME}/`, label: "Open dashboard", }; // The starter expose list omits this, which is the whole trap: the dashboard // works on the direct Studio port and 404s through the oauth2 proxy. export const DASHBOARD_PROXY_EXPOSE = "/dashboard/*"; function packageJson(ctx: ShapeContext): string { return `${JSON.stringify( { name: `${ctx.scope}/dashboard`, version: "0.0.1", private: true, type: "module", description: `Per-workflow operator dashboard. Builds into ../dashboards/${WORKFLOW_NAME}, which the backend packs into the product-template tar and Studio serves at /dashboard/${WORKFLOW_NAME}/.`, scripts: { build: "bunx vite build", dev: "bunx vite build --watch", clean: `rm -rf ../dashboards/${WORKFLOW_NAME}`, typecheck: "bunx tsc --noEmit", test: "vitest run", }, dependencies: { react: "^19.0.0", "react-dom": "^19.0.0", }, devDependencies: { "@types/node": "^22.10.0", "@types/react": "^19.0.10", "@types/react-dom": "^19.0.3", "@vitejs/plugin-react": "^6.0.1", typescript: "^5.7.2", vite: "^8.0.0", ...UI_TEST_DEV_DEPENDENCIES, }, }, null, 2, )}\n`; } // vite.config.ts is deliberately OUT of `include`: it imports vite itself, and // the product's `bun run typecheck` should not need the bundler installed to // check the app's own sources. const TSCONFIG = `{ "compilerOptions": { "target": "ESNext", "lib": ["ESNext", "DOM", "DOM.Iterable"], "module": "ESNext", "moduleResolution": "bundler", "strict": true, "esModuleInterop": true, "skipLibCheck": true, "allowImportingTsExtensions": true, "resolveJsonModule": true, "isolatedModules": true, "jsx": "react-jsx", "noEmit": true }, "include": ["src"] } `; const VITE_CONFIG_TS = `import react from "@vitejs/plugin-react"; import { defineConfig } from "vite"; // Builds the operator dashboard into ../dashboards/${WORKFLOW_NAME}, which the // backend packs into the product-template tar and Studio serves at // /dashboard/${WORKFLOW_NAME}/. // // - \`base: "./"\` — the page is served behind a per-instance proxy prefix // (/instance//…/dashboard/${WORKFLOW_NAME}/), so an absolute asset URL // escapes the prefix and loads nothing. // - the outDir name MUST equal the workflow name. The tar ships workflow.yml at // its root, so the workflow is "${WORKFLOW_NAME}". Get this wrong and // everything still builds, packs and launches — the dashboard just 404s. export default defineConfig({ base: "./", plugins: [react()], build: { outDir: "../dashboards/${WORKFLOW_NAME}", emptyOutDir: true, }, }); `; function indexHtml(ctx: ShapeContext): string { return ` ${ctx.name} — Operator
`; } const API_TS = `// The wire contract with the product's Studio components. The dashboard is a // SEPARATE build from components/, so it codes against the HTTP/WS shape rather // than importing the component's types. // // ADDRESSING. The page is served at /instance//…/dashboard/${WORKFLOW_NAME}/, // and it cannot know that prefix at build time. Studio serves an \`env\` document // beside it carrying the resolved bases, so everything here is discovered, never // hardcoded — a root-absolute "/live/api/…" would resolve against the PROXY ROOT // (the norsk-ctl UI) and never reach the instance. export type Env = { studioUrlPrefix?: string; apiBasePath: string; wsBasePath: string }; // The placeholder component's instance id — the /live/api//… path segment. // Mirrors EXAMPLE_ID in shared/src/workflow/ids.ts; duplicated deliberately, // because this is a separate build coding to the wire shape. const COMPONENT_ID = "${EXAMPLE_ID}"; /** Relative fetch: resolves against the directory this page is served from. */ export async function loadEnv(): Promise { const r = await fetch("env"); if (!r.ok) throw new Error(\`env returned HTTP \${r.status}\`); const body = (await r.json()) as Partial; if (!body.apiBasePath || !body.wsBasePath) throw new Error("env missing apiBasePath/wsBasePath"); return body as Env; } export async function apiGet(env: Env, path: string): Promise { const r = await fetch(\`\${env.apiBasePath}/\${COMPONENT_ID}\${path}\`); if (!r.ok) throw new Error(\`GET \${path} -> HTTP \${r.status}\`); return (await r.json()) as T; } export type ExampleState = { ticks: number }; export const getStatus = (env: Env) => apiGet(env, "/status"); /** Studio wraps the state in every message that carries one; events and alerts carry none. */ export function stateFromMessage(msg: unknown): unknown { const m = msg as { type?: string; state?: unknown } | null; if (m && typeof m === "object" && (m.type === "initial-state" || m.type === "state-update")) return m.state; return undefined; } /** * The component's state feed: Studio broadcasts every updates.update() here. * * Studio REFUSES this socket while the component's state is undefined, which is * why the component publishes from its constructor rather than on its first * timer tick. */ export function openStateSocket(env: Env, onState: (state: ExampleState) => void): WebSocket { const socket = new WebSocket(\`\${env.wsBasePath}/\${COMPONENT_ID}/ws\`); socket.onmessage = (event) => { const state = stateFromMessage(JSON.parse(event.data as string)); if (state !== undefined) onState(state as ExampleState); }; return socket; } `; const APP_TSX = `// PLACEHOLDER operator dashboard — it renders the heartbeat the placeholder // component publishes, which is enough to prove the whole path: the tar packed // this page, Studio served it, \`env\` resolved the bases, and the component's // state socket delivered. Replace the body; keep the shape. import { useEffect, useState } from "react"; import { type Env, type ExampleState, openStateSocket } from "./api.ts"; export default function App({ env }: { env: Env }) { const [state, setState] = useState(); useEffect(() => { const socket = openStateSocket(env, setState); return () => socket.close(); }, [env]); return (

Operator

Placeholder dashboard — replace it with this product's operator screen, or drop the dashboard workspace if the product does not need one.

{state === undefined ? "waiting for the component's first state…" : \`\${state.ticks} ticks\`}

); } `; // A stand-in for the component's state socket: what a dashboard test drives // instead of a running Studio. const APP_TEST_TSX = `import { act, render, screen } from "@testing-library/react"; import { afterEach, expect, test, vi } from "vitest"; import App from "./App.tsx"; class FakeSocket { static last: FakeSocket | undefined; onmessage: ((event: { data: string }) => void) | null = null; constructor(readonly url: string) { FakeSocket.last = this; } close() {} deliver(message: unknown) { this.onmessage?.({ data: JSON.stringify(message) }); } } afterEach(() => { vi.unstubAllGlobals(); }); test("shows the component's state as it arrives", () => { vi.stubGlobal("WebSocket", FakeSocket); render(); expect(screen.getByText(/waiting for the component's first state/)).toBeInTheDocument(); act(() => FakeSocket.last?.deliver({ type: "state-update", state: { ticks: 3 } })); expect(screen.getByText("3 ticks")).toBeInTheDocument(); }); `; const MAIN_TSX = `import React from "react"; import ReactDOM from "react-dom/client"; import App from "./App.tsx"; import { loadEnv } from "./api.ts"; import "./index.css"; // env first, everything else after: the bases every request needs are only // knowable at runtime (see api.ts). const env = await loadEnv(); const root = document.getElementById("root"); if (!root) throw new Error("root element missing"); ReactDOM.createRoot(root).render( , ); `; const INDEX_CSS = `:root { color-scheme: light dark; font-family: system-ui, -apple-system, "Segoe UI", sans-serif; } body { margin: 0; padding: 2rem; } .tick { font-variant-numeric: tabular-nums; font-size: 1.5rem; } `; const BACKEND_LIB_DASHBOARDS_TS = `// The built dashboard files for the product-template tar. Studio serves them at // /dashboard// once the template mounts; this backend does not // serve them itself. DASHBOARDS_ROOT is the dashboard/ workspace's vite outDir // (gitignored build output). The root is ours; the reader is // @norskvideo/ctl-sdk's. import path from "node:path"; import { loadDashboardFiles as load } from "@norskvideo/ctl-sdk"; export const DASHBOARDS_ROOT = path.resolve(import.meta.dir, "../../../dashboards"); export const loadDashboardFiles = (root: string = DASHBOARDS_ROOT) => load(root); `; export function dashboardFiles(ctx: ShapeContext): GeneratedFile[] { return [ { path: "dashboard/package.json", content: packageJson(ctx) }, { path: "dashboard/tsconfig.json", content: TSCONFIG }, { path: "dashboard/vite.config.ts", content: VITE_CONFIG_TS }, { path: "dashboard/vitest.config.ts", content: VITEST_CONFIG_TS }, { path: "dashboard/index.html", content: indexHtml(ctx) }, { path: "dashboard/src/main.tsx", content: MAIN_TSX }, { path: "dashboard/src/App.tsx", content: APP_TSX }, { path: "dashboard/src/App.test.tsx", content: APP_TEST_TSX }, { path: "dashboard/src/test/setup.ts", content: TEST_SETUP_TS }, { path: "dashboard/src/api.ts", content: API_TS }, { path: "dashboard/src/index.css", content: INDEX_CSS }, { path: "backend/src/lib/dashboards.ts", content: BACKEND_LIB_DASHBOARDS_TS }, ]; } export const DASHBOARD_GITIGNORE = `# Vite output of the dashboard/ workspace (outDir ../dashboards/${WORKFLOW_NAME}). # Regenerated by \`bun run build:dashboard\`; packed by the backend at runtime. dashboards/ `;