diff --git a/apps/web/package.json b/apps/web/package.json index 9592f0f1..09e8952a 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -123,6 +123,7 @@ "babel-plugin-react-compiler": "^1.0.0", "happy-dom": "^20.1.0", "jsdom": "^26.0.0", + "msw": "^2.7.0", "postcss": "^8.5.6", "typescript": "^5", "vite": "^8.0.7", diff --git a/apps/web/src/demo/README.md b/apps/web/src/demo/README.md new file mode 100644 index 00000000..073c916a --- /dev/null +++ b/apps/web/src/demo/README.md @@ -0,0 +1,45 @@ +# Demo agent (MSW prototype) + +The `/demo` route mounts the studio against an in-browser mock of the Go agent +so visitors to stackpanel.com can poke at the UI without a real environment. + +## Files + +| Path | Purpose | +|---|---| +| `fixture.ts` | Frozen demo data: `stack.json` shape, Nix config, entity tables. | +| `handlers.ts` | MSW handlers for the agent REST surface + a Connect-RPC catch-all. | +| `worker.ts` | Lazy `setupWorker(...)` + idempotent `startDemoWorker()`. | +| `token.ts` | Synthesises a non-expiring fake JWT (only decoded, never verified). | +| `../routes/demo.tsx` | `/demo` route: boots the worker, mounts the studio. | + +## One-time bootstrap + +MSW ships its own service-worker script that needs to live in `public/`: + +```bash +cd apps/web +bunx msw init public/ --save +``` + +That command writes `public/mockServiceWorker.js`. Commit it; both dev and +production builds load it from the static path `/mockServiceWorker.js`. + +## What's mocked today + +- `GET /health`, `GET /api/auth/validate` +- `GET /api/events` → 204 (forces fall-back to polling, no SSE stream yet) +- `GET|POST /api/nix/config`, `GET|POST /api/nix/data?entity=*` +- `POST /api/exec` (no-op echo) +- `POST /.` Connect-RPC catch-all returning `{}` + +Everything else falls through (`onUnhandledRequest: "bypass"`); add handlers +as you discover blank panels. + +## Next step: proto-driven mocks + +Long-term these handlers should be generated from `packages/proto/`. A buf +plugin can emit `handlers.gen.ts` — one MSW stub per RPC method, typed off +the same descriptors that drive the Go agent and the TS Connect client — and +this hand-written `handlers.ts` becomes overrides on top of the generated +defaults. diff --git a/apps/web/src/demo/fixture.ts b/apps/web/src/demo/fixture.ts new file mode 100644 index 00000000..6e77bf01 --- /dev/null +++ b/apps/web/src/demo/fixture.ts @@ -0,0 +1,117 @@ +/** + * Frozen fixture data for the demo agent. + * + * Mirrors the shape of `.stack/state/stack.json` plus the entity tables the + * studio reads through the agent's REST surface. Treat it as immutable from + * the handler side; if a handler simulates a write, mutate a shallow clone. + */ + +export const DEMO_HOST = "demo-agent.stackpanel.local"; +export const DEMO_PORT = 9876; +export const DEMO_BASE_URL = `http://${DEMO_HOST}:${DEMO_PORT}`; + +export const demoStateJson = { + version: 1, + projectName: "stackpanel-demo", + basePort: 6400, + paths: { + state: ".stack/state", + gen: ".stack/gen", + data: ".stack", + }, + apps: { + web: { + port: 6402, + domain: "stackpanel-demo.localhost", + url: "http://stackpanel-demo.localhost", + tls: false, + }, + server: { + port: 6401, + domain: null, + url: null, + tls: false, + }, + docs: { + port: 6400, + domain: "docs.stackpanel-demo.localhost", + url: "http://docs.stackpanel-demo.localhost", + tls: false, + }, + }, + services: { + postgres: { + key: "POSTGRES", + name: "PostgreSQL", + port: 6410, + envVar: "STACKPANEL_POSTGRES_PORT", + }, + redis: { + key: "REDIS", + name: "Redis", + port: 6411, + envVar: "STACKPANEL_REDIS_PORT", + }, + minio: { + key: "MINIO", + name: "MinIO", + port: 6412, + envVar: "STACKPANEL_MINIO_PORT", + }, + }, + network: { + step: { enable: false, caUrl: null }, + }, +} as const; + +export const demoNixConfig = { + stack: { + enable: true, + name: "stackpanel-demo", + root: "/home/demo/stackpanel-demo", + apps: demoStateJson.apps, + services: demoStateJson.services, + ports: { basePort: demoStateJson.basePort, modulus: 100 }, + users: { + "demo-user": { + name: "Demo User", + email: "demo@stackpanel.com", + github: "stackpanel-demo", + }, + }, + theme: { palette: "tokyo-night" }, + }, +} as const; + +export const demoEntities: Record> = { + apps: { + web: { name: "web", port: 6402, domain: "stackpanel-demo.localhost" }, + server: { name: "server", port: 6401 }, + docs: { name: "docs", port: 6400, domain: "docs.stackpanel-demo.localhost" }, + }, + services: demoStateJson.services as Record, + users: { + "demo-user": { + name: "Demo User", + email: "demo@stackpanel.com", + github: "stackpanel-demo", + }, + }, + variables: { + NODE_ENV: { value: "development", scope: "all" }, + LOG_LEVEL: { value: "info", scope: "all" }, + }, + tasks: { + dev: { command: "bun run dev", description: "Start dev servers" }, + build: { command: "bun run build", description: "Build all apps" }, + }, + "generated-files": {}, +}; + +export const demoHealth = { + status: "ok", + projectRoot: "/home/demo/stackpanel-demo", + hasProject: true, + agentId: "demo-agent", + version: "demo", +}; diff --git a/apps/web/src/demo/handlers.ts b/apps/web/src/demo/handlers.ts new file mode 100644 index 00000000..ee80da52 --- /dev/null +++ b/apps/web/src/demo/handlers.ts @@ -0,0 +1,103 @@ +/** + * MSW request handlers for the demo agent. + * + * The studio talks to the agent over two protocols, and both terminate as + * `fetch()` calls so MSW can intercept them: + * - REST (AgentHttpClient) GET/POST `${baseUrl}/api/...` + * - Connect-RPC (createAgentTransport) POST `${baseUrl}//` + * + * For now we hand-write a handful of high-traffic REST handlers and a + * Connect-RPC catch-all that returns empty-but-shaped responses. The longer- + * term plan is to generate handler stubs from the same `.proto` files that + * already drive the Go agent and the TS client. + */ + +import { http, HttpResponse, passthrough } from "msw"; +import { + DEMO_BASE_URL, + demoEntities, + demoHealth, + demoNixConfig, + demoStateJson, +} from "./fixture"; + +const url = (path: string) => `${DEMO_BASE_URL}${path}`; + +const ok = (data: T) => HttpResponse.json({ success: true, data }); + +export const demoHandlers = [ + // --------------------------------------------------------------------------- + // Health + auth + // --------------------------------------------------------------------------- + http.get(url("/health"), () => HttpResponse.json(demoHealth)), + http.get(url("/api/auth/validate"), () => + HttpResponse.json({ valid: true, agentId: demoHealth.agentId }), + ), + + // SSE: respond 204 so the EventSource fails fast and the provider falls + // back to polling. Mocking a real event-stream is possible but noisier. + http.get(url("/api/events"), () => new HttpResponse(null, { status: 204 })), + + // --------------------------------------------------------------------------- + // Nix config + entity data + // --------------------------------------------------------------------------- + http.get(url("/api/nix/config"), () => + ok({ + config: demoNixConfig, + last_updated: new Date().toISOString(), + cached: true, + source: "demo", + }), + ), + http.post(url("/api/nix/config"), () => + ok({ + config: demoNixConfig, + last_updated: new Date().toISOString(), + refreshed: true, + source: "demo", + }), + ), + + http.get(url("/api/nix/data"), ({ request }) => { + const entity = new URL(request.url).searchParams.get("entity") ?? ""; + const data = demoEntities[entity]; + return ok({ + entity, + exists: data !== undefined, + data: data ?? null, + }); + }), + http.post(url("/api/nix/data"), () => + HttpResponse.json({ success: true, path: "demo (read-only)" }), + ), + + http.get(url("/api/state"), () => HttpResponse.json(demoStateJson)), + + // --------------------------------------------------------------------------- + // Process / service control: accept and no-op so the UI feels responsive + // --------------------------------------------------------------------------- + http.post(url("/api/exec"), () => + HttpResponse.json({ + success: true, + data: { + exitCode: 0, + stdout: "[demo] command acknowledged (no real execution)\n", + stderr: "", + }, + }), + ), + + // --------------------------------------------------------------------------- + // Connect-RPC catch-all + // + // Connect uses POST to `//`. Returning an + // empty JSON object keeps most queries from throwing; specific methods can + // be peeled off into dedicated handlers as the demo grows. + // --------------------------------------------------------------------------- + http.post(`${DEMO_BASE_URL}/:service/:method`, ({ params }) => { + const service = String(params.service ?? ""); + // Only intercept Connect-style service paths (contain a dot) + if (!service.includes(".")) return passthrough(); + return HttpResponse.json({}); + }), +]; diff --git a/apps/web/src/demo/token.ts b/apps/web/src/demo/token.ts new file mode 100644 index 00000000..970fd111 --- /dev/null +++ b/apps/web/src/demo/token.ts @@ -0,0 +1,30 @@ +/** + * Build a fake JWT for the demo agent. + * + * `AgentProvider` only *decodes* the JWT (it never verifies the signature), + * so a hand-crafted token with valid base64url segments and an `exp` claim in + * the future is enough to flip the studio into a "connected" state. + */ + +function base64UrlEncode(input: string): string { + const base64 = + typeof window === "undefined" + ? Buffer.from(input, "utf-8").toString("base64") + : btoa(input); + return base64.replace(/=+$/, "").replace(/\+/g, "-").replace(/\//g, "_"); +} + +export function buildDemoToken(): string { + const header = base64UrlEncode(JSON.stringify({ alg: "HS256", typ: "JWT" })); + const payload = base64UrlEncode( + JSON.stringify({ + agent_id: "demo-agent", + // year 2099 + exp: 4_070_908_800, + demo: true, + }), + ); + return `${header}.${payload}.demo-signature`; +} + +export const DEMO_TOKEN = buildDemoToken(); diff --git a/apps/web/src/demo/worker.ts b/apps/web/src/demo/worker.ts new file mode 100644 index 00000000..3267c19f --- /dev/null +++ b/apps/web/src/demo/worker.ts @@ -0,0 +1,40 @@ +/** + * Browser-side MSW worker setup. + * + * Lazily imports `msw/browser` so the demo bundle is only pulled in on the + * `/demo` route. `start()` is idempotent and resolves once the service worker + * is intercepting requests. + */ + +import { demoHandlers } from "./handlers"; + +type SetupWorker = Awaited>; + +let workerPromise: Promise | null = null; + +async function loadWorker() { + const { setupWorker } = await import("msw/browser"); + return setupWorker(...demoHandlers); +} + +export async function startDemoWorker(): Promise { + if (typeof window === "undefined") return; + if (!workerPromise) { + workerPromise = loadWorker(); + } + const worker = await workerPromise; + await worker.start({ + // Don't warn about real requests the studio fires (auth, fonts, etc.) + onUnhandledRequest: "bypass", + serviceWorker: { + url: "/mockServiceWorker.js", + }, + }); +} + +export async function stopDemoWorker(): Promise { + if (!workerPromise) return; + const worker = await workerPromise; + worker.stop(); + workerPromise = null; +} diff --git a/apps/web/src/routeTree.gen.ts b/apps/web/src/routeTree.gen.ts index 7ccef4ae..6731ecef 100644 --- a/apps/web/src/routeTree.gen.ts +++ b/apps/web/src/routeTree.gen.ts @@ -12,6 +12,7 @@ import { Route as rootRouteImport } from './routes/__root' import { Route as SuccessRouteImport } from './routes/success' import { Route as StudioRouteImport } from './routes/studio' import { Route as LoginRouteImport } from './routes/login' +import { Route as DemoRouteImport } from './routes/demo' import { Route as DashboardRouteImport } from './routes/dashboard' import { Route as AiRouteImport } from './routes/ai' import { Route as IndexRouteImport } from './routes/index' @@ -64,6 +65,11 @@ const LoginRoute = LoginRouteImport.update({ path: '/login', getParentRoute: () => rootRouteImport, } as any) +const DemoRoute = DemoRouteImport.update({ + id: '/demo', + path: '/demo', + getParentRoute: () => rootRouteImport, +} as any) const DashboardRoute = DashboardRouteImport.update({ id: '/dashboard', path: '/dashboard', @@ -249,6 +255,7 @@ export interface FileRoutesByFullPath { '/': typeof IndexRoute '/ai': typeof AiRoute '/dashboard': typeof DashboardRoute + '/demo': typeof DemoRoute '/login': typeof LoginRoute '/studio': typeof StudioRouteWithChildren '/success': typeof SuccessRoute @@ -290,6 +297,7 @@ export interface FileRoutesByTo { '/': typeof IndexRoute '/ai': typeof AiRoute '/dashboard': typeof DashboardRoute + '/demo': typeof DemoRoute '/login': typeof LoginRoute '/success': typeof SuccessRoute '/api/provision-db': typeof ApiProvisionDbRoute @@ -331,6 +339,7 @@ export interface FileRoutesById { '/': typeof IndexRoute '/ai': typeof AiRoute '/dashboard': typeof DashboardRoute + '/demo': typeof DemoRoute '/login': typeof LoginRoute '/studio': typeof StudioRouteWithChildren '/success': typeof SuccessRoute @@ -374,6 +383,7 @@ export interface FileRouteTypes { | '/' | '/ai' | '/dashboard' + | '/demo' | '/login' | '/studio' | '/success' @@ -415,6 +425,7 @@ export interface FileRouteTypes { | '/' | '/ai' | '/dashboard' + | '/demo' | '/login' | '/success' | '/api/provision-db' @@ -455,6 +466,7 @@ export interface FileRouteTypes { | '/' | '/ai' | '/dashboard' + | '/demo' | '/login' | '/studio' | '/success' @@ -497,6 +509,7 @@ export interface RootRouteChildren { IndexRoute: typeof IndexRoute AiRoute: typeof AiRoute DashboardRoute: typeof DashboardRoute + DemoRoute: typeof DemoRoute LoginRoute: typeof LoginRoute StudioRoute: typeof StudioRouteWithChildren SuccessRoute: typeof SuccessRoute @@ -531,6 +544,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof LoginRouteImport parentRoute: typeof rootRouteImport } + '/demo': { + id: '/demo' + path: '/demo' + fullPath: '/demo' + preLoaderRoute: typeof DemoRouteImport + parentRoute: typeof rootRouteImport + } '/dashboard': { id: '/dashboard' path: '/dashboard' @@ -853,6 +873,7 @@ const rootRouteChildren: RootRouteChildren = { IndexRoute: IndexRoute, AiRoute: AiRoute, DashboardRoute: DashboardRoute, + DemoRoute: DemoRoute, LoginRoute: LoginRoute, StudioRoute: StudioRouteWithChildren, SuccessRoute: SuccessRoute, diff --git a/apps/web/src/routes/demo.tsx b/apps/web/src/routes/demo.tsx new file mode 100644 index 00000000..a9435726 --- /dev/null +++ b/apps/web/src/routes/demo.tsx @@ -0,0 +1,78 @@ +import { createFileRoute } from "@tanstack/react-router"; +import { useEffect, useState } from "react"; +import { DashboardHeader } from "@/components/studio/dashboard-header"; +import { DashboardSidebar } from "@/components/studio/dashboard-sidebar"; +import { OverviewPanel } from "@/components/studio/panels/overview-panel"; +import { + SidebarInset, + SidebarProvider, +} from "@/components/ui/sidebar"; +import { AgentProvider } from "@/lib/agent-provider"; +import { ProjectProvider } from "@/lib/project-provider"; +import { FeatureFlagProvider } from "@gen/featureflags"; +import { DEMO_HOST, DEMO_PORT } from "@/demo/fixture"; +import { DEMO_TOKEN } from "@/demo/token"; +import { startDemoWorker } from "@/demo/worker"; + +export const Route = createFileRoute("/demo")({ + component: DemoLayout, +}); + +function DemoLayout() { + const ready = useDemoWorker(); + + if (!ready) { + return ( +
+ Booting demo agent… +
+ ); + } + + return ( + + + + + + + +
+ + +
+
+
+
+
+
+ ); +} + +function DemoBanner() { + return ( +
+ Demo mode. All agent traffic is intercepted in the + browser by a mock service worker. Nothing here touches a real machine. +
+ ); +} + +function useDemoWorker(): boolean { + const [ready, setReady] = useState(false); + useEffect(() => { + let cancelled = false; + startDemoWorker() + .then(() => { + if (!cancelled) setReady(true); + }) + .catch((err) => { + console.error("[demo] failed to start mock worker", err); + if (!cancelled) setReady(true); // fall through; handlers won't fire + }); + return () => { + cancelled = true; + }; + }, []); + return ready; +} diff --git a/bun.lock b/bun.lock index 4ba66a38..0a711d7e 100644 --- a/bun.lock +++ b/bun.lock @@ -245,6 +245,7 @@ "babel-plugin-react-compiler": "^1.0.0", "happy-dom": "^20.1.0", "jsdom": "^26.0.0", + "msw": "^2.7.0", "postcss": "^8.5.6", "typescript": "^5", "vite": "^8.0.7", @@ -4811,7 +4812,7 @@ "type-detect": ["type-detect@4.0.8", "", {}, "sha512-0fr/mIH1dlO+x7TlcMy+bIDqKPsw/70tVyeHW787goQjhmqaZe10uwLujubK9q9Lg6Fiho1KUKDYz0Z7k7g5/g=="], - "type-fest": ["type-fest@4.41.0", "", {}, "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA=="], + "type-fest": ["type-fest@5.6.0", "", { "dependencies": { "tagged-tag": "^1.0.0" } }, "sha512-8ZiHFm91orbSAe2PSAiSVBVko18pbhbiB3U9GglSzF/zCGkR+rxpHx6sEMCUm4kxY4LjDIUGgCfUMtwfZfjfUA=="], "type-is": ["type-is@2.0.1", "", { "dependencies": { "content-type": "^1.0.5", "media-typer": "^1.1.0", "mime-types": "^3.0.0" } }, "sha512-OZs6gsjF4vMp32qrCbiVSkrFmXtG/AZhY3t0iAMrMBiAZyV9oALtXO8hsrHbMXF9x6L3grlFuwW2oAz7cav+Gw=="], @@ -5641,8 +5642,6 @@ "msw/tough-cookie": ["tough-cookie@6.0.1", "", { "dependencies": { "tldts": "^7.0.5" } }, "sha512-LktZQb3IeoUWB9lqR5EWTHgW/VTITCXg4D21M+lvybRVdylLrRMnqaIONLVb5mav8vM19m44HIcGq4qASeu2Qw=="], - "msw/type-fest": ["type-fest@5.6.0", "", { "dependencies": { "tagged-tag": "^1.0.0" } }, "sha512-8ZiHFm91orbSAe2PSAiSVBVko18pbhbiB3U9GglSzF/zCGkR+rxpHx6sEMCUm4kxY4LjDIUGgCfUMtwfZfjfUA=="], - "msw/yargs": ["yargs@17.7.2", "", { "dependencies": { "cliui": "^8.0.1", "escalade": "^3.1.1", "get-caller-file": "^2.0.5", "require-directory": "^2.1.1", "string-width": "^4.2.3", "y18n": "^5.0.5", "yargs-parser": "^21.1.1" } }, "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w=="], "mysql8/bignumber.js": ["bignumber.js@7.2.1", "", {}, "sha512-S4XzBk5sMB+Rcb/LNcpzXr57VRTxgAvaAEDAl1AwRx27j00hT84O6OkteE7u8UB3NuaaygCRrEpqox4uDOrbdQ=="], @@ -5809,6 +5808,8 @@ "tr46/punycode": ["punycode@2.3.1", "", {}, "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="], + "ts-jest/type-fest": ["type-fest@4.41.0", "", {}, "sha512-TeTSQ6H5YHvpqVwBRcnLDCBnDOHWYu7IvGbHT6N8AOymcr9PJGjc1GTtiWZTYg0NCgYwvnYWEkVChQAr9bjfwA=="], + "ts-node/diff": ["diff@4.0.4", "", {}, "sha512-X07nttJQkwkfKfvTPG/KSnE2OMdcUCao6+eXF3wmnIQRn2aPAHH3VxDbDOdegkd6JbPsXqShpvEOHfAT+nCNwQ=="], "tsx/esbuild": ["esbuild@0.27.7", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.27.7", "@esbuild/android-arm": "0.27.7", "@esbuild/android-arm64": "0.27.7", "@esbuild/android-x64": "0.27.7", "@esbuild/darwin-arm64": "0.27.7", "@esbuild/darwin-x64": "0.27.7", "@esbuild/freebsd-arm64": "0.27.7", "@esbuild/freebsd-x64": "0.27.7", "@esbuild/linux-arm": "0.27.7", "@esbuild/linux-arm64": "0.27.7", "@esbuild/linux-ia32": "0.27.7", "@esbuild/linux-loong64": "0.27.7", "@esbuild/linux-mips64el": "0.27.7", "@esbuild/linux-ppc64": "0.27.7", "@esbuild/linux-riscv64": "0.27.7", "@esbuild/linux-s390x": "0.27.7", "@esbuild/linux-x64": "0.27.7", "@esbuild/netbsd-arm64": "0.27.7", "@esbuild/netbsd-x64": "0.27.7", "@esbuild/openbsd-arm64": "0.27.7", "@esbuild/openbsd-x64": "0.27.7", "@esbuild/openharmony-arm64": "0.27.7", "@esbuild/sunos-x64": "0.27.7", "@esbuild/win32-arm64": "0.27.7", "@esbuild/win32-ia32": "0.27.7", "@esbuild/win32-x64": "0.27.7" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-IxpibTjyVnmrIQo5aqNpCgoACA/dTKLTlhMHihVHhdkxKyPO1uBBthumT0rdHmcsk9uMonIWS0m4FljWzILh3w=="],