From d578a29148abd8275c474805b3cc97a47fe072e8 Mon Sep 17 00:00:00 2001 From: Rob Macrae Date: Sun, 14 Jun 2026 23:30:48 +0100 Subject: [PATCH 1/3] Add logging for browser component performance --- controlplane/src/sessions/handler.ts | 4 ++ .../src/components/blocks/BrowserBlock.tsx | 8 +++ .../src/components/blocks/TerminalBlock.tsx | 9 ++- frontend/src/lib/perf.ts | 67 +++++++++++++++++++ sandbox/internal/browser/browser.go | 21 ++++-- 5 files changed, 102 insertions(+), 7 deletions(-) create mode 100644 frontend/src/lib/perf.ts diff --git a/controlplane/src/sessions/handler.ts b/controlplane/src/sessions/handler.ts index 5a723e42..82c8150c 100644 --- a/controlplane/src/sessions/handler.ts +++ b/controlplane/src/sessions/handler.ts @@ -1333,7 +1333,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(); diff --git a/frontend/src/components/blocks/BrowserBlock.tsx b/frontend/src/components/blocks/BrowserBlock.tsx index d1446d54..238a8d38 100644 --- a/frontend/src/components/blocks/BrowserBlock.tsx +++ b/frontend/src/components/blocks/BrowserBlock.tsx @@ -23,6 +23,7 @@ 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 } 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"; @@ -134,6 +135,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 +152,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 +162,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 +180,7 @@ export function BrowserBlock({ id, data, selected }: NodeProps) { return () => { cancelled = true; + perfCancel(`browser:${dashboardId}`); releaseBrowser(dashboardId); }; }, [dashboardId, isMinimized]); diff --git a/frontend/src/components/blocks/TerminalBlock.tsx b/frontend/src/components/blocks/TerminalBlock.tsx index 59b88352..1d915caf 100644 --- a/frontend/src/components/blocks/TerminalBlock.tsx +++ b/frontend/src/components/blocks/TerminalBlock.tsx @@ -49,6 +49,7 @@ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { toast } from "sonner"; import { cn } from "@/lib/utils"; import { trackEvent } from "@/lib/analytics"; +import { perfStart, perfMark, perfEnd } from "@/lib/perf"; import { BlockWrapper } from "./BlockWrapper"; import { MinimizedBlockView, MINIMIZED_SIZE } from "./MinimizedBlockView"; import { @@ -1889,8 +1890,10 @@ export function TerminalBlock({ React.useEffect(() => { if (connectionState === "connected") { wasConnectedRef.current = true; + // Perf: close the create→connected span (no-op if not started, e.g. reconnect). + perfEnd(`terminal:${data.itemId || id}`, "ws-connected"); } - }, [connectionState]); + }, [connectionState, data.itemId, id]); // Reset wasConnected when session changes (new session created) React.useEffect(() => { @@ -2236,8 +2239,12 @@ export function TerminalBlock({ setSessionError(null); try { + // Perf: time terminal create → first connected. perfEnd fires in the + // connectionState effect when the WS reaches "connected". + perfStart(`terminal:${actualItemId}`); console.log(`[TerminalBlock] Creating session...`); const newSession = await createSession(data.dashboardId, actualItemId); + perfMark(`terminal:${actualItemId}`, "session-created"); console.log(`[TerminalBlock] Session created:`, newSession); setSession(newSession); upsertDashboardSession(newSession); diff --git a/frontend/src/lib/perf.ts b/frontend/src/lib/perf.ts new file mode 100644 index 00000000..45633b23 --- /dev/null +++ b/frontend/src/lib/perf.ts @@ -0,0 +1,67 @@ +// Copyright 2026 Rob Macrae. All rights reserved. +// SPDX-License-Identifier: LicenseRef-Proprietary + +// REVISION: perf-v1-span-timers +// +// Lightweight client-side span timing for user-perceived latency (component +// created → ready). Emits a single structured `[perf]` console line per span with +// per-segment deltas + total, e.g.: +// [perf] terminal:abc123 total=1840ms create→session=+612ms session→ws=+1100ms ws→ready=+128ms +// Uses performance.now() (monotonic) and is keyed by a stable id (itemId/dashboardId) +// so marks recorded across different components/effects accumulate into one span. + +const MODULE_REVISION = "perf-v1-span-timers"; +if (typeof console !== "undefined") { + console.log(`[perf] REVISION: ${MODULE_REVISION} loaded`); +} + +interface Span { + t0: number; + last: number; + segments: string[]; +} + +const spans = new Map(); + +function now(): number { + return typeof performance !== "undefined" ? performance.now() : Date.now(); +} + +/** Begin (or restart) a span. Safe to call again; the latest call wins. */ +export function perfStart(key: string): void { + const t = now(); + spans.set(key, { t0: t, last: t, segments: [] }); +} + +/** Record an intermediate milestone (delta since the previous mark/start). */ +export function perfMark(key: string, label: string): void { + const s = spans.get(key); + if (!s) return; + const t = now(); + s.segments.push(`${label}=+${Math.round(t - s.last)}ms`); + s.last = t; +} + +/** + * Close a span: records the final segment, logs the structured line, and clears it. + * No-op if the span was never started (e.g. a reconnect path that skipped perfStart). + */ +export function perfEnd(key: string, label: string): void { + const s = spans.get(key); + if (!s) return; + const t = now(); + s.segments.push(`${label}=+${Math.round(t - s.last)}ms`); + const total = Math.round(t - s.t0); + console.log(`[perf] ${key} total=${total}ms ${s.segments.join(" ")}`); + spans.delete(key); +} + +/** Discard a span without logging (e.g. the component unmounted before ready). */ +export function perfCancel(key: string): void { + spans.delete(key); +} + +/** Whether a span is currently open for this key. */ +export function perfActive(key: string): boolean { + return spans.has(key); +} diff --git a/sandbox/internal/browser/browser.go b/sandbox/internal/browser/browser.go index d29f1a29..e4705562 100644 --- a/sandbox/internal/browser/browser.go +++ b/sandbox/internal/browser/browser.go @@ -1,7 +1,7 @@ // Copyright 2026 Rob Macrae. All rights reserved. // SPDX-License-Identifier: LicenseRef-Proprietary -// REVISION: browser-v7-proxy-server +// REVISION: browser-v8-perf-timing package browser import ( @@ -44,8 +44,8 @@ type Controller struct { processes []*exec.Cmd } -// REVISION: browser-v7-proxy-server -const browserRevision = "browser-v7-proxy-server" +// REVISION: browser-v8-perf-timing +const browserRevision = "browser-v8-perf-timing" func init() { log.Printf("[browser] REVISION: %s loaded at %s", browserRevision, time.Now().Format(time.RFC3339)) @@ -62,7 +62,7 @@ func NewController(workspace string) *Controller { // that redirects, XHR, and subresource loads are subject to the same // CheckAndHold approval flow as the initial navigation URL. // Loopback addresses bypass the proxy so the CDP debug connection is unaffected. -// REVISION: browser-v7-proxy-server +// REVISION: browser-v8-perf-timing func NewControllerWithEgress(workspace string, egressProxyPort int) *Controller { return &Controller{workspace: workspace, egressProxyPort: egressProxyPort} } @@ -75,6 +75,9 @@ func (c *Controller) Start() (Status, error) { return c.statusLocked(), nil } + // Perf: time the full cold browser bring-up (dominated by chromium boot). + startTime := time.Now() + if err := ensureBinary("chromium"); err != nil { return Status{}, err } @@ -180,7 +183,7 @@ func (c *Controller) Start() (Status, error) { // Chromium runs as root and is outside the iptables UID range, so without // this every redirect, XHR, and subresource load bypasses kernel egress controls. // Loopback is bypassed so the CDP debug connection on 127.0.0.1 is unaffected. - // REVISION: browser-v7-proxy-server + // REVISION: browser-v8-perf-timing if c.egressProxyPort > 0 { chromiumArgs = append(chromiumArgs, "--proxy-server=http://127.0.0.1:"+strconv.Itoa(c.egressProxyPort), @@ -239,9 +242,15 @@ func (c *Controller) Start() (Status, error) { c.debugPort = debugPort c.processes = processes c.running = true + spawnElapsed := time.Since(startTime) + readyStart := time.Now() c.ready = waitForDebugReady(debugPort, 10*time.Second) - log.Printf("browser started display=%d wsPort=%d vncPort=%d", display, wsPort, vncPort) + // Perf: spawn = time to launch all 4 processes; debugReady = time waiting for + // chromium's DevTools endpoint to answer; total = full cold bring-up. + log.Printf("[browser][perf] started display=%d wsPort=%d vncPort=%d spawn=%dms debugReady=%dms total=%dms ready=%t", + display, wsPort, vncPort, + spawnElapsed.Milliseconds(), time.Since(readyStart).Milliseconds(), time.Since(startTime).Milliseconds(), c.ready) return c.statusLocked(), nil } From adca4d5f658ba661b6c107fb53502bc68631a773 Mon Sep 17 00:00:00 2001 From: Rob Macrae Date: Mon, 15 Jun 2026 18:05:04 +0100 Subject: [PATCH 2/3] Fix warm pool --- controlplane/src/index.ts | 5 +++ controlplane/src/sandbox/fly-machines.ts | 9 ++++- controlplane/src/sessions/handler.ts | 16 +++++--- sandbox/CLAUDE.md | 4 +- sandbox/internal/browser/browser.go | 48 +++++++++++++++++++++--- sandbox/internal/sessions/manager.go | 38 +++++++++++++++++++ 6 files changed, 107 insertions(+), 13 deletions(-) diff --git a/controlplane/src/index.ts b/controlplane/src/index.ts index 12e86f02..2ebe4a8d 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', diff --git a/controlplane/src/sandbox/fly-machines.ts b/controlplane/src/sandbox/fly-machines.ts index 1e9ee668..83a1d94f 100644 --- a/controlplane/src/sandbox/fly-machines.ts +++ b/controlplane/src/sandbox/fly-machines.ts @@ -375,7 +375,14 @@ export class FlyMachinesClient { image: string; region: string; env: Record; + // 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 82c8150c..499ca63a 100644 --- a/controlplane/src/sessions/handler.ts +++ b/controlplane/src/sessions/handler.ts @@ -653,9 +653,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 +747,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 +767,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 }; diff --git a/sandbox/CLAUDE.md b/sandbox/CLAUDE.md index 407614e7..90cedd71 100644 --- a/sandbox/CLAUDE.md +++ b/sandbox/CLAUDE.md @@ -403,8 +403,10 @@ The sandbox runs an embedded Chromium browser accessible via WebSocket for in-br - `OpenURL()` reuses blank tabs or deduplicates existing URLs via Chrome DevTools Protocol - Port waiting with timeouts (10–20s per process) - Status returns: `Running`, `Ready`, `WSPort`, `Display`, `DebugPort` +- **Readiness re-probe**: `Ready` is re-evaluated on every `Status()` poll (cold chromium boot can exceed the short startup budget), so the browser flips to ready as soon as DevTools answers instead of latching `false` until an `OpenURL`. +- **Pre-warm**: chromium is started in the background at sandbox session creation (`Manager.Create`) so the browser is ready (~instant) when first opened, instead of paying the ~25s cold boot on demand. One sandbox session per VM ⇒ one pre-warmed browser per VM (~250–350 MB idle on the 4 GB VMs). **On by default; set `BROWSER_PREWARM=false` to disable.** Future: idle auto-stop to reclaim RAM when unused. -**Key file:** `internal/browser/browser.go` +**Key file:** `internal/browser/browser.go`, pre-warm hook in `internal/sessions/manager.go` --- diff --git a/sandbox/internal/browser/browser.go b/sandbox/internal/browser/browser.go index e4705562..81f5712c 100644 --- a/sandbox/internal/browser/browser.go +++ b/sandbox/internal/browser/browser.go @@ -1,7 +1,7 @@ // Copyright 2026 Rob Macrae. All rights reserved. // SPDX-License-Identifier: LicenseRef-Proprietary -// REVISION: browser-v8-perf-timing +// REVISION: browser-v9-ready-reprobe package browser import ( @@ -44,8 +44,8 @@ type Controller struct { processes []*exec.Cmd } -// REVISION: browser-v8-perf-timing -const browserRevision = "browser-v8-perf-timing" +// REVISION: browser-v9-ready-reprobe +const browserRevision = "browser-v9-ready-reprobe" func init() { log.Printf("[browser] REVISION: %s loaded at %s", browserRevision, time.Now().Format(time.RFC3339)) @@ -62,7 +62,7 @@ func NewController(workspace string) *Controller { // that redirects, XHR, and subresource loads are subject to the same // CheckAndHold approval flow as the initial navigation URL. // Loopback addresses bypass the proxy so the CDP debug connection is unaffected. -// REVISION: browser-v8-perf-timing +// REVISION: browser-v9-ready-reprobe func NewControllerWithEgress(workspace string, egressProxyPort int) *Controller { return &Controller{workspace: workspace, egressProxyPort: egressProxyPort} } @@ -183,7 +183,7 @@ func (c *Controller) Start() (Status, error) { // Chromium runs as root and is outside the iptables UID range, so without // this every redirect, XHR, and subresource load bypasses kernel egress controls. // Loopback is bypassed so the CDP debug connection on 127.0.0.1 is unaffected. - // REVISION: browser-v8-perf-timing + // REVISION: browser-v9-ready-reprobe if c.egressProxyPort > 0 { chromiumArgs = append(chromiumArgs, "--proxy-server=http://127.0.0.1:"+strconv.Itoa(c.egressProxyPort), @@ -244,7 +244,10 @@ func (c *Controller) Start() (Status, error) { c.running = true spawnElapsed := time.Since(startTime) readyStart := time.Now() - c.ready = waitForDebugReady(debugPort, 10*time.Second) + // Short readiness budget under the lock — warm/fast browsers report ready right + // away; cold chromium boot (can exceed 20s) is picked up by the Status() re-probe + // instead of blocking Start() (and the mutex) for the whole boot. + c.ready = waitForDebugReady(debugPort, 3*time.Second) // Perf: spawn = time to launch all 4 processes; debugReady = time waiting for // chromium's DevTools endpoint to answer; total = full cold bring-up. @@ -375,6 +378,23 @@ func (c *Controller) Status() Status { } } + // Re-evaluate readiness. c.ready may have been false at Start() time if chromium's + // DevTools hadn't answered within the short startup budget (cold boot can take + // >20s). Probe again here so the browser flips to ready on a later status poll, + // instead of staying stuck until an OpenURL call happens to set it. + c.mu.Lock() + stillRunning := c.running + alreadyReady := c.ready + debugPort := c.debugPort + c.mu.Unlock() + if stillRunning && !alreadyReady && debugPort > 0 && probeDebugReady(debugPort) { + c.mu.Lock() + if c.running && c.debugPort == debugPort { + c.ready = true + } + c.mu.Unlock() + } + c.mu.Lock() defer c.mu.Unlock() return c.statusLocked() @@ -589,6 +609,22 @@ func waitForPort(port int, timeout time.Duration) bool { return false } +// probeDebugReady does a single quick check of chromium's DevTools endpoint. +// Unlike waitForDebugReady (which loops until a deadline), this returns immediately, +// so it's safe to call on every Status() poll without blocking. +func probeDebugReady(port int) bool { + if port == 0 { + return false + } + client := &http.Client{Timeout: 300 * time.Millisecond} + resp, err := client.Get(fmt.Sprintf("http://127.0.0.1:%d/json/version", port)) + if err != nil { + return false + } + _ = resp.Body.Close() + return resp.StatusCode < 300 +} + func waitForDebugReady(port int, timeout time.Duration) bool { if !waitForPort(port, timeout) { return false diff --git a/sandbox/internal/sessions/manager.go b/sandbox/internal/sessions/manager.go index 97fed3de..f10a9fa9 100644 --- a/sandbox/internal/sessions/manager.go +++ b/sandbox/internal/sessions/manager.go @@ -6,9 +6,12 @@ package sessions import ( "errors" "fmt" + "log" "net/http" "os" + "strings" "sync" + "time" "github.com/Hyper-Int/OrcaBot/sandbox/internal/broker" "github.com/Hyper-Int/OrcaBot/sandbox/internal/geminishim" @@ -131,9 +134,44 @@ func (m *Manager) Create(dashboardID string, mcpToken string) (*Session, error) m.sessions[sessionID] = session m.mu.Unlock() + // REVISION: browser-prewarm-v1 + // Pre-warm chromium in the background so the browser is ready (~instant) when the + // user or an agent first opens it, instead of paying chromium's ~25s cold boot on + // demand. One sandbox session per VM, so this warms one browser per VM. On by + // default; set BROWSER_PREWARM=false to disable (saves ~250-350MB idle RAM/VM). + // A short delay yields CPU to the initial terminal/agent launch first (chromium + // boot is heavy and the VM has only 2 vCPUs). + if browserPrewarmEnabled() { + go func(s *Session) { + time.Sleep(3 * time.Second) + // Skip if the session was deleted during the delay, so we don't start an + // orphan chromium on a torn-down session (Session.Close stops the browser, + // but only if it was already created). + if _, err := m.Get(s.ID); err != nil { + return + } + if _, err := s.StartBrowser(); err != nil { + log.Printf("[browser][prewarm] session %s: failed: %v", s.ID, err) + } else { + log.Printf("[browser][prewarm] session %s: started", s.ID) + } + }(session) + } + return session, nil } +// browserPrewarmEnabled reports whether chromium should be pre-warmed at session +// creation. Defaults to ON; BROWSER_PREWARM=false/0/no/off disables it. +func browserPrewarmEnabled() bool { + switch strings.TrimSpace(strings.ToLower(os.Getenv("BROWSER_PREWARM"))) { + case "false", "0", "no", "off": + return false + default: + return true + } +} + // Get retrieves a session by ID func (m *Manager) Get(id string) (*Session, error) { m.mu.RLock() From ff41d8e7ec6477611c8d92b82e8ab8011f95b7a5 Mon Sep 17 00:00:00 2001 From: Rob Macrae Date: Mon, 15 Jun 2026 21:28:46 +0100 Subject: [PATCH 3/3] component fixes --- controlplane/src/index.ts | 7 + controlplane/src/sessions/handler.ts | 94 +++++++++++++ frontend/package-lock.json | 7 + frontend/package.json | 1 + .../src/app/(app)/dashboards/[id]/page.tsx | 6 + .../src/components/SandboxStatusLight.tsx | 62 ++++++++ .../src/components/blocks/BrowserBlock.tsx | 112 ++++++++++----- frontend/src/components/blocks/VncViewer.tsx | 133 ++++++++++++++++++ frontend/src/components/canvas/Canvas.tsx | 16 ++- frontend/src/lib/api/cloudflare/dashboards.ts | 9 ++ frontend/src/types/novnc.d.ts | 33 +++++ 11 files changed, 434 insertions(+), 46 deletions(-) create mode 100644 frontend/src/components/SandboxStatusLight.tsx create mode 100644 frontend/src/components/blocks/VncViewer.tsx create mode 100644 frontend/src/types/novnc.d.ts diff --git a/controlplane/src/index.ts b/controlplane/src/index.ts index 2ebe4a8d..1a63e0ed 100644 --- a/controlplane/src/index.ts +++ b/controlplane/src/index.ts @@ -2453,6 +2453,13 @@ async function handleRequest(request: Request, env: EnvWithBindings, ctx: Pick(); +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. * @@ -1787,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 238a8d38..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,11 +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 } from "@/lib/perf"; +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"; @@ -81,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); @@ -200,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"}
@@ -304,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 ? ( -
-