From c5c475ce08ed357fa8cd3e8e652d7aeb632de042 Mon Sep 17 00:00:00 2001 From: John Donmoyer Date: Mon, 23 Feb 2026 16:32:52 -0500 Subject: [PATCH 1/5] feat: add interactive sandbox dashboard with org picker and scrolling viewport Adds a new `sandbox dashboard` TUI command for browsing and managing sandboxes. Includes org switching (press t), region filtering, sorting, SSH, kill, extend, and clipboard copy. Long lists scroll with fixed headers/footer and a visible range indicator. --- sandbox/dashboard.ts | 871 +++++++++++++++++++++++++++++++++++++++++++ sandbox/mod.ts | 2 + 2 files changed, 873 insertions(+) create mode 100644 sandbox/dashboard.ts diff --git a/sandbox/dashboard.ts b/sandbox/dashboard.ts new file mode 100644 index 0000000..95cbe68 --- /dev/null +++ b/sandbox/dashboard.ts @@ -0,0 +1,871 @@ +import { Command } from "@cliffy/command"; +import { bold, dim, green, red, stripAnsiCode } from "@std/fmt/colors"; +import { Sandbox } from "@deno/sandbox"; + +import { formatDuration, renderTemporalTimestamp } from "../util.ts"; +import { createTrpcClient, getAuth } from "../auth.ts"; +import { actionHandler, getOrg } from "../config.ts"; +import type { SandboxContext } from "./mod.ts"; + +// --- Types --- + +interface SandboxInfo { + id: string; + status: "running" | "stopped"; + created_at: Date; + stopped_at: Date | null; + cluster_hostname: string; +} + +interface OrgInfo { + name: string; + slug: string; + id: string; +} + +interface DashboardState { + sandboxes: SandboxInfo[]; + selectedIndex: number; + org: string; + error: string | null; + loading: boolean; + lastRefresh: Date; + regionFilter: string | null; + sortBy: "created" | "status" | "region"; + sortAsc: boolean; + mode: "normal" | "extend" | "org"; + statusMessage: string | null; + orgs: OrgInfo[]; + orgSelectedIndex: number; +} + +// --- ANSI escape helpers --- + +// These are special character sequences that tell the terminal what to do. +// For example, ESC[H moves the cursor to the top-left corner of the screen. +const ESC = "\x1b"; +const CLEAR_SCREEN = `${ESC}[2J`; +const CURSOR_HOME = `${ESC}[H`; +const HIDE_CURSOR = `${ESC}[?25l`; +const SHOW_CURSOR = `${ESC}[?25h`; +const RESET_STYLE = `${ESC}[0m`; +const INVERSE = `${ESC}[7m`; + +// --- API functions --- + +// Fetches the list of sandboxes from the API. +// Wrapped in try/catch so errors don't crash the dashboard — +// instead we store the error message and keep showing stale data. +async function fetchSandboxes( + client: ReturnType, + org: string, +): Promise<{ sandboxes: SandboxInfo[]; error: string | null }> { + try { + const list = await client.query("sandboxes.list", { org }) as SandboxInfo[]; + return { sandboxes: list, error: null }; + } catch (e) { + return { sandboxes: [], error: (e as Error).message }; + } +} + +// Kills a sandbox by first looking up its hostname, then calling the kill mutation. +// This is the same two-step pattern used by the `sandbox kill` command. +async function killSandbox( + client: ReturnType, + org: string, + sandboxId: string, +): Promise<{ success: boolean; error: string | null }> { + try { + const cluster = await client.query("sandboxes.findHostname", { + org, + sandboxId, + }) as { hostname: string }; + + const res = await client.mutation("sandboxes.kill", { + org, + sandboxId, + clusterHostname: cluster.hostname, + }) as { success: boolean }; + + return { success: res.success, error: null }; + } catch (e) { + return { success: false, error: (e as Error).message }; + } +} + +// --- Screen rendering --- + +// Builds the entire screen as one big string, then writes it all at once. +// Writing everything in a single shot prevents flickering — the terminal +// doesn't show partial updates between frames. +function renderScreen(state: DashboardState): string { + const { columns, rows } = Deno.consoleSize(); + const lines: string[] = []; + + // Apply region filter and sort to get the display list. + // We work on a copy so we don't mutate the original state. + const filtered = getFilteredSandboxes(state); + const displayList = sortSandboxes(filtered, state.sortBy, state.sortAsc); + + // Header + const title = bold(" Sandbox Dashboard"); + const orgLabel = dim(`Org: ${state.org}`); + const headerPadding = columns - stripAnsiCode(title).length - + stripAnsiCode(orgLabel).length; + lines.push(title + " ".repeat(Math.max(1, headerPadding)) + orgLabel); + + // Status summary — shows total count, running/stopped breakdown, + // current filter and sort + const total = state.sandboxes.length; + const running = state.sandboxes.filter((s) => s.status === "running").length; + const stopped = state.sandboxes.filter((s) => s.status === "stopped").length; + const parts: string[] = []; + if (running > 0) parts.push(`${running} running`); + if (stopped > 0) parts.push(`${stopped} stopped`); + + let summary = ` ${total} total`; + if (parts.length > 0) summary += ` — ${parts.join(", ")}`; + if (state.regionFilter) summary += dim(` (region: ${state.regionFilter})`); + const sortArrow = state.sortAsc ? "↑" : "↓"; + summary += dim(` Sort: ${state.sortBy} ${sortArrow}`); + + const timeStr = dim( + `Last refresh: ${ + state.lastRefresh.toLocaleTimeString("en-US", { hour12: false }) + }`, + ); + const summaryPadding = columns - stripAnsiCode(summary).length - + stripAnsiCode(timeStr).length; + lines.push(summary + " ".repeat(Math.max(1, summaryPadding)) + timeStr); + + lines.push(""); + + // How many rows are available for list items (sandboxes or orgs)? + // We subtract: header (3 lines above), table/org header (1 line), footer (2 lines). + // This keeps header + footer fixed and only the list rows scroll. + const maxVisibleRows = rows - 3 - 1 - 2; + + if (state.mode === "org") { + // Org picker — replaces the sandbox table when choosing an org + lines.push(dim(" " + "NAME".padEnd(30) + " " + "SLUG")); + + const { start, end } = getVisibleRange( + state.orgs.length, + state.orgSelectedIndex, + maxVisibleRows, + ); + + for (let i = start; i < end; i++) { + const org = state.orgs[i]; + const isHighlighted = i === state.orgSelectedIndex; + const isActive = org.slug === state.org; + const marker = isHighlighted ? ">" : " "; + const activeMarker = isActive ? " *" : ""; + + const row = ` ${marker} ${org.name.padEnd(30)} ${ + dim(org.slug) + }${activeMarker}`; + + if (isHighlighted) { + lines.push(INVERSE + row + RESET_STYLE); + } else { + lines.push(row); + } + } + + if (state.orgs.length > maxVisibleRows) { + lines.push( + dim(` [${start + 1}–${end} of ${state.orgs.length}]`), + ); + } + } else { + // Normal sandbox table + const headers = ["", "ID", "REGION", "STATUS", "UPTIME", "CREATED"]; + const colWidths = [2, 16, 10, 10, 10, 22]; + + // Calculate column widths based on actual data + for (const sandbox of displayList) { + colWidths[1] = Math.max(colWidths[1], sandbox.id.length); + const region = sandbox.cluster_hostname.split(".")[0]; + colWidths[2] = Math.max(colWidths[2], region.length); + } + + const headerLine = " " + headers.map((h, i) => + dim(h.padEnd(colWidths[i])) + ).join(" "); + lines.push(headerLine); + + // Sandbox rows — render the filtered+sorted list + if (displayList.length === 0 && !state.loading) { + lines.push(""); + if (state.regionFilter) { + lines.push( + dim( + ` No sandboxes in region "${state.regionFilter}". Press f to cycle filters.`, + ), + ); + } else { + lines.push( + dim(" No sandboxes found. Create one with: deno sandbox new"), + ); + } + } else { + // Only render the rows that fit on screen, scrolling to keep the + // selected item visible. The header and footer stay fixed. + const { start, end } = getVisibleRange( + displayList.length, + state.selectedIndex, + maxVisibleRows, + ); + + for (let i = start; i < end; i++) { + const sandbox = displayList[i]; + const isSelected = i === state.selectedIndex; + + let duration; + if (sandbox.stopped_at) { + duration = new Date(sandbox.stopped_at).getTime() - + new Date(sandbox.created_at).getTime(); + } else { + duration = Date.now() - new Date(sandbox.created_at).getTime(); + } + + const marker = isSelected ? ">" : " "; + const region = sandbox.cluster_hostname.split(".")[0]; + const statusText = sandbox.status === "running" + ? green(sandbox.status) + : red(sandbox.status); + const uptime = formatDuration(duration); + const created = renderTemporalTimestamp( + new Date(sandbox.created_at).toISOString(), + ); + + // stripAnsiCode is needed because color codes add invisible characters + // that would mess up the padding math. + const statusPadded = statusText + + " ".repeat( + Math.max(0, colWidths[3] - stripAnsiCode(statusText).length), + ); + + const row = ` ${marker} ${sandbox.id.padEnd(colWidths[1])} ` + + `${region.padEnd(colWidths[2])} ` + + `${statusPadded} ` + + `${uptime.padEnd(colWidths[4])} ` + + `${created}`; + + if (isSelected) { + lines.push(INVERSE + row + RESET_STYLE); + } else { + lines.push(row); + } + } + + // Show a scroll indicator when the list doesn't fit on one screen + if (displayList.length > maxVisibleRows) { + lines.push( + dim(` [${start + 1}–${end} of ${displayList.length}]`), + ); + } + } + } + + // Fill remaining space so the footer stays at the bottom + const footerHeight = 2; + const usedLines = lines.length; + const availableLines = rows - footerHeight; + for (let i = usedLines; i < availableLines; i++) { + lines.push(""); + } + + // Footer — status messages and shortcut bar + if (state.mode === "extend") { + lines.push( + bold(" Extend by: ") + "1) 5m 2) 15m 3) 30m 4) 1h " + + dim("(Esc cancel)"), + ); + } else if (state.mode === "org") { + lines.push( + bold(" Select org: ") + dim("↑/↓ Navigate Enter Select Esc Cancel"), + ); + } else if (state.error) { + lines.push(red(` Error: ${state.error}`)); + } else if (state.statusMessage) { + lines.push(green(` ${state.statusMessage}`)); + } else if (state.loading) { + lines.push(dim(" Refreshing...")); + } else { + lines.push(""); + } + + const shortcuts = state.mode === "org" + ? dim(" * = active org") + : dim( + " ↑/↓ Navigate s SSH k Kill e Extend c Copy ID f Filter o/O Sort t Org r Refresh q Quit", + ); + lines.push(shortcuts); + + return CLEAR_SCREEN + CURSOR_HOME + lines.join("\n"); +} + +// Sorts a list of sandboxes based on the current sort column. +// Returns a new array — doesn't modify the original. +function sortSandboxes( + sandboxes: SandboxInfo[], + sortBy: "created" | "status" | "region", + asc: boolean, +): SandboxInfo[] { + const sorted = [...sandboxes]; + switch (sortBy) { + case "created": + // Newest first + sorted.sort( + (a, b) => + new Date(b.created_at).getTime() - new Date(a.created_at).getTime(), + ); + break; + case "status": + // Running first, then stopped + sorted.sort((a, b) => { + if (a.status === b.status) return 0; + return a.status === "running" ? -1 : 1; + }); + break; + case "region": + // Alphabetical by region name + sorted.sort((a, b) => + a.cluster_hostname.split(".")[0].localeCompare( + b.cluster_hostname.split(".")[0], + ) + ); + break; + } + if (asc) sorted.reverse(); + return sorted; +} + +// --- Keypress reading --- + +// Reads individual keypresses from the terminal by putting stdin into "raw" mode. +// Normally the terminal waits for you to press Enter before sending input. +// Raw mode sends each keypress immediately, which is how we detect arrow keys. +// Arrow keys are sent as 3-byte sequences: ESC [ A (up), ESC [ B (down), etc. +async function* readKeypress(): AsyncGenerator { + const buf = new Uint8Array(8); + const reader = Deno.stdin.readable.getReader(); + + try { + while (true) { + const { value, done } = await reader.read(); + if (done || !value) break; + + // Copy the bytes we received into our buffer + buf.set(value); + const len = value.length; + + if (len === 1) { + // Single byte keypresses + const byte = buf[0]; + if (byte === 0x03) yield "ctrl+c"; // Ctrl+C + else if (byte === 0x1b) yield "escape"; // Escape (single byte, not arrow) + else if (byte === 0x71) yield "q"; + else if (byte === 0x6b) yield "k"; + else if (byte === 0x72) yield "r"; + else if (byte === 0x73) yield "s"; // SSH + else if (byte === 0x65) yield "e"; // Extend + else if (byte === 0x66) yield "f"; // Filter + else if (byte === 0x6f) yield "o"; // Order/sort + else if (byte === 0x4f) yield "O"; // Toggle sort direction + else if (byte === 0x63) yield "c"; // Copy + else if (byte === 0x74) yield "t"; // Team/org picker + else if (byte === 0x0d) yield "enter"; // Enter/Return + else if (byte === 0x31) yield "1"; // Extend presets + else if (byte === 0x32) yield "2"; + else if (byte === 0x33) yield "3"; + else if (byte === 0x34) yield "4"; + } else if (len === 3 && buf[0] === 0x1b && buf[1] === 0x5b) { + // Arrow key escape sequences: ESC [ A/B/C/D + if (buf[2] === 0x41) yield "up"; + if (buf[2] === 0x42) yield "down"; + } + } + } finally { + reader.releaseLock(); + } +} + +// --- Helper functions --- + +// Returns the list of sandboxes after applying the region filter. +// Used by both the key handlers (for navigation bounds) and renderScreen. +function getFilteredSandboxes(state: DashboardState): SandboxInfo[] { + if (state.regionFilter === null) return state.sandboxes; + return state.sandboxes.filter( + (s) => s.cluster_hostname.split(".")[0] === state.regionFilter, + ); +} + +// Gets the sandbox that's currently highlighted, accounting for the region filter. +function getSelectedSandbox(state: DashboardState): SandboxInfo | undefined { + const filtered = getFilteredSandboxes(state); + return filtered[state.selectedIndex]; +} + +// Figures out which slice of a list to display so the selected item stays visible. +// Think of it like a window sliding over the full list — the window moves to +// follow the cursor, but the header and footer stay fixed on screen. +function getVisibleRange( + totalItems: number, + selectedIndex: number, + maxVisible: number, +): { start: number; end: number } { + // If everything fits, show it all + if (totalItems <= maxVisible) { + return { start: 0, end: totalItems }; + } + + // Center the selected item in the visible window when possible. + // If the selected item is near the top or bottom of the list, + // the window clamps to the edges so we don't show empty space. + let start = selectedIndex - Math.floor(maxVisible / 2); + start = Math.max(0, start); + start = Math.min(start, totalItems - maxVisible); + + return { start, end: start + maxVisible }; +} + +// Copies text to the system clipboard using platform-specific commands. +// macOS uses pbcopy, Linux tries xclip first then falls back to xsel. +async function copyToClipboard(text: string): Promise { + const os = Deno.build.os; + let cmd: string[]; + + if (os === "darwin") { + cmd = ["pbcopy"]; + } else if (os === "linux") { + cmd = ["xclip", "-selection", "clipboard"]; + } else { + throw new Error("Clipboard not supported on this platform"); + } + + const process = new Deno.Command(cmd[0], { + args: cmd.slice(1), + stdin: "piped", + }).spawn(); + + const writer = process.stdin.getWriter(); + await writer.write(new TextEncoder().encode(text)); + await writer.close(); + const result = await process.output(); + + if (!result.success) { + // On Linux, try xsel as fallback + if (os === "linux") { + const fallback = new Deno.Command("xsel", { + args: ["--clipboard", "--input"], + stdin: "piped", + }).spawn(); + const fbWriter = fallback.stdin.getWriter(); + await fbWriter.write(new TextEncoder().encode(text)); + await fbWriter.close(); + const fbResult = await fallback.output(); + if (!fbResult.success) { + throw new Error("No clipboard tool available (tried xclip and xsel)"); + } + } else { + throw new Error("Clipboard command failed"); + } + } +} + +// --- Terminal cleanup --- + +// Restores the terminal to its normal state. This is critical — +// if we crash without doing this, the user's terminal would be stuck +// in raw mode with no visible cursor, which is very confusing. +// We call this on quit, Ctrl+C, and in a finally block as a safety net. +function restoreTerminal() { + const encoder = new TextEncoder(); + Deno.stdout.writeSync(encoder.encode(SHOW_CURSOR + RESET_STYLE)); + try { + Deno.stdin.setRaw(false); + } catch { + // stdin may already be restored or not a TTY + } +} + +// --- Main dashboard loop --- + +// This is the heart of the dashboard. It: +// 1. Fetches sandbox data from the API +// 2. Renders the screen +// 3. Waits for a keypress OR the auto-refresh timer +// 4. Updates the state based on what happened +// 5. Re-renders and loops back to step 3 +async function runDashboard( + client: ReturnType, + org: string, + options: SandboxContext, +) { + const encoder = new TextEncoder(); + + const state: DashboardState = { + sandboxes: [], + selectedIndex: 0, + org, + error: null, + loading: true, + lastRefresh: new Date(), + regionFilter: null, + sortBy: "created", + sortAsc: false, + mode: "normal", + statusMessage: null, + orgs: [], + orgSelectedIndex: 0, + }; + + // Initial data fetch + const initial = await fetchSandboxes(client, org); + state.sandboxes = initial.sandboxes; + state.error = initial.error; + state.loading = false; + state.lastRefresh = new Date(); + + // Fetch available orgs for the org picker + try { + state.orgs = await client.query("orgs.list") as OrgInfo[]; + } catch { + // If we can't fetch orgs, the picker just won't be available + } + + // Enter raw mode so we can read individual keypresses + Deno.stdin.setRaw(true); + Deno.stdout.writeSync(encoder.encode(HIDE_CURSOR)); + + // Draw the initial screen + Deno.stdout.writeSync(encoder.encode(renderScreen(state))); + + // Auto-refresh timer — fires every 5 seconds to fetch fresh data + let refreshTimer: ReturnType | null = null; + + // This function refreshes the data and redraws the screen. + // Used by both the timer and the manual refresh (r key). + const refreshAndRender = async () => { + state.loading = true; + Deno.stdout.writeSync(encoder.encode(renderScreen(state))); + + const result = await fetchSandboxes(client, state.org); + if (result.error) { + // Show error but keep stale data visible + state.error = result.error; + } else { + state.sandboxes = result.sandboxes; + state.error = null; + } + state.loading = false; + state.lastRefresh = new Date(); + + // Clamp selection to the filtered list length + // (sandboxes may have appeared or disappeared) + const filtered = getFilteredSandboxes(state); + if (filtered.length > 0) { + state.selectedIndex = Math.min( + state.selectedIndex, + filtered.length - 1, + ); + } else { + state.selectedIndex = 0; + } + + Deno.stdout.writeSync(encoder.encode(renderScreen(state))); + }; + + refreshTimer = setInterval(refreshAndRender, 5000); + + // Re-render on terminal resize so the layout adapts + const resizeHandler = () => { + Deno.stdout.writeSync(encoder.encode(renderScreen(state))); + }; + Deno.addSignalListener("SIGWINCH", resizeHandler); + + // Tracks when we need to break out of the keypress loop for SSH + let sshTarget: string | null = null; + let shouldQuit = false; + + try { + // Outer loop — allows re-entering the keypress reader after SSH sessions. + // SSH needs to take over stdin completely, so we break the inner loop + // (which releases the reader lock), do SSH, then start reading again. + while (!shouldQuit) { + // Main input loop — reads keypresses one at a time + for await (const key of readKeypress()) { + if (key === "q" || key === "ctrl+c") { + shouldQuit = true; + break; + } + + // When we're in extend mode, only accept 1-4 or Esc + if (state.mode === "extend") { + const durations: Record = { + "1": "5m", + "2": "15m", + "3": "30m", + "4": "60m", + }; + const durationLabels: Record = { + "1": "5m", + "2": "15m", + "3": "30m", + "4": "1h", + }; + if (key in durations) { + const selected = getSelectedSandbox(state); + if (selected && selected.status === "running") { + try { + const token = await getAuth(options, true); + await using sandbox = await Sandbox.connect({ + id: selected.id, + apiEndpoint: options.endpoint, + debug: options.debug, + token, + org, + }); + await sandbox.extendTimeout(durations[key]); + state.statusMessage = + `Extended timeout by ${durationLabels[key]}`; + } catch (e) { + state.error = (e as Error).message; + } + await refreshAndRender(); + } + } + state.mode = "normal"; + Deno.stdout.writeSync(encoder.encode(renderScreen(state))); + continue; + } + + // When we're in org mode, only accept ↑/↓/Enter/Esc + if (state.mode === "org") { + if (key === "up") { + if (state.orgSelectedIndex > 0) { + state.orgSelectedIndex--; + } + } else if (key === "down") { + if (state.orgSelectedIndex < state.orgs.length - 1) { + state.orgSelectedIndex++; + } + } else if (key === "enter") { + const selected = state.orgs[state.orgSelectedIndex]; + state.org = selected.slug; + state.selectedIndex = 0; + state.regionFilter = null; + state.mode = "normal"; + await refreshAndRender(); + } else if (key === "escape") { + state.mode = "normal"; + } + Deno.stdout.writeSync(encoder.encode(renderScreen(state))); + continue; + } + + if (key === "up") { + const filtered = getFilteredSandboxes(state); + if (state.selectedIndex > 0) { + state.selectedIndex--; + } + // Clamp to filtered list length + if (filtered.length > 0) { + state.selectedIndex = Math.min( + state.selectedIndex, + filtered.length - 1, + ); + } + } else if (key === "down") { + const filtered = getFilteredSandboxes(state); + if (state.selectedIndex < filtered.length - 1) { + state.selectedIndex++; + } + } else if (key === "r") { + await refreshAndRender(); + continue; + } else if (key === "k") { + const selected = getSelectedSandbox(state); + if (selected && selected.status === "running") { + state.loading = true; + Deno.stdout.writeSync(encoder.encode(renderScreen(state))); + + const result = await killSandbox(client, org, selected.id); + if (result.error) { + state.error = result.error; + } + state.loading = false; + + await refreshAndRender(); + continue; + } + } else if (key === "s") { + // SSH — break out of keypress loop to hand terminal to SSH + const selected = getSelectedSandbox(state); + if (selected && selected.status === "running") { + sshTarget = selected.id; + break; + } + } else if (key === "e") { + // Enter extend mode — shows duration picker in footer + const selected = getSelectedSandbox(state); + if (selected && selected.status === "running") { + state.mode = "extend"; + } + } else if (key === "f") { + // Cycle region filter: all → region1 → region2 → ... → all + const regions = [ + ...new Set( + state.sandboxes.map((s) => s.cluster_hostname.split(".")[0]), + ), + ].sort(); + + if (state.regionFilter === null) { + // Currently showing all — switch to first region + if (regions.length > 0) { + state.regionFilter = regions[0]; + } + } else { + const idx = regions.indexOf(state.regionFilter); + if (idx < regions.length - 1) { + state.regionFilter = regions[idx + 1]; + } else { + state.regionFilter = null; + } + } + + // Clamp selection to filtered list + const filtered = getFilteredSandboxes(state); + if (filtered.length > 0) { + state.selectedIndex = Math.min( + state.selectedIndex, + filtered.length - 1, + ); + } else { + state.selectedIndex = 0; + } + } else if (key === "o") { + // Cycle sort: created → status → region → created + const order: Array<"created" | "status" | "region"> = [ + "created", + "status", + "region", + ]; + const idx = order.indexOf(state.sortBy); + state.sortBy = order[(idx + 1) % order.length]; + } else if (key === "O") { + // Toggle sort direction between ascending and descending + state.sortAsc = !state.sortAsc; + } else if (key === "t") { + // Open org picker — only if there are multiple orgs to choose from + if (state.orgs.length > 1) { + state.mode = "org"; + // Start with the current org highlighted + const currentIdx = state.orgs.findIndex((o) => + o.slug === state.org + ); + state.orgSelectedIndex = currentIdx >= 0 ? currentIdx : 0; + } + } else if (key === "c") { + // Copy selected sandbox ID to clipboard + const selected = getSelectedSandbox(state); + if (selected) { + try { + await copyToClipboard(selected.id); + state.statusMessage = `Copied: ${selected.id}`; + } catch (e) { + state.statusMessage = `Copy failed: ${(e as Error).message}`; + } + } + } + + Deno.stdout.writeSync(encoder.encode(renderScreen(state))); + // Clear status message after it's been rendered so the next + // keypress starts with a clean footer + state.statusMessage = null; + } + + // Handle SSH — we're outside the keypress loop now, + // so stdin's reader lock has been released + if (sshTarget) { + const targetId = sshTarget; + sshTarget = null; + + // Pause auto-refresh while SSH is running + if (refreshTimer) clearInterval(refreshTimer); + + // Restore terminal for SSH (exit raw mode, show cursor, clear screen) + restoreTerminal(); + Deno.stdout.writeSync(encoder.encode(CLEAR_SCREEN + CURSOR_HOME)); + + try { + const token = await getAuth(options, true); + await using sandbox = await Sandbox.connect({ + id: targetId, + apiEndpoint: options.endpoint, + debug: options.debug, + token, + org, + }); + + const ssh = await sandbox.exposeSsh(); + const connectInfo = ssh.username + "@" + ssh.hostname; + + console.log(`ssh ${connectInfo}`); + const sshProcess = new Deno.Command("ssh", { + args: [connectInfo], + stdin: "inherit", + stdout: "inherit", + stderr: "inherit", + }).spawn(); + await sshProcess.output(); + } catch (e) { + state.error = (e as Error).message; + } + + // Re-enter raw mode and restart the dashboard + Deno.stdin.setRaw(true); + Deno.stdout.writeSync(encoder.encode(HIDE_CURSOR)); + + // Refresh data since things may have changed during SSH + await refreshAndRender(); + + // Restart auto-refresh + refreshTimer = setInterval(refreshAndRender, 5000); + } + } + } finally { + if (refreshTimer) clearInterval(refreshTimer); + Deno.removeSignalListener("SIGWINCH", resizeHandler); + restoreTerminal(); + Deno.stdout.writeSync(encoder.encode(CLEAR_SCREEN + CURSOR_HOME)); + } +} + +// --- Cliffy command export --- + +// This follows the exact same pattern as all other commands in the project. +// actionHandler wraps the function with config loading and error handling. +export const sandboxDashboardCommand = new Command() + .description("Interactive dashboard for browsing and managing sandboxes") + .action(actionHandler(async (config, options) => { + config.noCreate(); + + // If the terminal isn't interactive (e.g. output is piped), + // fall back to the regular list output instead of the TUI + if (!Deno.stdin.isTerminal()) { + console.error( + "Dashboard requires an interactive terminal. Use 'sandbox list' instead.", + ); + Deno.exit(1); + } + + const org = await getOrg(options, config, options.org); + const client = createTrpcClient(options, true); + + await runDashboard(client, org, options); + })); diff --git a/sandbox/mod.ts b/sandbox/mod.ts index 289ed61..dc4263b 100644 --- a/sandbox/mod.ts +++ b/sandbox/mod.ts @@ -22,6 +22,7 @@ import { createSwitchCommand, type GlobalContext } from "../main.ts"; import { volumesCommand } from "./volumes.ts"; import { snapshotsCommand } from "./snapshot.ts"; +import { sandboxDashboardCommand } from "./dashboard.ts"; import { actionHandler, type ConfigContext, getOrg } from "../config.ts"; export type SandboxContext = GlobalContext & { @@ -626,4 +627,5 @@ export const sandboxCommand = new Command() .command("deploy", sandboxDeployCommand) .command("volumes", volumesCommand) .command("snapshots", snapshotsCommand) + .command("dashboard", sandboxDashboardCommand) .command("switch", createSwitchCommand(false)); From 6df32925f274768767bc245fecb6a338361dc97a Mon Sep 17 00:00:00 2001 From: John Donmoyer Date: Mon, 23 Feb 2026 17:02:57 -0500 Subject: [PATCH 2/5] style: polish sandbox dashboard with colors, icons, and visual hierarchy MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add cyan/yellow header, box-drawing separator, status dot indicators (● running / ○ stopped), scroll arrows (▲/▼), bold shortcut keys, styled org picker, and ✓/✗ status message icons. --- sandbox/dashboard.ts | 65 ++++++++++++++++++++++++++++++-------------- 1 file changed, 44 insertions(+), 21 deletions(-) diff --git a/sandbox/dashboard.ts b/sandbox/dashboard.ts index 95cbe68..068fa1d 100644 --- a/sandbox/dashboard.ts +++ b/sandbox/dashboard.ts @@ -1,5 +1,13 @@ import { Command } from "@cliffy/command"; -import { bold, dim, green, red, stripAnsiCode } from "@std/fmt/colors"; +import { + bold, + cyan, + dim, + green, + red, + stripAnsiCode, + yellow, +} from "@std/fmt/colors"; import { Sandbox } from "@deno/sandbox"; import { formatDuration, renderTemporalTimestamp } from "../util.ts"; @@ -108,8 +116,8 @@ function renderScreen(state: DashboardState): string { const displayList = sortSandboxes(filtered, state.sortBy, state.sortAsc); // Header - const title = bold(" Sandbox Dashboard"); - const orgLabel = dim(`Org: ${state.org}`); + const title = bold(cyan(" Sandbox Dashboard")); + const orgLabel = yellow(`Org: ${state.org}`); const headerPadding = columns - stripAnsiCode(title).length - stripAnsiCode(orgLabel).length; lines.push(title + " ".repeat(Math.max(1, headerPadding)) + orgLabel); @@ -138,7 +146,7 @@ function renderScreen(state: DashboardState): string { stripAnsiCode(timeStr).length; lines.push(summary + " ".repeat(Math.max(1, summaryPadding)) + timeStr); - lines.push(""); + lines.push(dim("─".repeat(columns))); // How many rows are available for list items (sandboxes or orgs)? // We subtract: header (3 lines above), table/org header (1 line), footer (2 lines). @@ -147,12 +155,13 @@ function renderScreen(state: DashboardState): string { if (state.mode === "org") { // Org picker — replaces the sandbox table when choosing an org + lines.push(bold(cyan(" Select Organization"))); lines.push(dim(" " + "NAME".padEnd(30) + " " + "SLUG")); const { start, end } = getVisibleRange( state.orgs.length, state.orgSelectedIndex, - maxVisibleRows, + maxVisibleRows - 1, ); for (let i = start; i < end; i++) { @@ -160,7 +169,7 @@ function renderScreen(state: DashboardState): string { const isHighlighted = i === state.orgSelectedIndex; const isActive = org.slug === state.org; const marker = isHighlighted ? ">" : " "; - const activeMarker = isActive ? " *" : ""; + const activeMarker = isActive ? " " + green("●") : ""; const row = ` ${marker} ${org.name.padEnd(30)} ${ dim(org.slug) @@ -174,9 +183,11 @@ function renderScreen(state: DashboardState): string { } if (state.orgs.length > maxVisibleRows) { - lines.push( - dim(` [${start + 1}–${end} of ${state.orgs.length}]`), - ); + const parts: string[] = []; + if (start > 0) parts.push(dim("▲ more above")); + parts.push(yellow(`[${start + 1}–${end} of ${state.orgs.length}]`)); + if (end < state.orgs.length) parts.push(dim("▼ more below")); + lines.push(" " + parts.join(" ")); } } else { // Normal sandbox table @@ -233,8 +244,8 @@ function renderScreen(state: DashboardState): string { const marker = isSelected ? ">" : " "; const region = sandbox.cluster_hostname.split(".")[0]; const statusText = sandbox.status === "running" - ? green(sandbox.status) - : red(sandbox.status); + ? green("● running") + : red("○ stopped"); const uptime = formatDuration(duration); const created = renderTemporalTimestamp( new Date(sandbox.created_at).toISOString(), @@ -260,11 +271,14 @@ function renderScreen(state: DashboardState): string { } } - // Show a scroll indicator when the list doesn't fit on one screen + // Show scroll indicators when the list doesn't fit on one screen. + // ▲/▼ arrows only appear when there's content in that direction. if (displayList.length > maxVisibleRows) { - lines.push( - dim(` [${start + 1}–${end} of ${displayList.length}]`), - ); + const parts: string[] = []; + if (start > 0) parts.push(dim("▲ more above")); + parts.push(yellow(`[${start + 1}–${end} of ${displayList.length}]`)); + if (end < displayList.length) parts.push(dim("▼ more below")); + lines.push(" " + parts.join(" ")); } } } @@ -288,9 +302,9 @@ function renderScreen(state: DashboardState): string { bold(" Select org: ") + dim("↑/↓ Navigate Enter Select Esc Cancel"), ); } else if (state.error) { - lines.push(red(` Error: ${state.error}`)); + lines.push(red(` ✗ Error: ${state.error}`)); } else if (state.statusMessage) { - lines.push(green(` ${state.statusMessage}`)); + lines.push(green(` ✓ ${state.statusMessage}`)); } else if (state.loading) { lines.push(dim(" Refreshing...")); } else { @@ -298,10 +312,19 @@ function renderScreen(state: DashboardState): string { } const shortcuts = state.mode === "org" - ? dim(" * = active org") - : dim( - " ↑/↓ Navigate s SSH k Kill e Extend c Copy ID f Filter o/O Sort t Org r Refresh q Quit", - ); + ? " " + green("●") + dim(" = active org") + : " " + [ + bold("↑/↓") + dim(" Navigate"), + bold("s") + dim(" SSH"), + bold("k") + dim(" Kill"), + bold("e") + dim(" Extend"), + bold("c") + dim(" Copy ID"), + bold("f") + dim(" Filter"), + bold("o/O") + dim(" Sort"), + bold("t") + dim(" Org"), + bold("r") + dim(" Refresh"), + bold("q") + dim(" Quit"), + ].join(" "); lines.push(shortcuts); return CLEAR_SCREEN + CURSOR_HOME + lines.join("\n"); From 7b4297533bbfd4c3e9be39f2e898a26970003e2a Mon Sep 17 00:00:00 2001 From: John Donmoyer Date: Tue, 24 Feb 2026 10:57:45 -0500 Subject: [PATCH 3/5] feat: add labels column, filter, and sort to sandbox dashboard MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Show a LABELS column in the sandbox table (space-separated key=value pairs, truncated with … at 24 chars) - Press l to cycle through label filters (same pattern as f for region) - Press o to cycle sort now includes label sort (alphabetical by first label, empty last) - Fix column header alignment — all headers now align with their data columns --- sandbox/dashboard.ts | 97 ++++++++++++++++++++++++++++++++++++-------- 1 file changed, 80 insertions(+), 17 deletions(-) diff --git a/sandbox/dashboard.ts b/sandbox/dashboard.ts index 068fa1d..bc18bda 100644 --- a/sandbox/dashboard.ts +++ b/sandbox/dashboard.ts @@ -23,6 +23,7 @@ interface SandboxInfo { created_at: Date; stopped_at: Date | null; cluster_hostname: string; + labels?: Record; } interface OrgInfo { @@ -39,7 +40,8 @@ interface DashboardState { loading: boolean; lastRefresh: Date; regionFilter: string | null; - sortBy: "created" | "status" | "region"; + labelFilter: string | null; + sortBy: "created" | "status" | "region" | "label"; sortAsc: boolean; mode: "normal" | "extend" | "org"; statusMessage: string | null; @@ -134,6 +136,7 @@ function renderScreen(state: DashboardState): string { let summary = ` ${total} total`; if (parts.length > 0) summary += ` — ${parts.join(", ")}`; if (state.regionFilter) summary += dim(` (region: ${state.regionFilter})`); + if (state.labelFilter) summary += dim(` (label: ${state.labelFilter})`); const sortArrow = state.sortAsc ? "↑" : "↓"; summary += dim(` Sort: ${state.sortBy} ${sortArrow}`); @@ -191,8 +194,8 @@ function renderScreen(state: DashboardState): string { } } else { // Normal sandbox table - const headers = ["", "ID", "REGION", "STATUS", "UPTIME", "CREATED"]; - const colWidths = [2, 16, 10, 10, 10, 22]; + const headers = ["", "ID", "REGION", "STATUS", "UPTIME", "CREATED", "LABELS"]; + const colWidths = [2, 16, 10, 10, 10, 22, 24]; // Calculate column widths based on actual data for (const sandbox of displayList) { @@ -201,19 +204,21 @@ function renderScreen(state: DashboardState): string { colWidths[2] = Math.max(colWidths[2], region.length); } - const headerLine = " " + headers.map((h, i) => - dim(h.padEnd(colWidths[i])) + const headerLine = " " + headers.slice(1).map((h, i) => + dim(h.padEnd(colWidths[i + 1])) ).join(" "); lines.push(headerLine); // Sandbox rows — render the filtered+sorted list if (displayList.length === 0 && !state.loading) { lines.push(""); - if (state.regionFilter) { + if (state.regionFilter || state.labelFilter) { + const filterDesc = [ + state.regionFilter ? `region "${state.regionFilter}"` : null, + state.labelFilter ? `label "${state.labelFilter}"` : null, + ].filter(Boolean).join(", "); lines.push( - dim( - ` No sandboxes in region "${state.regionFilter}". Press f to cycle filters.`, - ), + dim(` No sandboxes match ${filterDesc}. Press f/l to cycle filters.`), ); } else { lines.push( @@ -258,11 +263,20 @@ function renderScreen(state: DashboardState): string { Math.max(0, colWidths[3] - stripAnsiCode(statusText).length), ); + const labelEntries = Object.entries(sandbox.labels ?? {}); + let labelsStr = labelEntries.length > 0 + ? labelEntries.map(([k, v]) => `${k}=${v}`).join(" ") + : "—"; + if (labelsStr.length > colWidths[6]) { + labelsStr = labelsStr.slice(0, colWidths[6] - 1) + "…"; + } + const row = ` ${marker} ${sandbox.id.padEnd(colWidths[1])} ` + `${region.padEnd(colWidths[2])} ` + `${statusPadded} ` + `${uptime.padEnd(colWidths[4])} ` + - `${created}`; + `${created.padEnd(colWidths[5])} ` + + `${labelsStr}`; if (isSelected) { lines.push(INVERSE + row + RESET_STYLE); @@ -320,6 +334,7 @@ function renderScreen(state: DashboardState): string { bold("e") + dim(" Extend"), bold("c") + dim(" Copy ID"), bold("f") + dim(" Filter"), + bold("l") + dim(" Label"), bold("o/O") + dim(" Sort"), bold("t") + dim(" Org"), bold("r") + dim(" Refresh"), @@ -334,7 +349,7 @@ function renderScreen(state: DashboardState): string { // Returns a new array — doesn't modify the original. function sortSandboxes( sandboxes: SandboxInfo[], - sortBy: "created" | "status" | "region", + sortBy: "created" | "status" | "region" | "label", asc: boolean, ): SandboxInfo[] { const sorted = [...sandboxes]; @@ -361,6 +376,19 @@ function sortSandboxes( ) ); break; + case "label": + // Alphabetical by first label key=value; empty labels sort last + sorted.sort((a, b) => { + const aLabel = Object.entries(a.labels ?? {})[0]; + const bLabel = Object.entries(b.labels ?? {})[0]; + const aStr = aLabel ? `${aLabel[0]}=${aLabel[1]}` : ""; + const bStr = bLabel ? `${bLabel[0]}=${bLabel[1]}` : ""; + if (!aStr && !bStr) return 0; + if (!aStr) return 1; + if (!bStr) return -1; + return aStr.localeCompare(bStr); + }); + break; } if (asc) sorted.reverse(); return sorted; @@ -399,6 +427,7 @@ async function* readKeypress(): AsyncGenerator { else if (byte === 0x6f) yield "o"; // Order/sort else if (byte === 0x4f) yield "O"; // Toggle sort direction else if (byte === 0x63) yield "c"; // Copy + else if (byte === 0x6c) yield "l"; // Label filter else if (byte === 0x74) yield "t"; // Team/org picker else if (byte === 0x0d) yield "enter"; // Enter/Return else if (byte === 0x31) yield "1"; // Extend presets @@ -421,10 +450,17 @@ async function* readKeypress(): AsyncGenerator { // Returns the list of sandboxes after applying the region filter. // Used by both the key handlers (for navigation bounds) and renderScreen. function getFilteredSandboxes(state: DashboardState): SandboxInfo[] { - if (state.regionFilter === null) return state.sandboxes; - return state.sandboxes.filter( - (s) => s.cluster_hostname.split(".")[0] === state.regionFilter, - ); + let list = state.sandboxes; + if (state.regionFilter !== null) { + list = list.filter( + (s) => s.cluster_hostname.split(".")[0] === state.regionFilter, + ); + } + if (state.labelFilter !== null) { + const [key, value] = state.labelFilter.split("="); + list = list.filter((s) => s.labels?.[key] === value); + } + return list; } // Gets the sandbox that's currently highlighted, accounting for the region filter. @@ -539,6 +575,7 @@ async function runDashboard( loading: true, lastRefresh: new Date(), regionFilter: null, + labelFilter: null, sortBy: "created", sortAsc: false, mode: "normal", @@ -682,6 +719,7 @@ async function runDashboard( state.org = selected.slug; state.selectedIndex = 0; state.regionFilter = null; + state.labelFilter = null; state.mode = "normal"; await refreshAndRender(); } else if (key === "escape") { @@ -771,12 +809,37 @@ async function runDashboard( } else { state.selectedIndex = 0; } + } else if (key === "l") { + // Cycle label filter: all → label1 → label2 → ... → all + const labelPairs = [ + ...new Set( + state.sandboxes.flatMap((s) => + Object.entries(s.labels ?? {}).map(([k, v]) => `${k}=${v}`) + ), + ), + ].sort(); + + if (state.labelFilter === null) { + if (labelPairs.length > 0) state.labelFilter = labelPairs[0]; + } else { + const idx = labelPairs.indexOf(state.labelFilter); + state.labelFilter = idx < labelPairs.length - 1 + ? labelPairs[idx + 1] + : null; + } + + // Clamp selection to filtered list + const filteredAfterLabel = getFilteredSandboxes(state); + state.selectedIndex = filteredAfterLabel.length > 0 + ? Math.min(state.selectedIndex, filteredAfterLabel.length - 1) + : 0; } else if (key === "o") { - // Cycle sort: created → status → region → created - const order: Array<"created" | "status" | "region"> = [ + // Cycle sort: created → status → region → label → created + const order: Array<"created" | "status" | "region" | "label"> = [ "created", "status", "region", + "label", ]; const idx = order.indexOf(state.sortBy); state.sortBy = order[(idx + 1) % order.length]; From 70f410c8371383f180278e6d76c7315402bdb2f0 Mon Sep 17 00:00:00 2001 From: John Donmoyer Date: Thu, 12 Mar 2026 10:50:11 -0400 Subject: [PATCH 4/5] feat: full-width labels column and contextual label filter picker Make the LABELS column fill remaining terminal width instead of being hardcoded to 24 chars. Display multiple labels with pipe separators for clarity. Replace the global label cycling (l key) with a footer picker that shows the selected sandbox's labels and lets you filter by one using arrow keys. --- sandbox/dashboard.ts | 92 ++++++++++++++++++++++++++++++++------------ 1 file changed, 68 insertions(+), 24 deletions(-) diff --git a/sandbox/dashboard.ts b/sandbox/dashboard.ts index bc18bda..a11ba6b 100644 --- a/sandbox/dashboard.ts +++ b/sandbox/dashboard.ts @@ -43,10 +43,12 @@ interface DashboardState { labelFilter: string | null; sortBy: "created" | "status" | "region" | "label"; sortAsc: boolean; - mode: "normal" | "extend" | "org"; + mode: "normal" | "extend" | "org" | "label"; statusMessage: string | null; orgs: OrgInfo[]; orgSelectedIndex: number; + labelPickerItems: string[]; + labelPickerIndex: number; } // --- ANSI escape helpers --- @@ -204,6 +206,12 @@ function renderScreen(state: DashboardState): string { colWidths[2] = Math.max(colWidths[2], region.length); } + // Make the LABELS column fill the remaining terminal width. + // 3 chars for the leading " > " marker, plus 2 chars between each of the 7 columns. + const fixedColumnsWidth = colWidths.slice(0, 6).reduce((sum, w) => sum + w, 0); + const gutterWidth = 3 + (6 * 2); + colWidths[6] = Math.max(10, columns - fixedColumnsWidth - gutterWidth); + const headerLine = " " + headers.slice(1).map((h, i) => dim(h.padEnd(colWidths[i + 1])) ).join(" "); @@ -265,7 +273,7 @@ function renderScreen(state: DashboardState): string { const labelEntries = Object.entries(sandbox.labels ?? {}); let labelsStr = labelEntries.length > 0 - ? labelEntries.map(([k, v]) => `${k}=${v}`).join(" ") + ? labelEntries.map(([k, v]) => `${k}=${v}`).join(" | ") : "—"; if (labelsStr.length > colWidths[6]) { labelsStr = labelsStr.slice(0, colWidths[6] - 1) + "…"; @@ -315,6 +323,11 @@ function renderScreen(state: DashboardState): string { lines.push( bold(" Select org: ") + dim("↑/↓ Navigate Enter Select Esc Cancel"), ); + } else if (state.mode === "label") { + const items = state.labelPickerItems.map((item, i) => + i === state.labelPickerIndex ? bold(`[${item}]`) : dim(item) + ).join(" "); + lines.push(bold(" Filter by label: ") + items); } else if (state.error) { lines.push(red(` ✗ Error: ${state.error}`)); } else if (state.statusMessage) { @@ -327,6 +340,8 @@ function renderScreen(state: DashboardState): string { const shortcuts = state.mode === "org" ? " " + green("●") + dim(" = active org") + : state.mode === "label" + ? " " + bold("←/→") + dim(" Select") + " " + bold("Enter") + dim(" Apply") + " " + bold("Esc") + dim(" Cancel") : " " + [ bold("↑/↓") + dim(" Navigate"), bold("s") + dim(" SSH"), @@ -334,7 +349,7 @@ function renderScreen(state: DashboardState): string { bold("e") + dim(" Extend"), bold("c") + dim(" Copy ID"), bold("f") + dim(" Filter"), - bold("l") + dim(" Label"), + bold("l") + dim(" Filter label"), bold("o/O") + dim(" Sort"), bold("t") + dim(" Org"), bold("r") + dim(" Refresh"), @@ -438,6 +453,8 @@ async function* readKeypress(): AsyncGenerator { // Arrow key escape sequences: ESC [ A/B/C/D if (buf[2] === 0x41) yield "up"; if (buf[2] === 0x42) yield "down"; + if (buf[2] === 0x43) yield "right"; + if (buf[2] === 0x44) yield "left"; } } } finally { @@ -582,6 +599,8 @@ async function runDashboard( statusMessage: null, orgs: [], orgSelectedIndex: 0, + labelPickerItems: [], + labelPickerIndex: 0, }; // Initial data fetch @@ -704,6 +723,39 @@ async function runDashboard( continue; } + // When we're in label picker mode, only accept ←/→/Enter/Esc + if (state.mode === "label") { + if (key === "left") { + if (state.labelPickerIndex > 0) { + state.labelPickerIndex--; + } + } else if (key === "right") { + if (state.labelPickerIndex < state.labelPickerItems.length - 1) { + state.labelPickerIndex++; + } + } else if (key === "enter") { + const picked = state.labelPickerItems[state.labelPickerIndex]; + if (picked === "Clear filter") { + state.labelFilter = null; + } else { + state.labelFilter = picked; + } + state.selectedIndex = 0; + const filtered = getFilteredSandboxes(state); + if (filtered.length > 0) { + state.selectedIndex = Math.min( + state.selectedIndex, + filtered.length - 1, + ); + } + state.mode = "normal"; + } else if (key === "escape") { + state.mode = "normal"; + } + Deno.stdout.writeSync(encoder.encode(renderScreen(state))); + continue; + } + // When we're in org mode, only accept ↑/↓/Enter/Esc if (state.mode === "org") { if (key === "up") { @@ -810,29 +862,21 @@ async function runDashboard( state.selectedIndex = 0; } } else if (key === "l") { - // Cycle label filter: all → label1 → label2 → ... → all - const labelPairs = [ - ...new Set( - state.sandboxes.flatMap((s) => - Object.entries(s.labels ?? {}).map(([k, v]) => `${k}=${v}`) - ), - ), - ].sort(); - - if (state.labelFilter === null) { - if (labelPairs.length > 0) state.labelFilter = labelPairs[0]; + // Open label picker for the selected sandbox's labels + const selected = getSelectedSandbox(state); + if (!selected || Object.keys(selected.labels ?? {}).length === 0) { + state.statusMessage = "No labels on selected sandbox"; } else { - const idx = labelPairs.indexOf(state.labelFilter); - state.labelFilter = idx < labelPairs.length - 1 - ? labelPairs[idx + 1] - : null; + const items = Object.entries(selected.labels!).map(([k, v]) => + `${k}=${v}` + ); + if (state.labelFilter !== null) { + items.push("Clear filter"); + } + state.labelPickerItems = items; + state.labelPickerIndex = 0; + state.mode = "label"; } - - // Clamp selection to filtered list - const filteredAfterLabel = getFilteredSandboxes(state); - state.selectedIndex = filteredAfterLabel.length > 0 - ? Math.min(state.selectedIndex, filteredAfterLabel.length - 1) - : 0; } else if (key === "o") { // Cycle sort: created → status → region → label → created const order: Array<"created" | "status" | "region" | "label"> = [ From 27bab6694ad3cf53779d5d8cb1a8351a4639511e Mon Sep 17 00:00:00 2001 From: John Donmoyer Date: Fri, 13 Mar 2026 10:21:40 -0400 Subject: [PATCH 5/5] feat: add live event tail view (T key) to sandbox dashboard --- sandbox/dashboard.ts | 264 ++++++++++++++++++++++++++++++++++++++++++- 1 file changed, 260 insertions(+), 4 deletions(-) diff --git a/sandbox/dashboard.ts b/sandbox/dashboard.ts index a11ba6b..e6c3552 100644 --- a/sandbox/dashboard.ts +++ b/sandbox/dashboard.ts @@ -17,6 +17,19 @@ import type { SandboxContext } from "./mod.ts"; // --- Types --- +// Represents a single event from a sandbox's activity log (HTTP requests, +// process spawns, file operations, etc.). These come from ClickHouse via +// the sandboxes.events tRPC query. +interface SandboxEvent { + timestamp: string; + event_name: string; + body: string; + severity_text: string; + severity_number: number; + log_attributes: Record; + resource_attributes: Record; +} + interface SandboxInfo { id: string; status: "running" | "stopped"; @@ -43,12 +56,16 @@ interface DashboardState { labelFilter: string | null; sortBy: "created" | "status" | "region" | "label"; sortAsc: boolean; - mode: "normal" | "extend" | "org" | "label"; + mode: "normal" | "extend" | "org" | "label" | "events"; statusMessage: string | null; orgs: OrgInfo[]; orgSelectedIndex: number; labelPickerItems: string[]; labelPickerIndex: number; + eventsLog: SandboxEvent[]; + eventsSandboxId: string | null; + eventsAutoScroll: boolean; + eventsScrollOffset: number; } // --- ANSI escape helpers --- @@ -105,6 +122,102 @@ async function killSandbox( } } +// --- Event formatting --- + +// Takes a sandbox event and returns a single colored line for the events view. +// Strips the "deno.sandbox." prefix from event names and maps common events +// to human-readable summaries, similar to the web dashboard's EVENT_METADATA_MAP. +function formatEvent(event: SandboxEvent, columns: number): string { + // Format timestamp as HH:MM:SS.mmm (time only, since events are recent) + const date = new Date(event.timestamp); + const timeStr = date.toLocaleTimeString("en-US", { + hour12: false, + hour: "2-digit", + minute: "2-digit", + second: "2-digit", + }) + "." + String(date.getMilliseconds()).padStart(3, "0"); + + // Strip the "deno.sandbox." prefix to get the short event name + const shortName = event.event_name.replace(/^deno\.sandbox\./, ""); + + // Parse log_attributes for common fields used in summaries + const attrs = event.log_attributes; + + // Map event names to human-readable summaries, grouped by category + let summary: string; + let colorFn: (s: string) => string; + + // Override color to red for error-level events + const isError = event.severity_text === "ERROR"; + + if (shortName === "start") { + summary = "Sandbox started"; + colorFn = cyan; + } else if (shortName === "shutdown") { + summary = "Sandbox shut down"; + colorFn = cyan; + } else if (shortName === "process.spawn") { + const cmd = attrs["command"] ?? ""; + const args = attrs["args"] ?? ""; + summary = `Spawn: ${cmd} ${args}`.trim(); + colorFn = yellow; + } else if (shortName === "process.finished") { + const code = attrs["exit.code"] ?? "?"; + summary = `Process exited (${code})`; + colorFn = yellow; + } else if (shortName === "process.kill") { + const signal = attrs["signal"] ?? "?"; + summary = `Kill process (${signal})`; + colorFn = yellow; + } else if (shortName === "js.runtime.spawn") { + const entrypoint = attrs["entrypoint"] ?? "unknown"; + summary = `Run: ${entrypoint}`; + colorFn = cyan; + } else if (shortName === "js.repl.spawn") { + summary = "Start REPL"; + colorFn = cyan; + } else if (shortName === "js.http.ready") { + const pid = attrs["pid"] ?? "?"; + summary = `HTTP ready (PID: ${pid})`; + colorFn = cyan; + } else if (shortName === "fetch") { + const method = attrs["method"] ?? "GET"; + const url = attrs["url"] ?? ""; + summary = `${method} ${url}`; + colorFn = green; + } else if (shortName === "expose.http") { + const domain = attrs["domain"] ?? ""; + const port = attrs["port"] ?? "?"; + summary = `Expose: ${domain} → :${port}`; + colorFn = green; + } else if (shortName.startsWith("fs.")) { + // Filesystem events: fs.read, fs.write, fs.mkdir, fs.remove, fs.rename, etc. + const action = shortName.replace("fs.", "").replace(/^\w/, (c) => + c.toUpperCase() + ); + const path = attrs["path"] ?? ""; + summary = `${action}: ${path}`; + colorFn = dim; + } else { + // Fallback for any unrecognized event + const body = event.body ? ` ${event.body}` : ""; + summary = `${shortName}${body}`; + colorFn = dim; + } + + // Apply error color override + if (isError) colorFn = red; + + // Truncate the summary if it would overflow the terminal width. + // 15 chars for the timestamp column (" HH:MM:SS.mmm ") + const maxSummaryWidth = columns - 17; + if (stripAnsiCode(summary).length > maxSummaryWidth) { + summary = summary.slice(0, maxSummaryWidth - 1) + "…"; + } + + return ` ${dim(timeStr)} ${colorFn(summary)}`; +} + // --- Screen rendering --- // Builds the entire screen as one big string, then writes it all at once. @@ -158,7 +271,58 @@ function renderScreen(state: DashboardState): string { // This keeps header + footer fixed and only the list rows scroll. const maxVisibleRows = rows - 3 - 1 - 2; - if (state.mode === "org") { + if (state.mode === "events") { + // Events tail view — shows a live-updating log of sandbox activity + const eventsTitle = bold(cyan(` Sandbox Events: ${state.eventsSandboxId}`)); + const eventsOrgLabel = yellow(`Org: ${state.org}`); + const eventsTitlePad = columns - stripAnsiCode(eventsTitle).length - + stripAnsiCode(eventsOrgLabel).length; + lines[0] = eventsTitle + " ".repeat(Math.max(1, eventsTitlePad)) + + eventsOrgLabel; + + const countStr = ` ${state.eventsLog.length} events`; + const eventsTimeStr = dim( + `Last refresh: ${ + state.lastRefresh.toLocaleTimeString("en-US", { hour12: false }) + }`, + ); + const countPad = columns - stripAnsiCode(countStr).length - + stripAnsiCode(eventsTimeStr).length; + lines[1] = countStr + " ".repeat(Math.max(1, countPad)) + eventsTimeStr; + + // Render the event lines + const eventCount = state.eventsLog.length; + if (eventCount === 0) { + lines.push(""); + lines.push(dim(" No events yet. Waiting for activity...")); + } else { + // Determine which slice of events to show based on scroll position + let startIdx: number; + if (state.eventsAutoScroll) { + // Stick to the bottom — show the most recent events + startIdx = Math.max(0, eventCount - maxVisibleRows); + } else { + startIdx = Math.max(0, state.eventsScrollOffset); + startIdx = Math.min(startIdx, Math.max(0, eventCount - maxVisibleRows)); + } + const endIdx = Math.min(startIdx + maxVisibleRows, eventCount); + + for (let i = startIdx; i < endIdx; i++) { + lines.push(formatEvent(state.eventsLog[i], columns)); + } + + // Scroll indicators + if (eventCount > maxVisibleRows) { + const parts: string[] = []; + if (startIdx > 0) parts.push(dim("▲ more above")); + parts.push( + yellow(`[${startIdx + 1}–${endIdx} of ${eventCount}]`), + ); + if (endIdx < eventCount) parts.push(dim("▼ more below")); + lines.push(" " + parts.join(" ")); + } + } + } else if (state.mode === "org") { // Org picker — replaces the sandbox table when choosing an org lines.push(bold(cyan(" Select Organization"))); lines.push(dim(" " + "NAME".padEnd(30) + " " + "SLUG")); @@ -314,7 +478,9 @@ function renderScreen(state: DashboardState): string { } // Footer — status messages and shortcut bar - if (state.mode === "extend") { + if (state.mode === "events") { + lines.push(state.error ? red(` ✗ Error: ${state.error}`) : ""); + } else if (state.mode === "extend") { lines.push( bold(" Extend by: ") + "1) 5m 2) 15m 3) 30m 4) 1h " + dim("(Esc cancel)"), @@ -338,7 +504,9 @@ function renderScreen(state: DashboardState): string { lines.push(""); } - const shortcuts = state.mode === "org" + const shortcuts = state.mode === "events" + ? " " + bold("↑/↓") + dim(" Scroll") + " " + bold("Esc") + dim(" Back") + " " + bold("q") + dim(" Quit") + : state.mode === "org" ? " " + green("●") + dim(" = active org") : state.mode === "label" ? " " + bold("←/→") + dim(" Select") + " " + bold("Enter") + dim(" Apply") + " " + bold("Esc") + dim(" Cancel") @@ -352,6 +520,7 @@ function renderScreen(state: DashboardState): string { bold("l") + dim(" Filter label"), bold("o/O") + dim(" Sort"), bold("t") + dim(" Org"), + bold("T") + dim(" Events"), bold("r") + dim(" Refresh"), bold("q") + dim(" Quit"), ].join(" "); @@ -443,6 +612,7 @@ async function* readKeypress(): AsyncGenerator { else if (byte === 0x4f) yield "O"; // Toggle sort direction else if (byte === 0x63) yield "c"; // Copy else if (byte === 0x6c) yield "l"; // Label filter + else if (byte === 0x54) yield "T"; // Events tail view else if (byte === 0x74) yield "t"; // Team/org picker else if (byte === 0x0d) yield "enter"; // Enter/Return else if (byte === 0x31) yield "1"; // Extend presets @@ -601,6 +771,10 @@ async function runDashboard( orgSelectedIndex: 0, labelPickerItems: [], labelPickerIndex: 0, + eventsLog: [], + eventsSandboxId: null, + eventsAutoScroll: true, + eventsScrollOffset: 0, }; // Initial data fetch @@ -627,6 +801,9 @@ async function runDashboard( // Auto-refresh timer — fires every 5 seconds to fetch fresh data let refreshTimer: ReturnType | null = null; + // Events polling timer — fires every 2.5 seconds while in events mode + let eventsTimer: ReturnType | null = null; + // This function refreshes the data and redraws the screen. // Used by both the timer and the manual refresh (r key). const refreshAndRender = async () => { @@ -659,6 +836,23 @@ async function runDashboard( Deno.stdout.writeSync(encoder.encode(renderScreen(state))); }; + // Fetches events for the currently-tailed sandbox and redraws the screen. + // Called on a 2.5-second interval while in events mode. + const fetchEvents = async () => { + if (state.mode !== "events" || !state.eventsSandboxId) return; + try { + const events = await client.query("sandboxes.events", { + org: state.org, + sandboxId: state.eventsSandboxId, + }) as SandboxEvent[]; + state.eventsLog = events; + state.lastRefresh = new Date(); + } catch (e) { + state.error = (e as Error).message; + } + Deno.stdout.writeSync(encoder.encode(renderScreen(state))); + }; + refreshTimer = setInterval(refreshAndRender, 5000); // Re-render on terminal resize so the layout adapts @@ -781,6 +975,42 @@ async function runDashboard( continue; } + // When we're in events mode, only accept ↑/↓/Esc + if (state.mode === "events") { + if (key === "up") { + // Scroll up — disengage auto-scroll so the view stays put + state.eventsAutoScroll = false; + if (state.eventsScrollOffset > 0) { + state.eventsScrollOffset--; + } + } else if (key === "down") { + // Scroll down — re-engage auto-scroll when we reach the bottom + const { rows } = Deno.consoleSize(); + const maxVisibleRows = rows - 3 - 1 - 2; + state.eventsScrollOffset++; + if ( + state.eventsScrollOffset >= + state.eventsLog.length - maxVisibleRows + ) { + state.eventsAutoScroll = true; + } + } else if (key === "escape") { + // Exit events mode — stop events polling, restart sandbox refresh + state.mode = "normal"; + state.eventsSandboxId = null; + state.eventsLog = []; + state.error = null; + if (eventsTimer) { + clearInterval(eventsTimer); + eventsTimer = null; + } + refreshTimer = setInterval(refreshAndRender, 5000); + await refreshAndRender(); + } + Deno.stdout.writeSync(encoder.encode(renderScreen(state))); + continue; + } + if (key === "up") { const filtered = getFilteredSandboxes(state); if (state.selectedIndex > 0) { @@ -900,6 +1130,31 @@ async function runDashboard( ); state.orgSelectedIndex = currentIdx >= 0 ? currentIdx : 0; } + } else if (key === "T") { + // Enter events tail view for the selected sandbox + const selected = getSelectedSandbox(state); + if (selected) { + state.eventsSandboxId = selected.id; + state.eventsLog = []; + state.eventsAutoScroll = true; + state.eventsScrollOffset = 0; + state.mode = "events"; + state.error = null; + + // Pause the sandbox list refresh while viewing events + if (refreshTimer) { + clearInterval(refreshTimer); + refreshTimer = null; + } + + // Fetch events immediately so the view isn't empty + await fetchEvents(); + + // Start polling for new events every 2.5 seconds + eventsTimer = setInterval(fetchEvents, 2500); + } else { + state.statusMessage = "No sandbox selected"; + } } else if (key === "c") { // Copy selected sandbox ID to clipboard const selected = getSelectedSandbox(state); @@ -970,6 +1225,7 @@ async function runDashboard( } } finally { if (refreshTimer) clearInterval(refreshTimer); + if (eventsTimer) clearInterval(eventsTimer); Deno.removeSignalListener("SIGWINCH", resizeHandler); restoreTerminal(); Deno.stdout.writeSync(encoder.encode(CLEAR_SCREEN + CURSOR_HOME));