diff --git a/.github/workflows/deploy-demo.yml b/.github/workflows/deploy-demo.yml new file mode 100644 index 00000000..31f9cf99 --- /dev/null +++ b/.github/workflows/deploy-demo.yml @@ -0,0 +1,109 @@ +name: Deploy Demo to Vercel + +# Re-deploys apps/ui to https://nqr-microvm-demo.vercel.app with +# NEXT_PUBLIC_DEMO_MODE=true so the public mocked demo stays in sync with +# every tagged release. +# +# Triggers: +# - any pushed semver tag (vX.Y.Z) — release-time refresh +# - manual run via the Actions UI — ad-hoc refresh +# +# Required repository secret: +# VERCEL_TOKEN — a token created in https://vercel.com/account/tokens scoped +# to the kleopasevans-projects team. +# +# The Vercel org + project IDs aren't secret (they're already in +# apps/ui/.vercel/project.json on the linked dev machine and shown in the +# Vercel dashboard URL), so we keep them inline for clarity. + +on: + push: + tags: + - 'v*.*.*' + workflow_dispatch: + inputs: + target: + description: 'Deployment target' + required: true + default: 'production' + type: choice + options: + - production + - preview + +permissions: + contents: read + +env: + VERCEL_ORG_ID: team_alFtlU7eg7MXyxZQq2Wj0B7z + VERCEL_PROJECT_ID: prj_k7rhdRvqYOhZBQrnKuyXHhnmZamO + TARGET: ${{ github.event.inputs.target || 'production' }} + +jobs: + deploy: + name: Deploy apps/ui (demo) + runs-on: ubuntu-24.04 + timeout-minutes: 20 + defaults: + run: + working-directory: apps/ui + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Install pnpm + uses: pnpm/action-setup@v4 + with: + version: 10 + + - name: Setup Node + uses: actions/setup-node@v4 + with: + node-version: '22' + cache: pnpm + cache-dependency-path: apps/ui/pnpm-lock.yaml + + - name: Install Vercel CLI + run: npm install --global vercel@latest + + - name: Pull Vercel environment + env: + VERCEL_TOKEN: ${{ secrets.VERCEL_TOKEN }} + run: | + if [ "$TARGET" = "production" ]; then + vercel pull --yes --environment=production --token=$VERCEL_TOKEN + else + vercel pull --yes --environment=preview --token=$VERCEL_TOKEN + fi + + - name: Build with Vercel + env: + VERCEL_TOKEN: ${{ secrets.VERCEL_TOKEN }} + run: | + if [ "$TARGET" = "production" ]; then + vercel build --prod --token=$VERCEL_TOKEN + else + vercel build --token=$VERCEL_TOKEN + fi + + - name: Deploy prebuilt artifact + id: deploy + env: + VERCEL_TOKEN: ${{ secrets.VERCEL_TOKEN }} + run: | + if [ "$TARGET" = "production" ]; then + URL=$(vercel deploy --prebuilt --prod --token=$VERCEL_TOKEN) + else + URL=$(vercel deploy --prebuilt --token=$VERCEL_TOKEN) + fi + echo "url=$URL" >> "$GITHUB_OUTPUT" + echo "Deployed to: $URL" + + - name: Summary + run: | + echo "## ✅ Demo deployed" >> "$GITHUB_STEP_SUMMARY" + echo "" >> "$GITHUB_STEP_SUMMARY" + echo "- Target: \`$TARGET\`" >> "$GITHUB_STEP_SUMMARY" + echo "- URL: ${{ steps.deploy.outputs.url }}" >> "$GITHUB_STEP_SUMMARY" + echo "- Stable alias: https://nqr-microvm-demo.vercel.app" >> "$GITHUB_STEP_SUMMARY" diff --git a/apps/ui/.vercelignore b/apps/ui/.vercelignore new file mode 100644 index 00000000..0f4c35b3 --- /dev/null +++ b/apps/ui/.vercelignore @@ -0,0 +1,10 @@ +# Things Vercel doesn't need from the apps/ui directory +node_modules +.next +.turbo +out +dist +*.log + +# Heavy assets we don't ship to demo +content/api/overrides diff --git a/apps/ui/app/(dashboard)/templates/page.tsx b/apps/ui/app/(dashboard)/templates/page.tsx index 9784bd7d..83f00fa9 100644 --- a/apps/ui/app/(dashboard)/templates/page.tsx +++ b/apps/ui/app/(dashboard)/templates/page.tsx @@ -1,6 +1,8 @@ "use client" -import { useEffect } from "react" +export const dynamic = "force-dynamic" + +import { Suspense, useEffect } from "react" import { useSearchParams, useRouter } from "next/navigation" import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card" import { Button } from "@/components/ui/button" @@ -94,6 +96,14 @@ const TemplateFlowDiagram = () => ( ) export default function TemplatesPage() { + return ( + + + + ) +} + +function TemplatesPageInner() { const searchParams = useSearchParams() const router = useRouter() const { data: templates = [], isLoading, error } = useTemplates() diff --git a/apps/ui/app/page.tsx b/apps/ui/app/page.tsx index 83b47123..31750c6a 100644 --- a/apps/ui/app/page.tsx +++ b/apps/ui/app/page.tsx @@ -13,9 +13,10 @@ import { authApi } from "@/lib/api/auth" import { ssoApi, type SsoProvider } from "@/lib/api/sso" import { useAuthStore } from "@/lib/auth/store" import { parseFacadeError } from "@/lib/api" -import { Eye, EyeOff, LogIn } from "lucide-react" +import { Eye, EyeOff, LogIn, Sparkles } from "lucide-react" import { toast } from "sonner" import { apiClient } from "@/lib/api/http" +import { DEMO_MODE } from "@/lib/demo/flag" export default function LandingPage() { const router = useRouter() @@ -150,6 +151,28 @@ export default function LandingPage() { Welcome back! Please enter your credentials to continue. + {DEMO_MODE && ( +
+ +
+

Demo mode

+

+ Sign in with admin /{" "} + admin. All data is simulated. +

