diff --git a/controlplane/src/index.ts b/controlplane/src/index.ts index 12e86f02..1a63e0ed 100644 --- a/controlplane/src/index.ts +++ b/controlplane/src/index.ts @@ -678,6 +678,11 @@ async function replenishWarmPool(env: EnvWithBindings): Promise { volumeId, image, region, + // Warm pool stays genuinely warm — Fly must NOT auto-stop idle warm machines, + // otherwise they cold-cycle (stop→autostart→stop) and a claim hits a stopped + // machine that must cold-boot ("stuck connecting"). The pool is small + // (WARM_POOL_TARGET) so the always-on cost is bounded. + autostop: 'off', env: { SANDBOX_INTERNAL_TOKEN: env.SANDBOX_INTERNAL_TOKEN || '', CONTROLPLANE_URL: env.FLY_SANDBOX_CONTROLPLANE_URL || 'https://api.orcabot.com', @@ -2448,6 +2453,13 @@ async function handleRequest(request: Request, env: EnvWithBindings, ctx: Pick; + // Fly proxy autostop behavior when the machine is idle: + // - 'off' : never auto-stop (used for the warm pool — keep it genuinely warm) + // - 'suspend' : pause + snapshot RAM, resume in ~sub-second (used for dashboard VMs) + // - 'stop' : full shutdown, cold-boot on resume (legacy default) + // Defaults to 'suspend' so a forgotten caller gets fast-resume, not cold-boot churn. + autostop?: 'off' | 'stop' | 'suspend'; }): FlyCreateMachineRequest { + const autostop = opts.autostop ?? 'suspend'; return { name: `orcabot-${opts.dashboardId.slice(0, 8)}-${crypto.randomUUID().slice(0, 6)}`, region: opts.region, @@ -404,7 +411,7 @@ export class FlyMachinesClient { hard_limit: 100, }, autostart: true, - autostop: 'stop', + autostop, }, ], mounts: [ diff --git a/controlplane/src/sessions/handler.ts b/controlplane/src/sessions/handler.ts index 5a723e42..6c570576 100644 --- a/controlplane/src/sessions/handler.ts +++ b/controlplane/src/sessions/handler.ts @@ -313,6 +313,27 @@ async function getMachineStateCached(fly: FlyMachinesClient, machineId: string): } } +// Cached sandbox /health probe for the status light, so polling doesn't round-trip +// to the VM every time. A machine can be Fly-'started' while the server is still +// booting — this is what distinguishes "warmed up" (green) from "starting" (orange). +const sandboxHealthCache = new Map(); +const SANDBOX_HEALTH_TTL_MS = 8000; + +async function getSandbоxHealthCached(env: EnvWithDriveCache, machineId: string): Promise { + const cached = sandboxHealthCache.get(machineId); + const now = Date.now(); + if (cached && now - cached.checkedAt < SANDBOX_HEALTH_TTL_MS) return cached.healthy; + let healthy = false; + try { + const res = await sandboxFetch(env, '/health', { machineId, timeoutMs: 2500, retries: 0 }); + healthy = res.ok; + } catch { + healthy = false; + } + sandboxHealthCache.set(machineId, { healthy, checkedAt: now }); + return healthy; +} + /** * Ensures a dashboard has exactly one sandbox VM. * @@ -653,9 +674,9 @@ async function claimWarmMachine( return null; } - // Disable autostop so Fly doesn't hibernate this active dashboard machine. - // Warm pool machines have autostop:'stop' for hibernation; once claimed, disable it. - await fly.disableAutostop(machineId); // best-effort, non-throwing + // Warm machines are created with autostop:'off' (genuinely warm), so there's no + // hibernation to disable on claim — and avoiding a config update here means no + // machine restart right when the user claims it. // Create sandbox session pinned to this machine const sandboxSession = await sandbox.createSessiоn(dashboardId, mcpToken, machineId); @@ -747,6 +768,10 @@ async function coldProvisionMachine( volumeId, image, region, + // Dashboard VMs suspend (RAM snapshot, ~sub-second resume) when idle instead of + // cold-stopping, so coming back to a dashboard doesn't cold-boot ("stuck + // connecting"). The cron escalates long-idle VMs from suspended → stopped. + autostop: 'suspend', env: { SANDBOX_INTERNAL_TOKEN: env.SANDBOX_INTERNAL_TOKEN, CONTROLPLANE_URL: env.FLY_SANDBOX_CONTROLPLANE_URL || 'https://api.orcabot.com', @@ -763,8 +788,10 @@ async function coldProvisionMachine( // Step 3: Wait for machine to be ready await fly.waitForState(machineId, 'started', 90); - // Disable autostop so Fly doesn't hibernate this active dashboard machine - await fly.disableAutostop(machineId); // best-effort, non-throwing + // NOTE: do NOT force autostop off here. Dashboard VMs are created with + // autostop:'suspend' (fast RAM-snapshot resume) so idle dashboards suspend + // instead of cold-stopping. A config update here would (a) override that and + // (b) trigger a machine restart right after provisioning. // Step 4: Create sandbox session with retry (server may not be listening immediately after started) let sandboxSession!: { id: string; machineId?: string }; @@ -1333,7 +1360,11 @@ export async function createSessiоn( // Use ensureDashboardSandbox which checks machine health via Fly API, // handles destroyed machines (FlyMachineNotFoundError → reprovision), // and validates the session is still reachable on the sandbox. + // Perf: this is the "first connect to sandbox" cost (warm-pool claim is fast, + // cold provision is slow). Logged so we can attribute terminal startup latency. + const ensureStart = Date.now(); const sandboxResult = await ensureDashbоardSandbоx(env, dashboardId, userId, preferredRegion); + console.log(`[perf][createSession] ensureDashboardSandbox=${Date.now() - ensureStart}ms dashboard=${dashboardId}`); if (sandboxResult instanceof Response) { // Access denied or other error — clean up the creating session record await env.DB.prepare(`DELETE FROM sessions WHERE id = ?`).bind(id).run(); @@ -1777,6 +1808,79 @@ export async function stоpDashbоardBrowser( return new Response(null, { status: 204 }); } +// REVISION: sandbox-status-light-v1 +// Read-only VM status for the dashboard "traffic light". Does NOT provision — +// reports the current Fly machine state, mapped to a small status enum: +// ready (green) — machine started/running +// starting (orange) — booting/transitioning (created/starting/replacing) +// asleep (red) — stopped/suspended, or no VM provisioned yet +// unknown (orange) — couldn't determine (transient Fly error / provisioning off) +// Uses getMachineStateCached so polling doesn't hammer the Fly API (anti-429). +export async function getDashbоardSandbоxStatus( + env: EnvWithDriveCache, + dashboardId: string, + userId: string, +): Promise { + const access = await env.DB.prepare( + `SELECT role FROM dashboard_members WHERE dashboard_id = ? AND user_id = ?` + ).bind(dashboardId, userId).first<{ role: string }>(); + if (!access) { + return Response.json({ error: 'E79201: Not found or no access' }, { status: 404 }); + } + + const sandbox = await getDashbоardSandbоx(env, dashboardId); + if (!sandbox?.sandbox_machine_id) { + // No VM (or no machine id yet) — asleep until the first terminal/browser opens. + return Response.json({ state: 'asleep', flyState: null, machineId: null }); + } + + const flyProvisioningEnabled = env.FLY_PROVISIONING_ENABLED === 'true' + && Boolean(env.FLY_API_TOKEN) && Boolean(env.FLY_APP_NAME); + if (!flyProvisioningEnabled) { + // Can't query Fly (e.g. local/desktop). A recorded session implies it's up. + return Response.json({ state: 'ready', flyState: null, machineId: sandbox.sandbox_machine_id }); + } + + const fly = new FlyMachinesClient(env.FLY_APP_NAME!, env.FLY_API_TOKEN!); + let flyState: string; + try { + flyState = await getMachineStateCached(fly, sandbox.sandbox_machine_id); + } catch (err) { + if (err instanceof FlyMachineNotFoundError) { + return Response.json({ state: 'asleep', flyState: 'not_found', machineId: sandbox.sandbox_machine_id }); + } + // Transient (e.g. 429) — report in-between rather than failing the poll. + return Response.json({ state: 'unknown', flyState: null, machineId: sandbox.sandbox_machine_id }); + } + + // Machine running at the Fly level — but only call it "ready" (green) once the + // sandbox server actually answers /health. Started-but-not-yet-serving = orange. + if (flyState === 'started') { + const healthy = await getSandbоxHealthCached(env, sandbox.sandbox_machine_id); + return Response.json({ + state: healthy ? 'ready' : 'starting', + flyState, + machineId: sandbox.sandbox_machine_id, + }); + } + + let state: 'starting' | 'asleep' | 'unknown'; + switch (flyState) { + case 'starting': + case 'created': + case 'replacing': + state = 'starting'; + break; + case 'stopped': + case 'suspended': + state = 'asleep'; + break; + default: + state = 'unknown'; + } + return Response.json({ state, flyState, machineId: sandbox.sandbox_machine_id }); +} + export async function getDashbоardBrowserStatus( env: EnvWithDriveCache, dashboardId: string, diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 1d7d64ac..3886922f 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -8,6 +8,7 @@ "name": "orcabot-frontend", "version": "0.1.0", "dependencies": { + "@novnc/novnc": "^1.7.0", "@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-dropdown-menu": "^2.1.16", "@radix-ui/react-slot": "^1.2.4", @@ -11273,6 +11274,12 @@ "node": ">=12.4.0" } }, + "node_modules/@novnc/novnc": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/@novnc/novnc/-/novnc-1.7.0.tgz", + "integrity": "sha512-ucEJOx4T2avIRCleodk7YobZj5O2Ga2AeLfQ69A/yjG9HHba2+PDgwSkN3FttrmG+70ZGx21sElNFouK13RzyA==", + "license": "MPL-2.0" + }, "node_modules/@opennextjs/aws": { "version": "3.9.12", "resolved": "https://registry.npmjs.org/@opennextjs/aws/-/aws-3.9.12.tgz", diff --git a/frontend/package.json b/frontend/package.json index 2b9daef0..2ac7cc28 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -19,6 +19,7 @@ "workers:deploy:dev": "node scripts/workers-deploy-dev.mjs" }, "dependencies": { + "@novnc/novnc": "^1.7.0", "@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-dropdown-menu": "^2.1.16", "@radix-ui/react-slot": "^1.2.4", diff --git a/frontend/src/app/(app)/dashboards/[id]/page.tsx b/frontend/src/app/(app)/dashboards/[id]/page.tsx index 712ace10..e159d4a4 100644 --- a/frontend/src/app/(app)/dashboards/[id]/page.tsx +++ b/frontend/src/app/(app)/dashboards/[id]/page.tsx @@ -113,6 +113,7 @@ import { import { PolicyEditorDialog } from "@/components/blocks/PolicyEditorDialog"; import { EgressApprovalDialog } from "@/components/EgressApprovalDialog"; import { EgressAllowlistPanel } from "@/components/EgressAllowlistPanel"; +import { SandboxStatusLight } from "@/components/SandboxStatusLight"; import { WorkspaceSidebar } from "@/components/workspace"; import { ChatPanel } from "@/components/chat"; import { getUserSetup, dismissAiSetup } from "@/lib/api/cloudflare/user-setup"; @@ -3606,6 +3607,11 @@ export default function DashboardPage() {
+ {/* Sandbox VM traffic light */} +
+ +
+ {/* Presence indicators */}
= { + ready: { dot: "bg-green-500", label: "Sandbox ready" }, + starting: { dot: "bg-amber-500", label: "Sandbox starting…" }, + asleep: { dot: "bg-red-500", label: "Sandbox asleep" }, + unknown: { dot: "bg-amber-500", label: "Sandbox status unknown" }, +}; + +interface SandboxStatusLightProps { + dashboardId: string; + /** Poll interval in ms (default 12s — server caches the underlying Fly call). */ + pollMs?: number; +} + +export function SandboxStatusLight({ dashboardId, pollMs = 12000 }: SandboxStatusLightProps) { + const { data } = useQuery({ + queryKey: ["sandbox-status", dashboardId], + queryFn: () => getDashboardSandboxStatus(dashboardId), + enabled: Boolean(dashboardId), + refetchInterval: pollMs, + refetchOnWindowFocus: true, + staleTime: pollMs, + retry: false, + }); + + const state: SandboxState = data?.state ?? "unknown"; + const { dot, label } = COLORS[state]; + const title = data?.flyState ? `${label} (${data.flyState})` : label; + + return ( + + + + ); +} + +export default SandboxStatusLight; diff --git a/frontend/src/components/blocks/BrowserBlock.tsx b/frontend/src/components/blocks/BrowserBlock.tsx index d1446d54..40568d1c 100644 --- a/frontend/src/components/blocks/BrowserBlock.tsx +++ b/frontend/src/components/blocks/BrowserBlock.tsx @@ -1,6 +1,6 @@ // Copyright 2026 Rob Macrae. All rights reserved. // SPDX-License-Identifier: LicenseRef-Proprietary -// REVISION: browser-v6-close-deletes-block +// REVISION: browser-v8-bringtofront-loop-fix "use client"; @@ -19,10 +19,13 @@ import { DropdownMenuTrigger, } from "@/components/ui"; import { BlockSettingsFooter } from "./BlockSettingsFooter"; +import { VncViewer, type VncConnectionState } from "./VncViewer"; +import { useTerminalOverlay } from "@/components/terminal"; import { HelpButton } from "@/components/help/HelpDialog"; import { browserDoc } from "@/docs/content/browser"; import { API, DEV_MODE_ENABLED } from "@/config/env"; import { ApiError } from "@/lib/api/client"; +import { perfStart, perfMark, perfEnd, perfCancel, perfActive } from "@/lib/perf"; import { getDashboardBrowserStatus, openDashboardBrowser, startDashboardBrowser, stopDashboardBrowser } from "@/lib/api/cloudflare/dashboards"; import { useAuthStore } from "@/stores/auth-store"; import type { DashboardItem } from "@/types/dashboard"; @@ -80,12 +83,32 @@ function releaseBrowser(dashboardId: string) { export function BrowserBlock({ id, data, selected }: NodeProps) { const { deleteElements } = useReactFlow(); const [status, setStatus] = React.useState<"idle" | "starting" | "running" | "error">("idle"); + const [vncState, setVncState] = React.useState("connecting"); const [errorMessage, setErrorMessage] = React.useState(null); const [refreshKey, setRefreshKey] = React.useState(0); const connectorsVisible = selected || Boolean(data.connectorMode); const lastOpenedRef = React.useRef(null); const user = useAuthStore((state) => state.user); const isMinimized = data.metadata?.minimized === true; + const overlay = useTerminalOverlay(); + // IMPORTANT: depend on the stable `bringToFront` callback, NOT the whole `overlay` + // object. The overlay context value is rebuilt on every zIndexVersion change, so + // depending on `overlay` here makes the effect re-fire → bringToFront → version + // bump → re-fire → infinite loop (React #185 "max update depth"). bringToFront is + // a useCallback([]) so it's stable. + const bringToFront = overlay?.bringToFront; + + // Bring the browser block to front like any other component. Two paths: + // 1) when it becomes selected (keyboard/click on chrome), mirror TerminalBlock; and + // 2) a capture-phase pointerdown, because the noVNC canvas consumes the event for + // VNC input before React Flow can select the node (the old iframe had the same + // issue, which is why the browser historically couldn't pop to front). + React.useEffect(() => { + if (selected) bringToFront?.(id); + }, [selected, id, bringToFront]); + const handleBringToFront = React.useCallback(() => { + bringToFront?.(id); + }, [bringToFront, id]); const [expandAnimation, setExpandAnimation] = React.useState(null); const [isAnimatingMinimize, setIsAnimatingMinimize] = React.useState(false); const minimizeTimeoutRef = React.useRef | null>(null); @@ -134,6 +157,9 @@ export function BrowserBlock({ id, data, selected }: NodeProps) { } let cancelled = false; + // Perf: time browser start → ready (dominated by chromium cold-boot when the + // VM isn't pre-warmed). perfEnd fires when the sandbox reports running+ready. + perfStart(`browser:${dashboardId}`); setStatus("starting"); setErrorMessage(null); @@ -148,6 +174,7 @@ export function BrowserBlock({ id, data, selected }: NodeProps) { }) .finally(() => { if (cancelled) return; + perfMark(`browser:${dashboardId}`, "retained"); let attempts = 0; const poll = async () => { attempts += 1; @@ -157,11 +184,13 @@ export function BrowserBlock({ id, data, selected }: NodeProps) { const statusResponse = await getDashboardBrowserStatus(dashboardId); if (cancelled) return; if (statusResponse?.running && statusResponse?.ready !== false) { + perfEnd(`browser:${dashboardId}`, `ready (after ${attempts} polls)`); setStatus("running"); setErrorMessage(null); return; } if (attempts >= 40) { + perfCancel(`browser:${dashboardId}`); setStatus("error"); setErrorMessage("Browser failed to start."); return; @@ -173,6 +202,7 @@ export function BrowserBlock({ id, data, selected }: NodeProps) { return () => { cancelled = true; + perfCancel(`browser:${dashboardId}`); releaseBrowser(dashboardId); }; }, [dashboardId, isMinimized]); @@ -192,35 +222,51 @@ export function BrowserBlock({ id, data, selected }: NodeProps) { openDashboardBrowser(dashboardId, url).catch(() => {}); }, [dashboardId, status, data.content]); - const browserUrl = React.useMemo(() => { + // WebSocket URL to the sandbox's websockify (VNC pixel stream), proxied via the + // control plane. Only the WS goes to the sandbox now — the noVNC client itself is + // bundled in the frontend (VncViewer), not served through the proxy. + const vncWsUrl = React.useMemo(() => { if (!dashboardId) return ""; - const baseUrl = `${API.cloudflare.dashboards}/${dashboardId}/browser/`; - const params = new URLSearchParams({ - autoconnect: "1", - resize: "scale", - show_dot: "true", - quality: "6", - compression: "3", - logging: "warn", - reconnect: "true", - reconnect_delay: "3000", - path: `dashboards/${dashboardId}/browser/websockify`, - }); + const httpUrl = `${API.cloudflare.dashboards}/${dashboardId}/browser/websockify`; + const wsUrl = httpUrl.replace(/^http/i, "ws"); // https→wss, http→ws + const params = new URLSearchParams(); if (DEV_MODE_ENABLED && user) { params.set("user_id", user.id); params.set("user_email", user.email); params.set("user_name", user.name); } - return `${baseUrl}?${params.toString()}`; + const qs = params.toString(); + return qs ? `${wsUrl}?${qs}` : wsUrl; }, [dashboardId, user]); + // Times the VNC client phase (WS handshake → first frame), i.e. the part this + // native-noVNC change actually targets — separate from chromium cold-boot. + // Emits: [perf] vnc: total= painted=+ + const handleVncState = React.useCallback( + (state: VncConnectionState) => { + setVncState(state); + const key = `vnc:${dashboardId}`; + if (state === "connecting" && !perfActive(key)) perfStart(key); + if (state === "connected") perfEnd(key, "painted"); + }, + [dashboardId] + ); + const header = (
- {status === "running" ? "Browser" : status === "starting" ? "Starting browser..." : "Browser stopped"} + {status === "running" + ? vncState === "connected" + ? "Browser" + : vncState === "disconnected" + ? "Reconnecting…" + : "Connecting…" + : status === "starting" + ? "Starting browser..." + : "Browser stopped"}
@@ -296,33 +342,29 @@ export function BrowserBlock({ id, data, selected }: NodeProps) { includeHandles={false} > {/* All content fades during minimize */} -
+
{header}
- {/* TODO: Clipboard — Copy/paste does not work across the VNC boundary. - The iframe renders a noVNC canvas (remote desktop pixels), so native - text selection and navigator.clipboard have no effect. x11vnc already - syncs X11 CLIPBOARD ↔ VNC CutText, but noVNC does not bridge VNC - clipboard events to the host's navigator.clipboard API. - Future fix: fork noVNC to add postMessage-based clipboard bridging: - 1. On VNC ServerCutText → noVNC posts { type: "vnc-clipboard", text } - to parent via window.parent.postMessage. - 2. Parent listens for the message and calls navigator.clipboard.writeText. - 3. On paste (Ctrl+V in parent or a UI action), parent posts - { type: "vnc-paste", text } into the iframe. - 4. noVNC receives it and sends VNC ClientCutText so the remote X11 - selection is updated before the keystroke reaches the app. - This gives seamless Ctrl+C / Ctrl+V across the VNC boundary. */} - {status === "running" && browserUrl ? ( -
-