+ +
+
+ )}
diff --git a/apps/ui/app/providers.tsx b/apps/ui/app/providers.tsx index 3770e302..da9641be 100644 --- a/apps/ui/app/providers.tsx +++ b/apps/ui/app/providers.tsx @@ -10,6 +10,15 @@ import { AuthProvider, useAuthStore, getAuthToken } from "@/lib/auth/store" import { authApi, setAuthTokenGetter } from "@/lib/api/auth" import { ThemeProvider } from "@/components/theme-provider" import { ThemeSyncProvider } from "@/components/theme-sync-provider" +import { DemoBootstrap } from "@/components/demo/demo-bootstrap" +import { DEMO_MODE } from "@/lib/demo/flag" +import { installDemoMode } from "@/lib/demo/install" + +// Install demo-mode interceptors at module-eval time so they're active before +// any React effect fires (notably AuthInitializer's getCurrentUser call). +if (DEMO_MODE && typeof window !== "undefined") { + installDemoMode() +} function AuthInitializer({ children }: { children: React.ReactNode }) { const { token, user, setUser, clearAuth } = useAuthStore() @@ -22,6 +31,7 @@ function AuthInitializer({ children }: { children: React.ReactNode }) { // Validate token and fetch user on mount if token exists // Only validate if we have token but no user data useEffect(() => { + if (DEMO_MODE) return if (token && !user) { authApi.getCurrentUser() .then((fetchedUser) => { @@ -83,13 +93,19 @@ export function Providers({ children }: { children: React.ReactNode }) { - - - - {children} - - - + {DEMO_MODE ? ( + + {children} + + ) : ( + + + + {children} + + + + )} diff --git a/apps/ui/app/sso/callback/page.tsx b/apps/ui/app/sso/callback/page.tsx index c3d6096a..da5ad811 100644 --- a/apps/ui/app/sso/callback/page.tsx +++ b/apps/ui/app/sso/callback/page.tsx @@ -1,10 +1,20 @@ "use client" -import { useEffect, useRef, useState } from "react" +export const dynamic = "force-dynamic" + +import { Suspense, useEffect, useRef, useState } from "react" import { useRouter, useSearchParams } from "next/navigation" import { useAuthStore } from "@/lib/auth/store" export default function SsoCallbackPage() { + return ( + + + + ) +} + +function SsoCallbackPageInner() { const router = useRouter() const searchParams = useSearchParams() const { setAuth } = useAuthStore() diff --git a/apps/ui/components/demo/demo-bootstrap.tsx b/apps/ui/components/demo/demo-bootstrap.tsx new file mode 100644 index 00000000..8e375750 --- /dev/null +++ b/apps/ui/components/demo/demo-bootstrap.tsx @@ -0,0 +1,65 @@ +"use client" + +// DemoBootstrap mounts at the top of the providers tree when NEXT_PUBLIC_DEMO_MODE +// is enabled. Responsibilities: +// 1. Install the request/WebSocket interceptors before any data hooks fire. +// 2. Show a small floating banner so visitors know the data is synthetic. +// +// Auth: real login flow stays — the user must type admin/admin to enter. The +// mock /auth/login endpoint validates those credentials and rejects anything +// else, so visitors get the same experience as the real product. + +import { useEffect } from "react" +import { DEMO_MODE } from "@/lib/demo/flag" +import { installDemoMode } from "@/lib/demo/install" +import { Sparkles, RotateCcw } from "lucide-react" +import { resetState } from "@/lib/demo/state" + +export function DemoBootstrap({ children }: { children: React.ReactNode }) { + if (!DEMO_MODE) return <>{children} + return {children} +} + +function DemoBootstrapImpl({ children }: { children: React.ReactNode }) { + useEffect(() => { + installDemoMode() + }, []) + + return ( + <> + {children} + + + ) +} + +function DemoBanner() { + const onReset = () => { + resetState() + if (typeof window !== "undefined") { + // Clear the auth state too so visitors are kicked back to the login + // page after a reset — matches the "fresh demo" expectation. + try { + localStorage.removeItem("auth-storage") + } catch { + /* ignore */ + } + window.location.href = "/" + } + } + return ( +
+ + Demo mode — data is simulated + +
+ ) +} diff --git a/apps/ui/lib/demo/flag.ts b/apps/ui/lib/demo/flag.ts new file mode 100644 index 00000000..41e74f36 --- /dev/null +++ b/apps/ui/lib/demo/flag.ts @@ -0,0 +1,11 @@ +// Demo-mode flag — true when the deployment is the public, mocked-data demo. +// Read at build/runtime via NEXT_PUBLIC_DEMO_MODE. +// +// Why a dedicated module: we want a single source of truth that's safe to import +// from anywhere (server components, client components, lib code) without pulling +// React or browser-only state. + +export const DEMO_MODE: boolean = (() => { + const v = process.env.NEXT_PUBLIC_DEMO_MODE + return v === "1" || v === "true" || v === "yes" +})() diff --git a/apps/ui/lib/demo/install.ts b/apps/ui/lib/demo/install.ts new file mode 100644 index 00000000..14a27301 --- /dev/null +++ b/apps/ui/lib/demo/install.ts @@ -0,0 +1,162 @@ +// Install global demo-mode interceptors. Idempotent: safe to call more than +// once per page session. +// +// What it patches: +// 1. `apiClient.request` — short-circuits to the mock router. +// 2. `window.fetch` — for the small set of `fetch(...)` calls inside the app +// that bypass apiClient (avatar upload, image upload, etc.). +// 3. `window.WebSocket` — stubs out shell/metrics/log streams with a fake +// socket that emits canned events. + +import { DEMO_MODE } from "./flag" +import { handleMockRequest } from "./router" +import { apiClient } from "@/lib/api/http" + +const INSTALLED_FLAG = "__nqr_demo_installed__" + +export function installDemoMode() { + if (!DEMO_MODE) return + if (typeof window === "undefined") return + if ((window as any)[INSTALLED_FLAG]) return + ;(window as any)[INSTALLED_FLAG] = true + + patchApiClient() + patchFetch() + patchWebSocket() + + // Make sure the auth/token check inside the SPA never explodes — the + // bootstrap component below also seeds an auth token. We just guard + // against double-init here. +} + +function patchApiClient() { + const original = apiClient.request.bind(apiClient) + ;(apiClient as any).request = async function patched(endpoint: string, options: RequestInit = {}) { + const method = (options.method || "GET").toUpperCase() + let body: any = undefined + if (typeof options.body === "string") { + try { body = JSON.parse(options.body) } catch { body = options.body } + } else if (options.body) { + body = options.body + } + // Simulate a small but non-zero latency so loading states still flash. + await delay(80 + Math.random() * 160) + try { + const result = await handleMockRequest({ method, path: endpoint, body }) + return result as any + } catch (e) { + throw e + } + } + // keep `original` reachable for debugging + ;(apiClient as any)._originalRequest = original +} + +function patchFetch() { + const original = window.fetch.bind(window) + window.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { + const url = typeof input === "string" ? input : (input instanceof URL ? input.toString() : input.url) + const base = apiClient.baseURL + if (url.startsWith(base)) { + const path = url.slice(base.length) + const method = (init?.method || "GET").toUpperCase() + let body: any = undefined + if (init?.body && typeof init.body === "string") { + try { body = JSON.parse(init.body) } catch { body = init.body } + } + await delay(120 + Math.random() * 160) + const data = await handleMockRequest({ method, path, body }) + return new Response(JSON.stringify(data ?? {}), { status: 200, headers: { "Content-Type": "application/json" } }) + } + return original(input as any, init) + }) as any +} + +function patchWebSocket() { + const NativeWS = window.WebSocket + class DemoWebSocket { + url: string + readyState = 0 + onopen: ((e: any) => void) | null = null + onmessage: ((e: any) => void) | null = null + onerror: ((e: any) => void) | null = null + onclose: ((e: any) => void) | null = null + static CONNECTING = 0 + static OPEN = 1 + static CLOSING = 2 + static CLOSED = 3 + private _interval: any = null + private _kind: "metrics" | "logs" | "shell" | "unknown" + constructor(url: string) { + this.url = url + // Classify by URL substring. + if (url.includes("/metrics")) this._kind = "metrics" + else if (url.includes("/logs")) this._kind = "logs" + else if (url.includes("/shell")) this._kind = "shell" + else this._kind = "unknown" + setTimeout(() => { + this.readyState = 1 + this.onopen?.({}) + this._start() + }, 80) + } + _start() { + const emit = (data: any) => this.onmessage?.({ data: typeof data === "string" ? data : JSON.stringify(data) }) + if (this._kind === "metrics") { + this._interval = setInterval(() => { + emit({ + timestamp: new Date().toISOString(), + cpu_usage_percent: 20 + Math.random() * 60, + memory_usage_percent: 30 + Math.random() * 50, + memory_used_kb: 1024 * 1024, + memory_total_kb: 2 * 1024 * 1024, + load_average: 0.5 + Math.random(), + }) + }, 2000) + } else if (this._kind === "logs") { + const samples = [ + "[info] demo mode: log stream is simulated", + "[info] GET /healthz -> 200", + "[debug] cache miss key=user:42", + "[info] worker accepted job #1284", + ] + let i = 0 + this._interval = setInterval(() => { + emit({ + container_id: "demo", + timestamp: new Date().toISOString(), + stream: i % 4 === 3 ? "stderr" : "stdout", + message: samples[i % samples.length], + }) + i++ + }, 1500) + } else if (this._kind === "shell") { + emit("\x1b[1;33mDemo mode\x1b[0m: interactive shell is disabled.\r\n") + emit("Try the live build of NQR-MicroVM to get a real terminal.\r\n$ ") + } + } + send(_: any) { + // ignore in demo mode + } + close() { + this.readyState = 3 + if (this._interval) clearInterval(this._interval) + this.onclose?.({}) + } + addEventListener(name: string, cb: (e: any) => void) { + if (name === "open") this.onopen = cb + else if (name === "message") this.onmessage = cb + else if (name === "error") this.onerror = cb + else if (name === "close") this.onclose = cb + } + removeEventListener() { /* noop */ } + } + // Replace globally so any new WebSocket(...) uses our shim. Keep the native + // class around in case something explicitly references it later. + ;(window as any)._NativeWebSocket = NativeWS + ;(window as any).WebSocket = DemoWebSocket +} + +function delay(ms: number) { + return new Promise((r) => setTimeout(r, ms)) +} diff --git a/apps/ui/lib/demo/router.ts b/apps/ui/lib/demo/router.ts new file mode 100644 index 00000000..224e8f59 --- /dev/null +++ b/apps/ui/lib/demo/router.ts @@ -0,0 +1,712 @@ +// Mock router for demo mode. Pattern-matches (method, path) tuples against the +// in-memory store and returns the same shapes the real manager would. +// +// Coverage strategy: hand-handle the high-traffic endpoints (auth, eula, +// license, vms, containers, networks, volumes, images, templates, hosts, +// functions, users, dashboard, settings, audit, backups, snapshots, storage +// backends). Unmatched endpoints fall through to a safe default that won't +// crash list/detail screens (`{ items: [] }`, `{ item: null }`, etc.). + +import { DEMO_MODE } from "./flag" +import { getState, mutateState, newId, nowIso } from "./state" +import type { Vm, Container, Network, Volume, Template } from "@/lib/types" + +export interface MockRequest { + method: string + path: string // path after the base URL, e.g. "/vms" + body?: any +} + +function ok() { + return { ok: true } +} + +function notFound(): never { + throw new Error( + JSON.stringify({ + error: "Not found", + status: 404, + suggestion: "Demo data does not include this resource", + request_id: "demo", + }), + ) +} + +function asStateVm(req: any): Vm { + const tpl = getState().templates.find((t) => t.id === req.template_id) || getState().templates[0] + const vcpu = req.vcpu ?? tpl?.spec.vcpu ?? 1 + const mem = req.mem_mib ?? tpl?.spec.mem_mib ?? 1024 + const host = getState().hosts[Math.floor(Math.random() * getState().hosts.length)] + const ipSuffix = 100 + Math.floor(Math.random() * 100) + const id = newId("vm") + return { + id, + name: req.name || `demo-${id.slice(-4)}`, + state: "running", + host_id: host.id, + template_id: req.template_id, + host_addr: host.addr, + api_sock: "/srv/fc/sock", + tap: "tap0", + log_path: "/srv/fc/log", + http_port: 9000 + Math.floor(Math.random() * 999), + fc_unit: "firecracker@demo.service", + vcpu, + mem_mib: mem, + kernel_path: "/srv/images/vmlinux-6.1", + rootfs_path: "/srv/images/ubuntu-22.04.ext4", + guest_ip: `172.16.0.${ipSuffix}`, + tags: req.tags ?? [], + cpu_usage_percent: 10 + Math.floor(Math.random() * 60), + memory_usage_percent: 20 + Math.floor(Math.random() * 60), + created_at: nowIso(), + updated_at: nowIso(), + } +} + +function fakeMetricsSeries(now = Date.now(), n = 60, base = 50, jitter = 30) { + return Array.from({ length: n }, (_, i) => { + const t = new Date(now - (n - i) * 60_000).toISOString() + return { recorded_at: t, value: Math.max(0, Math.min(100, base + Math.sin(i / 4) * jitter + (Math.random() - 0.5) * 10)) } + }) +} + +function parseId(path: string, prefix: string): string | null { + const m = path.match(new RegExp(`^${prefix}/([^/?]+)`)) + return m ? m[1] : null +} + +export async function handleMockRequest({ method, path, body }: MockRequest): Promise { + // Strip query string for matching, but keep for filters + const [rawPath, _query] = path.split("?") + const p = rawPath + + // Auth ---------------------------------------------------------------- + if (p === "/auth/login" && method === "POST") { + const username = (body?.username ?? "").toString().trim() + const password = (body?.password ?? "").toString() + // Demo only accepts admin/admin so the login screen feels real. + if (username !== "admin" || password !== "admin") { + throw new Error( + JSON.stringify({ + error: "Invalid credentials", + status: 401, + suggestion: "Use admin / admin to sign in to the demo.", + request_id: "demo", + }), + ) + } + return { + token: "demo-token-" + Math.random().toString(36).slice(2), + user: { + id: "u-demo", + username: "admin", + role: "admin", + email: "admin@nqr-microvm.com", + created_at: nowIso(), + }, + } + } + if (p === "/auth/me" && method === "GET") { + return { id: "u-demo", username: "admin", role: "admin", created_at: nowIso(), timezone: "UTC" } + } + if (p === "/auth/me/avatar" && method === "DELETE") return ok() + if (p === "/auth/me/profile" && method === "PATCH") return ok() + if (p === "/auth/me/password" && method === "POST") return ok() + if (p === "/auth/me/preferences" && method === "GET") { + return { + preferences: { + timezone: "UTC", + theme: "dark", + date_format: "YYYY-MM-DD", + notifications: { email: true, browser: true, desktop: false }, + vm_defaults: { vcpu: 2, mem_mib: 2048, disk_gb: 10 }, + auto_refresh: 30, + metrics_retention: 7, + }, + } + } + if (p === "/auth/me/preferences" && method === "PATCH") return ok() + + // EULA + License + SSO providers (gating endpoints) + if (p === "/admin/eula/status" || p === "/eula/status") { + return { needs_acceptance: false, latest_accepted_version: "1.0.0" } + } + if (p === "/admin/eula/info" || p === "/eula/info") { + return { version: "1.0.0", languages: ["en"] } + } + if (p === "/admin/eula/accept" && method === "POST") return { success: true } + if (p === "/admin/license/status" || p === "/license/status") { + return { + is_licensed: true, + status: "active", + is_grace_period: false, + customer_name: "NQR-MicroVM Demo", + product: "NQR-MicroVM", + features: ["vm", "container", "function", "network", "volume", "snapshot", "backup"], + expires_at: new Date(Date.now() + 365 * 86400_000).toISOString(), + activations: 1, + max_activations: 1000, + verified_at: nowIso(), + license_key: "DEMO-****-****-MODE", + } + } + if (p === "/admin/license/activate" && method === "POST") return { success: true } + if (p === "/admin/license/upload" && method === "POST") return { success: true } + if (p === "/sso/providers" && method === "GET") return { providers: [] } + if (p === "/admin/sso/providers" && method === "GET") return { items: [] } + + // Dashboard ---------------------------------------------------------- + if (p === "/dashboard/stats" || p === "/stats" || p === "/admin/stats") { + const s = getState() + return { + total_vms: s.vms.length, + running_vms: s.vms.filter((v) => v.state === "running").length, + total_functions: s.functions.length, + invocations_24h: s.functions.reduce((a, f) => a + (f.invocation_count_24h ?? 0), 0), + total_containers: s.containers.length, + running_containers: s.containers.filter((c) => c.state === "running").length, + total_hosts: s.hosts.length, + } + } + + // System -------------------------------------------------------------- + if (p === "/admin/db/info" || p === "/db/info") { + return { kind: "postgres", host: "demo-db.internal", port: 5432, database: "nqr", version: "16.2", uptime_seconds: 86400 * 30 } + } + if (p === "/admin/system/stats" || p === "/system/stats") { + return { cpu_count: 32, mem_total_mb: 65536, disk_total_gb: 1024, manager_version: "demo", agent_version: "demo" } + } + + // Audit Logs ---------------------------------------------------------- + if (p === "/admin/audit" || p === "/audit-logs") { + const items = getState().auditLogs + return { items, total: items.length } + } + + // VMs ----------------------------------------------------------------- + if (p === "/vms" && method === "GET") return { items: getState().vms } + if (p === "/vms" && method === "POST") { + const vm = asStateVm(body) + mutateState((s) => s.vms.unshift(vm)) + return { id: vm.id, item: vm } + } + const vmId = parseId(p, "/vms") + if (vmId) { + const isAction = (a: string) => p === `/vms/${vmId}/${a}` + if (p === `/vms/${vmId}` && method === "GET") { + const item = getState().vms.find((v) => v.id === vmId) + if (!item) notFound() + return { item } + } + if (p === `/vms/${vmId}` && method === "PATCH") { + mutateState((s) => { + const v = s.vms.find((x) => x.id === vmId) + if (v && body) { + if (body.name) v.name = body.name + if (body.tags) v.tags = body.tags + v.updated_at = nowIso() + } + }) + return ok() + } + if (p === `/vms/${vmId}` && method === "DELETE") { + mutateState((s) => { + s.vms = s.vms.filter((v) => v.id !== vmId) + }) + return ok() + } + if (isAction("start") || isAction("resume")) { + mutateState((s) => { + const v = s.vms.find((x) => x.id === vmId) + if (v) { v.state = "running"; v.updated_at = nowIso(); v.cpu_usage_percent = 20; v.memory_usage_percent = 40 } + }) + return ok() + } + if (isAction("stop")) { + mutateState((s) => { + const v = s.vms.find((x) => x.id === vmId) + if (v) { v.state = "stopped"; v.updated_at = nowIso(); v.cpu_usage_percent = 0; v.memory_usage_percent = 0; v.guest_ip = "" } + }) + return ok() + } + if (isAction("pause")) { + mutateState((s) => { + const v = s.vms.find((x) => x.id === vmId) + if (v) { v.state = "paused"; v.updated_at = nowIso(); v.cpu_usage_percent = 0 } + }) + return ok() + } + if (isAction("flush-metrics") || isAction("ctrl-alt-del") || isAction("initialize")) return ok() + if (p === `/vms/${vmId}/shell` && method === "GET") return { username: "demo", password: "demo" } + if (p === `/vms/${vmId}/drives` && method === "GET") return { items: getState().drives[vmId] || [] } + if (p === `/vms/${vmId}/drives` && method === "POST") { + const d = { id: newId("drv"), vm_id: vmId, ...body, created_at: nowIso() } + mutateState((s) => { (s.drives[vmId] ||= []).push(d) }) + return d + } + if (p === `/vms/${vmId}/nics` && method === "GET") return { items: getState().nics[vmId] || [] } + if (p === `/vms/${vmId}/nics` && method === "POST") { + const n = { id: newId("nic"), vm_id: vmId, ...body, created_at: nowIso() } + mutateState((s) => { (s.nics[vmId] ||= []).push(n) }) + return n + } + if (p === `/vms/${vmId}/port-forwards` && method === "GET") return { items: getState().portForwards[vmId] || [] } + if (p === `/vms/${vmId}/port-forwards` && method === "POST") { + const pf = { id: newId("pf"), vm_id: vmId, ...body, created_at: nowIso() } + mutateState((s) => { (s.portForwards[vmId] ||= []).push(pf) }) + return pf + } + if (p === `/vms/${vmId}/snapshots` && method === "GET") { + return { items: getState().snapshots.filter((s) => s.vm_id === vmId) } + } + if (p === `/vms/${vmId}/snapshots` && method === "POST") { + const v = getState().vms.find((x) => x.id === vmId) + const snap = { + id: newId("snap"), + vm_id: vmId, + name: body?.name || `snap-${Date.now()}`, + host_addr: v?.host_addr || "10.0.0.11:9090", + mem_size_mib: v?.mem_mib || 1024, + vcpu: v?.vcpu || 1, + kernel_path: v?.kernel_path || "/srv/images/vmlinux-6.1", + rootfs_path: `/srv/snapshots/${vmId}-${Date.now()}.ext4`, + mem_path: `/srv/snapshots/${vmId}-${Date.now()}.mem`, + host_id: v?.host_id, + created_at: nowIso(), + } + mutateState((s) => s.snapshots.unshift(snap as any)) + return { id: snap.id, item: snap } + } + } + + // Snapshots top-level + if (p.startsWith("/snapshots/") && method === "DELETE") { + const id = p.slice("/snapshots/".length) + mutateState((s) => { s.snapshots = s.snapshots.filter((x) => x.id !== id) }) + return ok() + } + + // Containers ---------------------------------------------------------- + if (p === "/containers" && method === "GET") return { items: getState().containers } + if (p === "/containers" && method === "POST") { + const c: Container = { + id: newId("c"), + name: body?.name || "demo-container", + image: body?.image || "alpine:latest", + args: body?.args ?? [], + env_vars: body?.env_vars ?? {}, + volumes: body?.volumes ?? [], + port_mappings: body?.port_mappings ?? [], + restart_policy: body?.restart_policy ?? "no", + state: "running", + cpu_limit: body?.cpu_limit, + memory_limit_mb: body?.memory_limit_mb, + cpu_percent: 5 + Math.floor(Math.random() * 20), + memory_used_mb: 50 + Math.floor(Math.random() * 200), + uptime_seconds: 1, + guest_ip: `172.16.0.${150 + Math.floor(Math.random() * 50)}`, + created_at: nowIso(), + updated_at: nowIso(), + started_at: nowIso(), + } + mutateState((s) => s.containers.unshift(c)) + return { id: c.id } + } + const cId = parseId(p, "/containers") + if (cId) { + const cAct = (a: string) => p === `/containers/${cId}/${a}` + if (p === `/containers/${cId}` && method === "GET") { + const item = getState().containers.find((x) => x.id === cId) + if (!item) notFound() + return { item } + } + if (p === `/containers/${cId}` && method === "PUT") { + mutateState((s) => { const c = s.containers.find((x) => x.id === cId); if (c && body) Object.assign(c, body) }) + const item = getState().containers.find((x) => x.id === cId) + return { item } + } + if (p === `/containers/${cId}` && method === "DELETE") { + mutateState((s) => { s.containers = s.containers.filter((c) => c.id !== cId) }) + return ok() + } + if (cAct("start")) { mutateState((s) => { const c = s.containers.find((x) => x.id === cId); if (c) { c.state = "running"; c.started_at = nowIso() } }); return ok() } + if (cAct("stop")) { mutateState((s) => { const c = s.containers.find((x) => x.id === cId); if (c) { c.state = "stopped"; c.stopped_at = nowIso(); c.cpu_percent = 0; c.memory_used_mb = 0 } }); return ok() } + if (cAct("restart")) { mutateState((s) => { const c = s.containers.find((x) => x.id === cId); if (c) c.state = "running" }); return ok() } + if (cAct("pause")) { mutateState((s) => { const c = s.containers.find((x) => x.id === cId); if (c) c.state = "paused" }); return ok() } + if (cAct("resume")) { mutateState((s) => { const c = s.containers.find((x) => x.id === cId); if (c) c.state = "running" }); return ok() } + if (cAct("exec") && method === "POST") return { stdout: "demo-mode: exec is not available", stderr: "", exit_code: 0 } + if (p.startsWith(`/containers/${cId}/logs`)) { + const samples = [ + "[info] starting service", + "[info] listening on 0.0.0.0:8080", + "[info] accepted connection from 10.0.0.5", + "[debug] request /healthz returned 200", + "[debug] cache miss for key=user:42", + "[info] running gc cycle", + ] + return { + items: samples.map((message, i) => ({ + container_id: cId, + timestamp: new Date(Date.now() - (samples.length - i) * 1500).toISOString(), + stream: i % 5 === 0 ? "stderr" : "stdout", + message, + })), + } + } + if (p === `/containers/${cId}/stats`) { + return { + items: Array.from({ length: 30 }, (_, i) => ({ + cpu_percent: 8 + Math.sin(i / 3) * 6 + Math.random() * 3, + memory_used_mb: 200 + Math.sin(i / 4) * 50, + memory_limit_mb: 1024, + network_rx_bytes: i * 1024 * 16, + network_tx_bytes: i * 1024 * 12, + block_read_bytes: i * 4096, + block_write_bytes: i * 4096, + pids: 12, + recorded_at: new Date(Date.now() - (30 - i) * 60_000).toISOString(), + })), + } + } + } + + // Docker Hub mocks ---------------------------------------------------- + if (p === "/images/dockerhub/search" && method === "POST") { + const q = body?.query || "" + const items = [ + { name: "nginx", description: "Official build of NGINX.", star_count: 19000, is_official: true, is_automated: false, pull_count: 1_000_000_000 }, + { name: "redis", description: "Redis is the world's fastest data platform.", star_count: 12000, is_official: true, is_automated: false, pull_count: 800_000_000 }, + { name: "postgres", description: "The PostgreSQL object-relational database.", star_count: 13000, is_official: true, is_automated: false, pull_count: 1_200_000_000 }, + { name: "node", description: "Node.js JavaScript runtime.", star_count: 11000, is_official: true, is_automated: false, pull_count: 900_000_000 }, + ].filter((x) => !q || x.name.includes(q)) + return { items } + } + if (p === "/images/dockerhub/tags" && method === "POST") { + return { items: ["latest", "alpine", "1.27", "1.26", "stable"].map((tag) => ({ name: tag, last_updated: nowIso(), images: [] })) } + } + if (p.startsWith("/images/dockerhub/download")) return { task_id: "demo-task", status: "completed", progress: 100 } + + // Images / Registry --------------------------------------------------- + if (p === "/images" && method === "GET") return { items: getState().images } + if (p === "/images" && method === "POST") { + const img = { id: newId("img"), kind: body?.kind || "rootfs", name: body?.name || "demo-image", host_path: body?.host_path || "/srv/images/demo", sha256: "demo", size: body?.size || 1_000_000, project: body?.project, created_at: nowIso(), updated_at: nowIso() } + mutateState((s) => s.images.unshift(img)) + return { id: img.id } + } + const imageId = parseId(p, "/images") + if (imageId) { + if (method === "DELETE") { + mutateState((s) => { s.images = s.images.filter((i) => i.id !== imageId) }) + return ok() + } + if (method === "GET") { + const item = getState().images.find((i) => i.id === imageId) + if (item) return { item } + } + } + + // Templates ----------------------------------------------------------- + if (p === "/templates" && method === "GET") return { items: getState().templates } + if (p === "/templates" && method === "POST") { + const t: Template = { + id: newId("tpl"), + name: body?.name || "new-template", + description: body?.description || "", + kernel_path: "/srv/images/vmlinux-6.1", + mem_mib: body?.spec?.mem_mib || 1024, + vcpu: body?.spec?.vcpu || 1, + spec: body?.spec || { vcpu: 1, mem_mib: 1024 }, + created_at: nowIso(), + updated_at: nowIso(), + } + mutateState((s) => s.templates.unshift(t)) + return { id: t.id } + } + const tplId = parseId(p, "/templates") + if (tplId) { + if (method === "DELETE") { mutateState((s) => { s.templates = s.templates.filter((t) => t.id !== tplId) }); return ok() } + if (method === "PUT" || method === "PATCH") { + mutateState((s) => { const t = s.templates.find((x) => x.id === tplId); if (t && body) Object.assign(t, body) }) + const item = getState().templates.find((t) => t.id === tplId) + return { item } + } + if (method === "GET") { + const item = getState().templates.find((t) => t.id === tplId) + if (item) return { item } + } + if (p === `/templates/${tplId}/instantiate` && method === "POST") { + const tpl = getState().templates.find((t) => t.id === tplId) + const vm = asStateVm({ name: body?.name || `from-${tpl?.name}`, template_id: tplId, vcpu: tpl?.spec.vcpu, mem_mib: tpl?.spec.mem_mib }) + mutateState((s) => s.vms.unshift(vm)) + return { id: vm.id, item: vm } + } + } + + // Hosts --------------------------------------------------------------- + if (p === "/hosts" && method === "GET") return { items: getState().hosts } + const hostId = parseId(p, "/hosts") + if (hostId) { + if (method === "GET") { + const item = getState().hosts.find((h) => h.id === hostId) + if (item) return { item } + } + if (method === "DELETE") { mutateState((s) => { s.hosts = s.hosts.filter((h) => h.id !== hostId) }); return ok() } + } + + // Networks ------------------------------------------------------------ + if (p === "/networks" && method === "GET") return { items: getState().networks } + if (p === "/networks" && method === "POST") { + const n: Network = { + id: newId("net"), + name: body?.name || "new-network", + description: body?.description, + type: body?.type || "nat", + bridge_name: `br-${body?.name || Math.random().toString(36).slice(2, 6)}`, + host_id: body?.host_id, + cidr: body?.cidr || "192.168.100.0/24", + gateway: body?.gateway, + status: "active", + managed: true, + dhcp_enabled: body?.dhcp_enabled ?? true, + dhcp_range_start: body?.dhcp_range_start, + dhcp_range_end: body?.dhcp_range_end, + vm_count: 0, + vlan_id: body?.vlan_id, + created_at: nowIso(), + updated_at: nowIso(), + } + mutateState((s) => s.networks.unshift(n)) + return { item: n } + } + if (p === "/networks/suggest") { + return { bridge_name: "fcbr1", cidr: "172.16.50.0/24", gateway: "172.16.50.1", dhcp_range_start: "172.16.50.10", dhcp_range_end: "172.16.50.200" } + } + if (p.match(/^\/hosts\/[^/]+\/interfaces$/)) { + return { + interfaces: [ + { name: "eth0", mac: "52:54:00:12:34:01", state: "up", addresses: ["10.0.0.11/24"], is_management: true }, + { name: "eth1", mac: "52:54:00:12:34:02", state: "up", addresses: [], is_management: false }, + { name: "fcbr0", mac: "52:54:00:12:34:03", state: "up", addresses: ["172.16.0.1/24"], is_management: false, master: "fcbr0" }, + ], + } + } + const netId = parseId(p, "/networks") + if (netId) { + if (p === `/networks/${netId}` && method === "GET") { + const item = getState().networks.find((n) => n.id === netId) + if (item) return { item } + } + if (p === `/networks/${netId}` && method === "PATCH") { + mutateState((s) => { const n = s.networks.find((x) => x.id === netId); if (n && body) Object.assign(n, body) }) + const item = getState().networks.find((n) => n.id === netId) + return { item } + } + if (p === `/networks/${netId}` && method === "DELETE") { + mutateState((s) => { s.networks = s.networks.filter((n) => n.id !== netId) }); return ok() + } + if (p === `/networks/${netId}/vms`) { + const vm_ids = getState().vms.slice(0, Math.min(getState().vms.length, 2)).map((v) => v.id) + return { vm_ids } + } + if (p === `/networks/${netId}/retry` && method === "POST") { + const item = getState().networks.find((n) => n.id === netId) + return { item } + } + } + + // Volumes ------------------------------------------------------------- + if (p === "/volumes" && method === "GET") return { items: getState().volumes } + if (p === "/volumes" && method === "POST") { + const v: Volume = { + id: newId("vol"), + name: body?.name || "new-volume", + description: body?.description, + path: `/srv/volumes/${body?.name || "demo"}.${body?.type || "qcow2"}`, + size_bytes: (body?.size_gb || 10) * 1024 * 1024 * 1024, + size_gb: body?.size_gb || 10, + type: body?.type || "qcow2", + status: "available", + host_id: body?.host_id || getState().hosts[0].id, + host_name: getState().hosts.find((h) => h.id === body?.host_id)?.name, + created_at: nowIso(), + } + mutateState((s) => s.volumes.unshift(v)) + return { item: v } + } + const volId = parseId(p, "/volumes") + if (volId) { + if (p === `/volumes/${volId}` && method === "GET") { + const item = getState().volumes.find((v) => v.id === volId) + if (item) return { item } + } + if (p === `/volumes/${volId}` && method === "DELETE") { + mutateState((s) => { s.volumes = s.volumes.filter((v) => v.id !== volId) }); return ok() + } + if (p === `/volumes/${volId}/attach` && method === "POST") { + mutateState((s) => { + const v = s.volumes.find((x) => x.id === volId) + if (v) { + v.status = "attached" + v.attached_to_vm_id = body?.vm_id + v.attached_to_vm_name = s.vms.find((x) => x.id === body?.vm_id)?.name + } + }) + return ok() + } + if (p === `/volumes/${volId}/detach` && method === "POST") { + mutateState((s) => { + const v = s.volumes.find((x) => x.id === volId) + if (v) { v.status = "available"; v.attached_to_vm_id = undefined; v.attached_to_vm_name = undefined } + }) + return ok() + } + } + + // Functions ----------------------------------------------------------- + if (p === "/functions" && method === "GET") return getState().functions + if (p === "/functions" && method === "POST") { + const f = { + id: newId("fn"), + name: body?.name || "new-function", + runtime: body?.runtime || "javascript", + handler: body?.handler || "index.handler", + timeout_seconds: 30, + code: body?.code || "", + vcpu: body?.vcpu ?? 1, + memory_mb: body?.memory_mb ?? 128, + state: "ready" as const, + created_at: nowIso(), + updated_at: nowIso(), + invocation_count_24h: 0, + avg_duration_ms: 0, + guest_ip: `172.16.0.${180 + Math.floor(Math.random() * 20)}`, + port: 9100 + Math.floor(Math.random() * 100), + } + mutateState((s) => s.functions.unshift(f as any)) + return f + } + const fnId = parseId(p, "/functions") + if (fnId) { + if (p === `/functions/${fnId}` && method === "GET") { + const item = getState().functions.find((f) => f.id === fnId) + if (item) return item + } + if (p === `/functions/${fnId}` && method === "PUT") { + mutateState((s) => { const f = s.functions.find((x) => x.id === fnId); if (f && body) Object.assign(f, body) }) + return getState().functions.find((f) => f.id === fnId) + } + if (p === `/functions/${fnId}` && method === "DELETE") { + mutateState((s) => { s.functions = s.functions.filter((f) => f.id !== fnId) }); return ok() + } + if (p === `/functions/${fnId}/invoke` && method === "POST") { + return { + request_id: newId("req"), + status: "success", + duration_ms: 80 + Math.floor(Math.random() * 200), + response: { ok: true, echo: body?.event ?? null }, + logs: ["[info] cold start: 18ms", "[info] handler returned"], + } + } + if (p.startsWith(`/functions/${fnId}/invocations`)) { + return { items: Array.from({ length: 12 }, (_, i) => ({ + id: newId("inv"), + function_id: fnId, + status: i % 9 === 0 ? "error" : "success", + duration_ms: 60 + Math.floor(Math.random() * 400), + memory_used_mb: 60 + Math.floor(Math.random() * 120), + request_id: newId("req"), + event: { hello: "world" }, + response: { ok: true }, + logs: ["[info] invocation completed"], + invoked_at: new Date(Date.now() - i * 60_000).toISOString(), + })) } + } + } + if (p === "/functions/test" && method === "POST") { + return { request_id: newId("req"), status: "success", duration_ms: 84, response: { ok: true }, logs: ["[info] test invocation"] } + } + + // Users --------------------------------------------------------------- + if ((p === "/admin/users" || p === "/users") && method === "GET") return { items: getState().users } + if ((p === "/admin/users" || p === "/users") && method === "POST") { + const u = { id: newId("u"), username: body?.username || "newuser", role: body?.role || "user", created_at: nowIso() } + mutateState((s) => s.users.unshift(u as any)) + return { id: u.id } + } + const userId = parseId(p, "/admin/users") || parseId(p, "/users") + if (userId && (p.startsWith("/admin/users/") || p.startsWith("/users/"))) { + if (method === "GET") { + const item = getState().users.find((u) => u.id === userId) + if (item) return { item } + } + if (method === "PATCH" || method === "PUT") { + mutateState((s) => { const u = s.users.find((x) => x.id === userId); if (u && body) Object.assign(u, body) }) + return ok() + } + if (method === "DELETE") { + mutateState((s) => { s.users = s.users.filter((u) => u.id !== userId) }); return ok() + } + } + + // Storage backends ---------------------------------------------------- + if ((p === "/admin/storage-backends" || p === "/storage-backends") && method === "GET") { + return { items: getState().storageBackends } + } + if ((p === "/admin/storage-backends" || p === "/storage-backends") && method === "POST") { + const sb = { id: newId("sb"), name: body?.name || "new-backend", kind: body?.kind || "local_file", capabilities: {} as any, is_default: !!body?.is_default, created_at: nowIso() } + mutateState((s) => s.storageBackends.unshift(sb as any)) + return { id: sb.id } + } + // Network scan endpoints (NFS/iSCSI/SMB) + if (p.endsWith("/scan") && (p.includes("nfs") || p.includes("iscsi"))) { + if (p.includes("iscsi")) return { targets: [{ portal: "10.0.0.50:3260", iqn: "iqn.2024-01.com.example:storage1" }] } + return { exports: [{ path: "/exports/nqr", allowed: "10.0.0.0/24" }] } + } + if (p.endsWith("/health") && p.includes("storage")) { + return { reachable: true, status: "ok", used_bytes: 50 * 1024 ** 3, total_bytes: 1024 ** 4 } + } + + // Backup targets ------------------------------------------------------ + if (p === "/admin/backup-targets" || p === "/backup-targets") { + if (method === "GET") return { items: getState().backupTargets } + if (method === "POST") { + const bt = { id: newId("bt"), ...body, created_at: nowIso() } + mutateState((s) => s.backupTargets.unshift(bt as any)) + return bt + } + } + if ((p.startsWith("/admin/backup-targets/") || p.startsWith("/backup-targets/")) && method === "DELETE") { + const id = p.split("/").pop()! + mutateState((s) => { s.backupTargets = s.backupTargets.filter((b) => b.id !== id) }) + return ok() + } + + // Metrics ------------------------------------------------------------- + if (p.includes("/metrics")) { + if (p.startsWith("/hosts/")) { + const hostId = p.split("/")[2] + return fakeMetricsSeries().map((m) => ({ host_id: hostId, recorded_at: m.recorded_at, cpu_usage_percent: m.value, memory_used_mb: 30000 + (m.value * 100), memory_total_mb: 65536, disk_used_gb: 400, disk_total_gb: 1024 })) + } + if (p.startsWith("/vms/")) { + const vmId = p.split("/")[2] + return fakeMetricsSeries(Date.now(), 60, 40, 25).map((m) => ({ vm_id: vmId, recorded_at: m.recorded_at, cpu_usage_percent: m.value, memory_usage_percent: m.value, memory_used_kb: 1024 * 1024, memory_total_kb: 2 * 1024 * 1024, load_average: m.value / 25 })) + } + if (p.startsWith("/containers/")) { + const cid = p.split("/")[2] + return fakeMetricsSeries(Date.now(), 60, 30, 15).map((m) => ({ container_id: cid, recorded_at: m.recorded_at, cpu_percent: m.value, memory_used_mb: m.value * 5, memory_limit_mb: 1024, network_rx_bytes: 1024 * 1024, network_tx_bytes: 1024 * 1024, block_read_bytes: 4096, block_write_bytes: 4096, pids: 8 })) + } + } + + // Generic safe default: list-looking shape vs single-item ------------- + if (method === "GET") { + if (p.endsWith("s") || p.includes("list")) return { items: [], total: 0 } + return { item: null } + } + if (method === "DELETE") return ok() + if (method === "POST" || method === "PUT" || method === "PATCH") return { ok: true, demo: true } + + return null +} + +export { DEMO_MODE } diff --git a/apps/ui/lib/demo/state.ts b/apps/ui/lib/demo/state.ts new file mode 100644 index 00000000..3d381063 --- /dev/null +++ b/apps/ui/lib/demo/state.ts @@ -0,0 +1,500 @@ +// Seed state + in-memory store for demo mode. All data lives in module scope +// so it persists across the SPA session; localStorage is used to keep it across +// reloads. + +import type { + Vm, + Container, + Network, + Volume, + Image, + Template, + Host, + Function as Fn, + User, + StorageBackend, + Snapshot, + AuditLog, + BackupTarget, +} from "@/lib/types" + +const STORAGE_KEY = "nqr-demo-state-v1" + +function iso(daysAgo = 0, hoursAgo = 0): string { + const d = new Date() + d.setDate(d.getDate() - daysAgo) + d.setHours(d.getHours() - hoursAgo) + return d.toISOString() +} + +function seedHosts(): Host[] { + return [ + { + id: "host-aurora", + name: "aurora-01", + addr: "10.0.0.11:9090", + status: "healthy", + capabilities_json: { bridge: "fcbr0", run_dir: "/srv/fc", cpus: 32, total_memory_mb: 131072, total_disk_gb: 2048, used_disk_gb: 612 }, + total_cpus: 32, + total_memory_mb: 131072, + total_disk_gb: 2048, + used_disk_gb: 612, + vm_count: 4, + last_seen_at: iso(0, 0), + last_metrics_at: iso(0, 0), + }, + { + id: "host-borealis", + name: "borealis-02", + addr: "10.0.0.12:9090", + status: "healthy", + capabilities_json: { bridge: "fcbr0", run_dir: "/srv/fc", cpus: 64, total_memory_mb: 262144, total_disk_gb: 4096, used_disk_gb: 1320 }, + total_cpus: 64, + total_memory_mb: 262144, + total_disk_gb: 4096, + used_disk_gb: 1320, + vm_count: 7, + last_seen_at: iso(0, 0), + last_metrics_at: iso(0, 0), + }, + { + id: "host-corona", + name: "corona-03", + addr: "10.0.0.13:9090", + status: "degraded", + capabilities_json: { bridge: "fcbr0", run_dir: "/srv/fc", cpus: 32, total_memory_mb: 131072, total_disk_gb: 2048, used_disk_gb: 1980 }, + total_cpus: 32, + total_memory_mb: 131072, + total_disk_gb: 2048, + used_disk_gb: 1980, + vm_count: 2, + last_seen_at: iso(0, 1), + last_metrics_at: iso(0, 1), + }, + ] +} + +function seedImages(): Image[] { + return [ + { id: "img-vmlinux", kind: "kernel", name: "vmlinux-6.1", host_path: "/srv/images/vmlinux-6.1", sha256: "a1b2c3", size: 13631488, created_at: iso(30), updated_at: iso(30) }, + { id: "img-ubuntu-22", kind: "rootfs", name: "ubuntu-22.04", host_path: "/srv/images/ubuntu-22.04.ext4", sha256: "b2c3d4", size: 1610612736, created_at: iso(28), updated_at: iso(28) }, + { id: "img-ubuntu-24", kind: "rootfs", name: "ubuntu-24.04", host_path: "/srv/images/ubuntu-24.04.ext4", sha256: "c3d4e5", size: 1879048192, created_at: iso(14), updated_at: iso(14) }, + { id: "img-alpine", kind: "rootfs", name: "alpine-3.20", host_path: "/srv/images/alpine-3.20.ext4", sha256: "d4e5f6", size: 268435456, created_at: iso(21), updated_at: iso(21) }, + { id: "img-debian-12", kind: "rootfs", name: "debian-12", host_path: "/srv/images/debian-12.ext4", sha256: "e5f607", size: 1342177280, created_at: iso(20), updated_at: iso(20) }, + { id: "img-runtime", kind: "rootfs", name: "container-runtime", host_path: "/srv/images/container-runtime.ext4", sha256: "f60718", size: 524288000, project: "system", created_at: iso(60), updated_at: iso(60) }, + ] +} + +function seedTemplates(): Template[] { + return [ + { id: "tpl-small", name: "small", description: "1 vCPU · 1 GiB", kernel_path: "/srv/images/vmlinux-6.1", mem_mib: 1024, vcpu: 1, spec: { vcpu: 1, mem_mib: 1024, kernel_image_id: "img-vmlinux", rootfs_image_id: "img-ubuntu-22" }, created_at: iso(40), updated_at: iso(40) }, + { id: "tpl-medium", name: "medium", description: "2 vCPU · 2 GiB", kernel_path: "/srv/images/vmlinux-6.1", mem_mib: 2048, vcpu: 2, spec: { vcpu: 2, mem_mib: 2048, kernel_image_id: "img-vmlinux", rootfs_image_id: "img-ubuntu-22" }, created_at: iso(40), updated_at: iso(40) }, + { id: "tpl-large", name: "large", description: "4 vCPU · 4 GiB", kernel_path: "/srv/images/vmlinux-6.1", mem_mib: 4096, vcpu: 4, spec: { vcpu: 4, mem_mib: 4096, kernel_image_id: "img-vmlinux", rootfs_image_id: "img-ubuntu-24" }, created_at: iso(40), updated_at: iso(40) }, + { id: "tpl-xlarge", name: "xlarge", description: "8 vCPU · 16 GiB", kernel_path: "/srv/images/vmlinux-6.1", mem_mib: 16384, vcpu: 8, spec: { vcpu: 8, mem_mib: 16384, kernel_image_id: "img-vmlinux", rootfs_image_id: "img-ubuntu-24" }, created_at: iso(40), updated_at: iso(40) }, + ] +} + +function vmDefaults(): Partial { + return { + host_addr: "10.0.0.11:9090", + api_sock: "/srv/fc/sock", + tap: "tap0", + log_path: "/srv/fc/log", + fc_unit: "firecracker@1.service", + kernel_path: "/srv/images/vmlinux-6.1", + rootfs_path: "/srv/images/ubuntu-22.04.ext4", + tags: [], + } +} + +function seedVms(): Vm[] { + const base = vmDefaults() + return [ + { + ...(base as Vm), + id: "vm-web-01", + name: "web-01", + state: "running", + host_id: "host-aurora", + template_id: "tpl-medium", + http_port: 8001, + vcpu: 2, + mem_mib: 2048, + guest_ip: "172.16.0.21", + tags: ["prod", "web"], + cpu_usage_percent: 32, + memory_usage_percent: 48, + created_at: iso(10), + updated_at: iso(0, 2), + }, + { + ...(base as Vm), + id: "vm-web-02", + name: "web-02", + state: "running", + host_id: "host-aurora", + template_id: "tpl-medium", + http_port: 8002, + vcpu: 2, + mem_mib: 2048, + guest_ip: "172.16.0.22", + tags: ["prod", "web"], + cpu_usage_percent: 28, + memory_usage_percent: 52, + created_at: iso(10), + updated_at: iso(0, 2), + }, + { + ...(base as Vm), + id: "vm-api-01", + name: "api-01", + state: "running", + host_id: "host-borealis", + template_id: "tpl-large", + http_port: 8101, + vcpu: 4, + mem_mib: 4096, + guest_ip: "172.16.0.31", + tags: ["prod", "api"], + cpu_usage_percent: 64, + memory_usage_percent: 71, + created_at: iso(8), + updated_at: iso(0, 1), + }, + { + ...(base as Vm), + id: "vm-worker-01", + name: "worker-01", + state: "running", + host_id: "host-borealis", + template_id: "tpl-large", + http_port: 8201, + vcpu: 4, + mem_mib: 4096, + guest_ip: "172.16.0.41", + tags: ["prod", "worker"], + cpu_usage_percent: 81, + memory_usage_percent: 62, + created_at: iso(5), + updated_at: iso(0, 0), + }, + { + ...(base as Vm), + id: "vm-db-staging", + name: "db-staging", + state: "stopped", + host_id: "host-borealis", + template_id: "tpl-xlarge", + http_port: 8301, + vcpu: 8, + mem_mib: 16384, + guest_ip: "", + tags: ["staging", "db"], + cpu_usage_percent: 0, + memory_usage_percent: 0, + created_at: iso(20), + updated_at: iso(2), + }, + { + ...(base as Vm), + id: "vm-ci-runner", + name: "ci-runner", + state: "paused", + host_id: "host-aurora", + template_id: "tpl-small", + http_port: 8401, + vcpu: 1, + mem_mib: 1024, + guest_ip: "172.16.0.51", + tags: ["dev", "ci"], + cpu_usage_percent: 0, + memory_usage_percent: 24, + created_at: iso(3), + updated_at: iso(0, 3), + }, + ] +} + +function seedContainers(): Container[] { + return [ + { + id: "c-nginx-01", + name: "nginx-edge", + image: "nginx:1.27-alpine", + args: [], + env_vars: { TZ: "UTC" }, + volumes: [], + port_mappings: [{ host: 8080, container: 80, protocol: "tcp" }], + restart_policy: "unless-stopped", + state: "running", + cpu_limit: 1, + memory_limit_mb: 512, + cpu_percent: 4, + memory_used_mb: 84, + uptime_seconds: 86400 * 3, + guest_ip: "172.16.0.61", + created_at: iso(7), + updated_at: iso(0, 1), + started_at: iso(3), + }, + { + id: "c-redis", + name: "redis-cache", + image: "redis:7-alpine", + args: [], + env_vars: {}, + volumes: [], + port_mappings: [{ host: 6379, container: 6379, protocol: "tcp" }], + restart_policy: "unless-stopped", + state: "running", + cpu_limit: 1, + memory_limit_mb: 1024, + cpu_percent: 12, + memory_used_mb: 312, + uptime_seconds: 86400 * 5, + guest_ip: "172.16.0.62", + created_at: iso(8), + updated_at: iso(0, 1), + started_at: iso(5), + }, + { + id: "c-postgres", + name: "pg-primary", + image: "postgres:16", + args: [], + env_vars: { POSTGRES_PASSWORD: "•••••••", POSTGRES_DB: "app" }, + volumes: [{ host: "/srv/data/pg", container: "/var/lib/postgresql/data" }], + port_mappings: [{ host: 5432, container: 5432, protocol: "tcp" }], + restart_policy: "always", + state: "running", + cpu_limit: 2, + memory_limit_mb: 4096, + cpu_percent: 18, + memory_used_mb: 1842, + uptime_seconds: 86400 * 12, + guest_ip: "172.16.0.63", + created_at: iso(14), + updated_at: iso(0, 1), + started_at: iso(12), + }, + { + id: "c-grafana", + name: "grafana", + image: "grafana/grafana:latest", + args: [], + env_vars: {}, + volumes: [], + port_mappings: [{ host: 3000, container: 3000, protocol: "tcp" }], + restart_policy: "unless-stopped", + state: "stopped", + cpu_percent: 0, + memory_used_mb: 0, + created_at: iso(2), + updated_at: iso(1), + }, + ] +} + +function seedNetworks(): Network[] { + return [ + { + id: "net-default", + name: "default-nat", + description: "Default NAT network for VMs", + type: "nat", + bridge_name: "fcbr0", + host_id: "host-aurora", + host_name: "aurora-01", + cidr: "172.16.0.0/24", + gateway: "172.16.0.1", + status: "active", + managed: true, + dhcp_enabled: true, + dhcp_range_start: "172.16.0.10", + dhcp_range_end: "172.16.0.200", + vm_count: 6, + created_at: iso(60), + updated_at: iso(0), + }, + { + id: "net-prod", + name: "prod-bridged", + description: "Bridged to physical eth0", + type: "bridged", + bridge_name: "br-prod", + host_id: "host-borealis", + host_name: "borealis-02", + cidr: "10.10.0.0/24", + gateway: "10.10.0.1", + status: "active", + managed: true, + dhcp_enabled: false, + vm_count: 3, + created_at: iso(30), + updated_at: iso(2), + }, + { + id: "net-isolated", + name: "ci-isolated", + description: "Isolated CI/CD network", + type: "isolated", + bridge_name: "br-iso", + host_id: "host-aurora", + host_name: "aurora-01", + cidr: "192.168.10.0/24", + gateway: "192.168.10.1", + status: "active", + managed: true, + dhcp_enabled: true, + vm_count: 1, + created_at: iso(7), + updated_at: iso(0), + }, + ] +} + +function seedVolumes(): Volume[] { + return [ + { id: "vol-pg-data", name: "pg-data", description: "Postgres data volume", path: "/srv/volumes/pg-data.qcow2", size_bytes: 53687091200, size_gb: 50, type: "qcow2", status: "attached", host_id: "host-borealis", host_name: "borealis-02", attached_to_vm_id: "vm-api-01", attached_to_vm_name: "api-01", created_at: iso(14) }, + { id: "vol-logs", name: "shared-logs", description: "Centralized log volume", path: "/srv/volumes/logs.ext4", size_bytes: 21474836480, size_gb: 20, type: "ext4", status: "available", host_id: "host-aurora", host_name: "aurora-01", created_at: iso(7) }, + { id: "vol-cache", name: "build-cache", description: "CI build cache", path: "/srv/volumes/cache.raw", size_bytes: 107374182400, size_gb: 100, type: "raw", status: "attached", host_id: "host-aurora", host_name: "aurora-01", attached_to_vm_id: "vm-ci-runner", attached_to_vm_name: "ci-runner", created_at: iso(3) }, + { id: "vol-snapshots", name: "snapshot-store", description: "VM snapshot vault", path: "/srv/volumes/snapshots.qcow2", size_bytes: 214748364800, size_gb: 200, type: "qcow2", status: "available", host_id: "host-corona", host_name: "corona-03", created_at: iso(45) }, + ] +} + +function seedFunctions(): Fn[] { + return [ + { id: "fn-resize-img", name: "resize-image", runtime: "javascript", handler: "index.handler", timeout_seconds: 30, code: "export const handler = async (e) => ({ ok: true, in: e })", vcpu: 1, memory_mb: 256, state: "ready", invocation_count_24h: 1284, avg_duration_ms: 142, last_invoked_at: iso(0, 0), created_at: iso(14), updated_at: iso(2), guest_ip: "172.16.0.81", port: 9100 }, + { id: "fn-webhook", name: "github-webhook", runtime: "python", handler: "main.handler", timeout_seconds: 10, code: "def handler(event):\n return { 'ok': True }", vcpu: 1, memory_mb: 128, state: "ready", invocation_count_24h: 386, avg_duration_ms: 68, last_invoked_at: iso(0, 1), created_at: iso(7), updated_at: iso(1), guest_ip: "172.16.0.82", port: 9101 }, + { id: "fn-classify", name: "classify-event", runtime: "typescript", handler: "index.handler", timeout_seconds: 60, code: "export const handler = async (e) => ({ class: 'A' })", vcpu: 2, memory_mb: 512, state: "ready", invocation_count_24h: 52, avg_duration_ms: 412, last_invoked_at: iso(0, 2), created_at: iso(3), updated_at: iso(0, 4), guest_ip: "172.16.0.83", port: 9102 }, + ] +} + +function seedUsers(): User[] { + return [ + { id: "u-root", username: "root", role: "admin", created_at: iso(120), last_login_at: iso(0, 0), timezone: "UTC", theme: "dark" }, + { id: "u-alice", username: "alice", role: "admin", created_at: iso(80), last_login_at: iso(1), timezone: "Europe/Berlin", theme: "dark" }, + { id: "u-bob", username: "bob", role: "user", created_at: iso(45), last_login_at: iso(0, 3), timezone: "America/New_York", theme: "light" }, + { id: "u-carol", username: "carol", role: "viewer", created_at: iso(15), last_login_at: iso(2), timezone: "Asia/Singapore", theme: "system" }, + ] +} + +function seedStorageBackends(): StorageBackend[] { + return [ + { id: "sb-local", name: "Local Disk", kind: "local_file", capabilities: {} as any, is_default: true, created_at: iso(120) }, + { id: "sb-nfs", name: "NAS-NFS", kind: "nfs", capabilities: {} as any, is_default: false, created_at: iso(60) }, + { id: "sb-iscsi", name: "Pure-iSCSI", kind: "iscsi", capabilities: {} as any, is_default: false, created_at: iso(30) }, + ] +} + +function seedBackupTargets(): BackupTarget[] { + return [ + { id: "bt-s3", name: "AWS S3 (us-east-1)", endpoint: "https://s3.us-east-1.amazonaws.com", region: "us-east-1", bucket: "nqr-backups", prefix: "prod/", access_key_id: "AKIA••••••EXAMPLE", gc_hour: 3, created_at: iso(40) }, + { id: "bt-minio", name: "MinIO (on-prem)", endpoint: "https://minio.internal:9000", bucket: "nqr-backups", prefix: "lab/", access_key_id: "minio-admin", gc_hour: 4, created_at: iso(15) }, + ] +} + +function seedSnapshots(): Snapshot[] { + return [ + { id: "snap-1", vm_id: "vm-web-01", name: "pre-deploy", host_addr: "10.0.0.11:9090", mem_size_mib: 2048, vcpu: 2, kernel_path: "/srv/images/vmlinux-6.1", rootfs_path: "/srv/snapshots/web-01-1.ext4", mem_path: "/srv/snapshots/web-01-1.mem", host_id: "host-aurora", created_at: iso(2) } as Snapshot, + { id: "snap-2", vm_id: "vm-api-01", name: "v1.4-baseline", host_addr: "10.0.0.12:9090", mem_size_mib: 4096, vcpu: 4, kernel_path: "/srv/images/vmlinux-6.1", rootfs_path: "/srv/snapshots/api-01-2.ext4", mem_path: "/srv/snapshots/api-01-2.mem", host_id: "host-borealis", created_at: iso(5) } as Snapshot, + ] +} + +function seedAuditLogs(): AuditLog[] { + const rows: AuditLog[] = [ + { id: "al-1", user_id: "u-root", username: "root", action: "vm.start", resource_type: "vm", resource_id: "vm-web-01", details: {}, ip_address: "10.0.0.4", success: true, error_message: null, created_at: iso(0, 1) }, + { id: "al-2", user_id: "u-alice", username: "alice", action: "vm.create", resource_type: "vm", resource_id: "vm-worker-01", details: { template: "tpl-large" }, ip_address: "10.0.0.6", success: true, error_message: null, created_at: iso(5) }, + { id: "al-3", user_id: "u-bob", username: "bob", action: "container.deploy", resource_type: "container", resource_id: "c-nginx-01", details: {}, ip_address: "10.0.0.7", success: true, error_message: null, created_at: iso(0, 4) }, + { id: "al-4", user_id: "u-root", username: "root", action: "user.login", resource_type: "user", resource_id: "u-root", details: {}, ip_address: "10.0.0.4", success: true, error_message: null, created_at: iso(0, 0) }, + { id: "al-5", user_id: "u-carol", username: "carol", action: "vm.view", resource_type: "vm", resource_id: "vm-api-01", details: {}, ip_address: "10.0.0.9", success: true, error_message: null, created_at: iso(0, 2) }, + ] + return rows +} + +export type DemoStateShape = { + vms: Vm[] + containers: Container[] + networks: Network[] + volumes: Volume[] + images: Image[] + templates: Template[] + hosts: Host[] + functions: Fn[] + users: User[] + storageBackends: StorageBackend[] + snapshots: Snapshot[] + auditLogs: AuditLog[] + backupTargets: BackupTarget[] + // Per-VM sub-resources are stored by parent id for simplicity. + drives: Record + nics: Record + portForwards: Record +} + +function fresh(): DemoStateShape { + return { + vms: seedVms(), + containers: seedContainers(), + networks: seedNetworks(), + volumes: seedVolumes(), + images: seedImages(), + templates: seedTemplates(), + hosts: seedHosts(), + functions: seedFunctions(), + users: seedUsers(), + storageBackends: seedStorageBackends(), + snapshots: seedSnapshots(), + auditLogs: seedAuditLogs(), + backupTargets: seedBackupTargets(), + drives: {}, + nics: {}, + portForwards: {}, + } +} + +let _state: DemoStateShape | null = null + +function load(): DemoStateShape { + if (typeof window === "undefined") return fresh() + try { + const raw = localStorage.getItem(STORAGE_KEY) + if (raw) return JSON.parse(raw) as DemoStateShape + } catch { + // fall through + } + return fresh() +} + +function persist() { + if (typeof window === "undefined" || !_state) return + try { + localStorage.setItem(STORAGE_KEY, JSON.stringify(_state)) + } catch { + // quota etc — ignore, demo data is rebuildable + } +} + +export function getState(): DemoStateShape { + if (!_state) _state = load() + return _state +} + +export function mutateState(fn: (s: DemoStateShape) => void) { + if (!_state) _state = load() + fn(_state) + persist() +} + +export function resetState() { + _state = fresh() + persist() +} + +export function newId(prefix: string): string { + return `${prefix}-${Math.random().toString(36).slice(2, 8)}` +} + +export function nowIso(): string { + return new Date().toISOString() +} diff --git a/apps/ui/next.config.mjs b/apps/ui/next.config.mjs index 0013134d..3109733e 100644 --- a/apps/ui/next.config.mjs +++ b/apps/ui/next.config.mjs @@ -1,6 +1,11 @@ /** @type {import('next').NextConfig} */ +// Standalone output is for self-hosted runtimes (the manager bundles +// .next/standalone). Vercel uses its own serverless runtime and emits a +// warning when output: standalone is set, so we disable it there. +const isVercel = !!process.env.VERCEL + const nextConfig = { - output: "standalone", + ...(isVercel ? {} : { output: "standalone" }), typescript: { ignoreBuildErrors: true, }, diff --git a/apps/ui/package.json b/apps/ui/package.json index 961d67d1..54d9c836 100644 --- a/apps/ui/package.json +++ b/apps/ui/package.json @@ -57,7 +57,7 @@ "input-otp": "1.4.1", "lucide-react": "^0.454.0", "monaco-editor": "latest", - "next": "15.5.6", + "next": "15.5.18", "next-mdx-remote": "^6.0.0", "next-themes": "^0.4.6", "react": "19.2.0", diff --git a/apps/ui/pnpm-lock.yaml b/apps/ui/pnpm-lock.yaml index 701ce25e..69421e7e 100644 --- a/apps/ui/pnpm-lock.yaml +++ b/apps/ui/pnpm-lock.yaml @@ -103,7 +103,7 @@ importers: version: 5.90.2(@tanstack/react-query@5.90.5(react@19.2.0))(react@19.2.0) '@vercel/analytics': specifier: 1.3.1 - version: 1.3.1(next@15.5.6(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(react@19.2.0) + version: 1.3.1(next@15.5.18(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(react@19.2.0) '@xterm/addon-fit': specifier: ^0.10.0 version: 0.10.0(@xterm/xterm@5.5.0) @@ -144,8 +144,8 @@ importers: specifier: latest version: 0.54.0 next: - specifier: 15.5.6 - version: 15.5.6(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + specifier: 15.5.18 + version: 15.5.18(react-dom@19.2.0(react@19.2.0))(react@19.2.0) next-mdx-remote: specifier: ^6.0.0 version: 6.0.0(@types/react@19.2.2)(react@19.2.0) @@ -447,57 +447,57 @@ packages: react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 - '@next/env@15.5.6': - resolution: {integrity: sha512-3qBGRW+sCGzgbpc5TS1a0p7eNxnOarGVQhZxfvTdnV0gFI61lX7QNtQ4V1TSREctXzYn5NetbUsLvyqwLFJM6Q==} + '@next/env@15.5.18': + resolution: {integrity: sha512-hAV85Ckd9QR6RvH04MEKwsfLTksvFpO47j9xwtoIuvuPnlwecpSi+uZTtm8HirVbtlI2Fnz//xpcSTjFdyJk+g==} - '@next/swc-darwin-arm64@15.5.6': - resolution: {integrity: sha512-ES3nRz7N+L5Umz4KoGfZ4XX6gwHplwPhioVRc25+QNsDa7RtUF/z8wJcbuQ2Tffm5RZwuN2A063eapoJ1u4nPg==} + '@next/swc-darwin-arm64@15.5.18': + resolution: {integrity: sha512-w0WvQf1n+txiwns/9pwIQteCJpZTbxzO2SE0FLcwuD4v0WEh1JPOjdyxWL21XwJsdpx8cFRjyzxzCS/siP7HcQ==} engines: {node: '>= 10'} cpu: [arm64] os: [darwin] - '@next/swc-darwin-x64@15.5.6': - resolution: {integrity: sha512-JIGcytAyk9LQp2/nuVZPAtj8uaJ/zZhsKOASTjxDug0SPU9LAM3wy6nPU735M1OqacR4U20LHVF5v5Wnl9ptTA==} + '@next/swc-darwin-x64@15.5.18': + resolution: {integrity: sha512-znn71QmDuxm+BOaglihMZfvyySMnNljkVIY5Z2TCssBmm+WqL6c19VhtH5ktFkHa8EZ2bnTUpcNcmNSQsg67og==} engines: {node: '>= 10'} cpu: [x64] os: [darwin] - '@next/swc-linux-arm64-gnu@15.5.6': - resolution: {integrity: sha512-qvz4SVKQ0P3/Im9zcS2RmfFL/UCQnsJKJwQSkissbngnB/12c6bZTCB0gHTexz1s6d/mD0+egPKXAIRFVS7hQg==} + '@next/swc-linux-arm64-gnu@15.5.18': + resolution: {integrity: sha512-yPPe5MNL+igZUa+OsqQJisqSfh6oarIuA1Q0BDxljGJhRQyZeP+WRHh7rs/jZUGMh5aY0YdIjXZG0VohkKkUdw==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] libc: [glibc] - '@next/swc-linux-arm64-musl@15.5.6': - resolution: {integrity: sha512-FsbGVw3SJz1hZlvnWD+T6GFgV9/NYDeLTNQB2MXoPN5u9VA9OEDy6fJEfePfsUKAhJufFbZLgp0cPxMuV6SV0w==} + '@next/swc-linux-arm64-musl@15.5.18': + resolution: {integrity: sha512-glaCczEWIrHsokFZ3pP08U4BpKxwIdnT+txdOM32OBgpL9Yw4aqx8NejmgtZQZOdstQ5f0L3CasIZudzCuD+nw==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] libc: [musl] - '@next/swc-linux-x64-gnu@15.5.6': - resolution: {integrity: sha512-3QnHGFWlnvAgyxFxt2Ny8PTpXtQD7kVEeaFat5oPAHHI192WKYB+VIKZijtHLGdBBvc16tiAkPTDmQNOQ0dyrA==} + '@next/swc-linux-x64-gnu@15.5.18': + resolution: {integrity: sha512-oUfg2EgJmU3R0OCOWiokGFUTvZiPfXtriXiuF3YNxRoROCdgvTedHIzYoeKH34gsZxS/V7mHbfq2hpAHwhH1/A==} engines: {node: '>= 10'} cpu: [x64] os: [linux] libc: [glibc] - '@next/swc-linux-x64-musl@15.5.6': - resolution: {integrity: sha512-OsGX148sL+TqMK9YFaPFPoIaJKbFJJxFzkXZljIgA9hjMjdruKht6xDCEv1HLtlLNfkx3c5w2GLKhj7veBQizQ==} + '@next/swc-linux-x64-musl@15.5.18': + resolution: {integrity: sha512-JLxSP3KTd9iu/bvUMQxH7RJo9xKSHf55/6RPE4a6FTSZygGn7uvZbCej0AHXydwkggQGSD9UddSjwv6Xz5ESfA==} engines: {node: '>= 10'} cpu: [x64] os: [linux] libc: [musl] - '@next/swc-win32-arm64-msvc@15.5.6': - resolution: {integrity: sha512-ONOMrqWxdzXDJNh2n60H6gGyKed42Ieu6UTVPZteXpuKbLZTH4G4eBMsr5qWgOBA+s7F+uB4OJbZnrkEDnZ5Fg==} + '@next/swc-win32-arm64-msvc@15.5.18': + resolution: {integrity: sha512-ir1v7enP52K2HNz3tQQvwF+x7VNxBk1ciiZ18WBPvxf4C59IqdfmHPJYK3vH7rSxpuCVw/8C712wTXNAtEp+NA==} engines: {node: '>= 10'} cpu: [arm64] os: [win32] - '@next/swc-win32-x64-msvc@15.5.6': - resolution: {integrity: sha512-pxK4VIjFRx1MY92UycLOOw7dTdvccWsNETQ0kDHkBlcFH1GrTLUjSiHU1ohrznnux6TqRHgv5oflhfIWZwVROQ==} + '@next/swc-win32-x64-msvc@15.5.18': + resolution: {integrity: sha512-LIu5me6QTANCd25E7I5uIEfvgQ06RK7tvHAbYo3zCb3VpxQEPvMcSpd87NwUABDT6MbGPdEGR5VRiK4PPTJhQg==} engines: {node: '>= 10'} cpu: [x64] os: [win32] @@ -2092,8 +2092,8 @@ packages: react: ^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc react-dom: ^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc - next@15.5.6: - resolution: {integrity: sha512-zTxsnI3LQo3c9HSdSf91O1jMNsEzIXDShXd4wVdg9y5shwLqBXi4ZtUUJyB86KGVSJLZx0PFONvO54aheGX8QQ==} + next@15.5.18: + resolution: {integrity: sha512-eKL8zUJkX9Y5lE+RX/2YJoItVdGlIscyVyboeD9wSpp0PaGqjoA4tTpT2qPqz9ax+5IzGESyLSeZ/RCwbSZ2uQ==} engines: {node: ^18.18.0 || ^19.8.0 || >= 20.0.0} hasBin: true peerDependencies: @@ -2682,30 +2682,30 @@ snapshots: react: 19.2.0 react-dom: 19.2.0(react@19.2.0) - '@next/env@15.5.6': {} + '@next/env@15.5.18': {} - '@next/swc-darwin-arm64@15.5.6': + '@next/swc-darwin-arm64@15.5.18': optional: true - '@next/swc-darwin-x64@15.5.6': + '@next/swc-darwin-x64@15.5.18': optional: true - '@next/swc-linux-arm64-gnu@15.5.6': + '@next/swc-linux-arm64-gnu@15.5.18': optional: true - '@next/swc-linux-arm64-musl@15.5.6': + '@next/swc-linux-arm64-musl@15.5.18': optional: true - '@next/swc-linux-x64-gnu@15.5.6': + '@next/swc-linux-x64-gnu@15.5.18': optional: true - '@next/swc-linux-x64-musl@15.5.6': + '@next/swc-linux-x64-musl@15.5.18': optional: true - '@next/swc-win32-arm64-msvc@15.5.6': + '@next/swc-win32-arm64-msvc@15.5.18': optional: true - '@next/swc-win32-x64-msvc@15.5.6': + '@next/swc-win32-x64-msvc@15.5.18': optional: true '@radix-ui/number@1.1.0': {} @@ -3741,11 +3741,11 @@ snapshots: '@ungap/structured-clone@1.3.0': {} - '@vercel/analytics@1.3.1(next@15.5.6(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(react@19.2.0)': + '@vercel/analytics@1.3.1(next@15.5.18(react-dom@19.2.0(react@19.2.0))(react@19.2.0))(react@19.2.0)': dependencies: server-only: 0.0.1 optionalDependencies: - next: 15.5.6(react-dom@19.2.0(react@19.2.0))(react@19.2.0) + next: 15.5.18(react-dom@19.2.0(react@19.2.0))(react@19.2.0) react: 19.2.0 '@xterm/addon-fit@0.10.0(@xterm/xterm@5.5.0)': @@ -4471,9 +4471,9 @@ snapshots: react: 19.2.0 react-dom: 19.2.0(react@19.2.0) - next@15.5.6(react-dom@19.2.0(react@19.2.0))(react@19.2.0): + next@15.5.18(react-dom@19.2.0(react@19.2.0))(react@19.2.0): dependencies: - '@next/env': 15.5.6 + '@next/env': 15.5.18 '@swc/helpers': 0.5.15 caniuse-lite: 1.0.30001751 postcss: 8.4.31 @@ -4481,14 +4481,14 @@ snapshots: react-dom: 19.2.0(react@19.2.0) styled-jsx: 5.1.6(react@19.2.0) optionalDependencies: - '@next/swc-darwin-arm64': 15.5.6 - '@next/swc-darwin-x64': 15.5.6 - '@next/swc-linux-arm64-gnu': 15.5.6 - '@next/swc-linux-arm64-musl': 15.5.6 - '@next/swc-linux-x64-gnu': 15.5.6 - '@next/swc-linux-x64-musl': 15.5.6 - '@next/swc-win32-arm64-msvc': 15.5.6 - '@next/swc-win32-x64-msvc': 15.5.6 + '@next/swc-darwin-arm64': 15.5.18 + '@next/swc-darwin-x64': 15.5.18 + '@next/swc-linux-arm64-gnu': 15.5.18 + '@next/swc-linux-arm64-musl': 15.5.18 + '@next/swc-linux-x64-gnu': 15.5.18 + '@next/swc-linux-x64-musl': 15.5.18 + '@next/swc-win32-arm64-msvc': 15.5.18 + '@next/swc-win32-x64-msvc': 15.5.18 sharp: 0.34.4 transitivePeerDependencies: - '@babel/core' diff --git a/apps/ui/vercel.json b/apps/ui/vercel.json new file mode 100644 index 00000000..9f8a4ad6 --- /dev/null +++ b/apps/ui/vercel.json @@ -0,0 +1,8 @@ +{ + "$schema": "https://openapi.vercel.sh/vercel.json", + "framework": "nextjs", + "buildCommand": "next build", + "installCommand": "pnpm install --no-frozen-lockfile", + "outputDirectory": ".next", + "regions": ["sin1"] +}