From 99e5dfedbbf9cdb2fcf235392d56e5e2448c332f Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Sun, 30 Aug 2026 12:54:32 +0000 Subject: [PATCH] fix(desktop): Fleet is a tab, not a modal, and Run cannot scroll away MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fleet shipped in v0.2.8 as a dialog. It is the wrong container and it broke the thing the view exists for: pick the `upgrade` recipe and its fifty-seven lines of shell push the Run button off the bottom of the modal, where nothing can scroll it back. The script itself was five textarea rows, so most of what was about to run on every selected server was hidden too. A modal is for a question you must answer before anything continues. This is somewhere you sit for minutes with a long script in front of you while a dozen servers report — the opposite. So Fleet is now one of two tabs in the header, beside Transfer, and the layout has exactly three scrolling regions: the server list, the script editor, and the results. **The action bar sits outside all three**, pinned to the bottom of the window. Run is on screen at the 960x600 minimum size just as it is maximised. The editor is `clamp(120px, 26vh, 340px)`, resizable, with its own scrollbar and a `57 lines - runs under sh -e` line under it, so the size of what you are about to run is stated rather than discovered. Modals are kept for modality: the destructive-command confirmation is now a small dialog with Cancel / Run it anyway, which is a real blocking yes/no. Also fixes a clipped field found in the screenshots: Timeout was 70px, and the upgrade recipe's default of 3600 rendered as "360C". Verified by screenshotting the built renderer rather than by reading it, per the harness in [[diskpush-desktop-ui-preview]] — mock preload bridge served as an external script, the app's real hashed CSP on the response, playwright-core in headless Chromium. Eight shots: the tab empty, the upgrade recipe at 1360x860 and at the 960x600 minimum, a run in flight with a failure and an unreachable host, the check sweep, the hazard dialog, light theme, and the Transfer tab intact. No CSP violations. One trap worth recording: `page.waitForFunction` compiles its predicate with `eval`, which the real policy refuses, so hydration is polled with `page.evaluate` instead. Bypassing CSP would have hidden exactly what the harness is there to catch. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01UeSWg1Czsb2Lwxj8vHUnA4 --- README.md | 4 +- apps/desktop/src/app/page.tsx | 209 +++--- apps/desktop/src/components/fleet-dialog.tsx | 618 ----------------- apps/desktop/src/components/fleet-view.tsx | 663 +++++++++++++++++++ docs/desktop.md | 66 +- docs/fleet.md | 6 +- 6 files changed, 835 insertions(+), 731 deletions(-) delete mode 100644 apps/desktop/src/components/fleet-dialog.tsx create mode 100644 apps/desktop/src/components/fleet-view.tsx diff --git a/README.md b/README.md index 7ebcc44..d7fb4a5 100644 --- a/README.md +++ b/README.md @@ -20,8 +20,8 @@ diskpush ./data/ prod:/data/ -- --checksum # your own rsync flags - **Never deletes** destination-only files unless you explicitly enable Mirror, and Mirror always shows you the delete list first. - **One command, many servers.** Package upgrades, a health sweep, or a script - you already have — run across a whole tagged fleet, each server reported - separately. + you already have — run across a whole tagged fleet from the Fleet tab or the + CLI, each server reported separately. - **No cloud account, no relay.** For a server-to-server job the payload moves directly between the two servers; DiskPush only orchestrates. diff --git a/apps/desktop/src/app/page.tsx b/apps/desktop/src/app/page.tsx index 2366b3f..fdefa83 100644 --- a/apps/desktop/src/app/page.tsx +++ b/apps/desktop/src/app/page.tsx @@ -3,6 +3,7 @@ import { useCallback, useEffect, useMemo, useState } from 'react' import Image from 'next/image' import { + ArrowLeftRight, CircleAlert, CircleCheck, ExternalLink, @@ -15,7 +16,7 @@ import { X, } from 'lucide-react' import { ConnectionDialog } from '@/components/connection-dialog' -import { FleetDialog } from '@/components/fleet-dialog' +import { FleetView } from '@/components/fleet-view' import { endpointLabel, loadPane, Pane, type PaneEndpoint, type PaneState } from '@/components/pane' import { TransferRail } from '@/components/transfer-rail' import { MirrorPreviewDialog, TransferBand, type ActiveJob } from '@/components/transfer-panel' @@ -38,6 +39,32 @@ function MenuItem({ icon, label, onClick }: { icon: React.ReactNode; label: stri ) } +/** One of the two top-level views. A segmented control, not a link. */ +function TabButton({ + active, + onClick, + children, +}: { + active: boolean + onClick: () => void + children: React.ReactNode +}) { + return ( + + ) +} + const blankPane = (endpoint: PaneEndpoint, path: string): PaneState => ({ endpoint, path, @@ -62,7 +89,7 @@ export default function Workspace() { const [job, setJob] = useState(null) const [error, setError] = useState(null) const [showConnection, setShowConnection] = useState(false) - const [showFleet, setShowFleet] = useState(false) + const [tab, setTab] = useState<'transfer' | 'fleet'>('transfer') const [outsideShell, setOutsideShell] = useState(false) const refreshConnections = useCallback(async () => { @@ -278,20 +305,23 @@ export default function Workspace() { No servers yet )} -
- {/* - Fleet sits beside "New server" rather than inside the menu: it is - the other half of what this app does with a list of servers, and - a feature nobody can find is a feature nobody has. - */} - + + + +
) : null} -
- setActive('left')} - onChange={(patch) => setLeft((current) => ({ ...current, ...patch }))} - onNavigate={(path) => void navigate('left', left.endpoint, path)} - onEndpointChange={(endpoint) => setLeft(blankPane(endpoint, defaultPathFor(endpoint, allConnections)))} - onAddServer={() => setShowConnection(true)} - /> + {/* + One view or the other, never both. The transfer side keeps its own + footer and status band; Fleet brings its own, pinned so the action + it exists for cannot scroll out of reach. + */} + {tab === 'transfer' ? ( + <> +
+ setActive('left')} + onChange={(patch) => setLeft((current) => ({ ...current, ...patch }))} + onNavigate={(path) => void navigate('left', left.endpoint, path)} + onEndpointChange={(endpoint) => setLeft(blankPane(endpoint, defaultPathFor(endpoint, allConnections)))} + onAddServer={() => setShowConnection(true)} + /> + + setMirror((value) => !value)} + onPreview={runPreview} + onRun={run} + /> + + setActive('right')} + onChange={(patch) => setRight((current) => ({ ...current, ...patch }))} + onNavigate={(path) => void navigate('right', right.endpoint, path)} + onEndpointChange={(endpoint) => setRight(blankPane(endpoint, defaultPathFor(endpoint, allConnections)))} + onAddServer={() => setShowConnection(true)} + /> +
- setMirror((value) => !value)} - onPreview={runPreview} - onRun={run} - /> - - setActive('right')} - onChange={(patch) => setRight((current) => ({ ...current, ...patch }))} - onNavigate={(path) => void navigate('right', right.endpoint, path)} - onEndpointChange={(endpoint) => setRight(blankPane(endpoint, defaultPathFor(endpoint, allConnections)))} - onAddServer={() => setShowConnection(true)} + onCancel={() => { + if (job) void api()?.transfers.cancel(job.jobId) + }} /> -
- { - if (job) void api()?.transfers.cancel(job.jobId) - }} - /> - - {/* - This line used to be a fixed string that read like the command being - run but could not change -- turn Mirror on and it still claimed no - deletes. A command line nobody can trust is worse than none, so it is - built from the same state the transfer is. - */} -
- Incremental - · - Archive metadata - · - Resume - · - Deletes {mirror ? 'ON' : 'off'} {/* - The command used to run flush to the window edge and get sliced - mid-token by the truncation, so the last thing in the footer was - always half a word. It keeps a gutter now, and the full string is in - the tooltip. + This line used to be a fixed string that read like the command being + run but could not change -- turn Mirror on and it still claimed no + deletes. A command line nobody can trust is worse than none, so it is + built from the same state the transfer is. */} - - {rsyncFlags} - -
- - setShowFleet(false)} /> +
+ Incremental + · + Archive metadata + · + Resume + · + Deletes {mirror ? 'ON' : 'off'} + {/* + The command used to run flush to the window edge and get sliced + mid-token by the truncation, so the last thing in the footer was + always half a word. It keeps a gutter now, and the full string is in + the tooltip. + */} + + {rsyncFlags} + +
+ + ) : ( + setShowConnection(true)} /> + )} setShowConnection(false)} onSaved={() => void refreshConnections()} /> diff --git a/apps/desktop/src/components/fleet-dialog.tsx b/apps/desktop/src/components/fleet-dialog.tsx deleted file mode 100644 index e8a4491..0000000 --- a/apps/desktop/src/components/fleet-dialog.tsx +++ /dev/null @@ -1,618 +0,0 @@ -'use client' - -import { useCallback, useEffect, useMemo, useRef, useState } from 'react' -import { - CircleAlert, - CircleCheck, - CircleDashed, - Clock, - Loader2, - PlugZap, - Play, - RefreshCw, - Server, - ShieldAlert, - Square, - TriangleAlert, -} from 'lucide-react' -import { Badge } from '@/components/ui/badge' -import { Button } from '@/components/ui/button' -import { Checkbox } from '@/components/ui/checkbox' -import { Dialog, DialogContent } from '@/components/ui/dialog' -import { Input } from '@/components/ui/input' -import { ScrollArea } from '@/components/ui/scroll-area' -import { Textarea } from '@/components/ui/textarea' -import { blankHost, foldHosts, type HostView } from '@/lib/fleet-events' -import { - api, - unwrap, - type Connection, - type FleetCommand, - type FleetEvent, - type FleetHostState, - type Hazard, - type HostUpdateReport, -} from '@/lib/api' - -/** - * Fleet — one command, many servers. - * - * The two-pane view answers "move these bytes there". This answers "do this - * on all of those", and it is built around the same bargain: show exactly - * what will run and exactly where, before it runs, and report each server - * separately afterwards. A run is never summarised as "done" on behalf of a - * host that did not say so. - */ - -const STATE_META: Record = { - pending: { label: 'Waiting', tone: 'text-faint', icon: }, - connecting: { label: 'Connecting', tone: 'text-muted-foreground', icon: }, - running: { label: 'Running', tone: 'text-primary', icon: }, - succeeded: { label: 'Succeeded', tone: 'text-ok', icon: }, - failed: { label: 'Failed', tone: 'text-destructive', icon: }, - unreachable: { label: 'Unreachable', tone: 'text-warn', icon: }, - timeout: { label: 'Timed out', tone: 'text-warn', icon: }, - cancelled: { label: 'Cancelled', tone: 'text-faint', icon: }, - skipped: { label: 'Not run', tone: 'text-faint', icon: }, -} - -export function FleetDialog({ open, onClose }: { open: boolean; onClose: () => void }) { - const [servers, setServers] = useState([]) - const [commands, setCommands] = useState([]) - const [selected, setSelected] = useState>(new Set()) - const [tagFilter, setTagFilter] = useState(null) - - const [script, setScript] = useState('') - const [label, setLabel] = useState('') - const [commandId, setCommandId] = useState(null) - const [interpreter, setInterpreter] = useState<'sh' | 'bash' | 'raw'>('raw') - const [sudo, setSudo] = useState(false) - const [sudoPassword, setSudoPassword] = useState('') - const [askSudoPassword, setAskSudoPassword] = useState(false) - const [concurrency, setConcurrency] = useState(4) - const [timeoutSeconds, setTimeoutSeconds] = useState(900) - const [stopOnError, setStopOnError] = useState(false) - - const [hazards, setHazards] = useState([]) - const [hazardsConfirmed, setHazardsConfirmed] = useState(false) - const [runId, setRunId] = useState(null) - const [hosts, setHosts] = useState([]) - const [checking, setChecking] = useState(false) - const [reports, setReports] = useState(null) - const [error, setError] = useState(null) - const [finished, setFinished] = useState<{ succeeded: number; failed: number; skipped: number } | null>(null) - - const running = runId !== null && finished === null - const logRef = useRef(null) - - const refresh = useCallback(async () => { - try { - const [serverList, commandList] = await Promise.all([ - unwrap(api()?.fleet.servers()), - unwrap(api()?.fleet.commands()), - ]) - setServers(serverList) - setCommands(commandList) - } catch (caught) { - setError(caught instanceof Error ? caught.message : String(caught)) - } - }, []) - - useEffect(() => { - if (open) void refresh() - }, [open, refresh]) - - // One subscription for the life of the dialog. Events for a run other than - // the one on screen are ignored rather than merged, so reopening the dialog - // mid-run does not paint someone else's output into this one. - useEffect(() => { - const bridge = api() - if (!bridge) return - return bridge.events.onFleet(({ runId: incoming, event }) => { - setRunId((current) => { - if (current !== incoming) return current - applyEvent(event, setHosts, setFinished, setError) - return current - }) - }) - }, []) - - useEffect(() => { - // Follow the tail while it runs. Once it stops, leave the scroll where the - // reader put it: yanking them back to the bottom of a finished run is how - // you lose the line you were reading. - if (running && logRef.current) logRef.current.scrollTop = logRef.current.scrollHeight - }, [hosts, running]) - - const tags = useMemo(() => { - const seen = new Map() - for (const server of servers) { - for (const tag of server.tags ?? []) if (!seen.has(tag.toLowerCase())) seen.set(tag.toLowerCase(), tag) - } - return [...seen.values()].sort((a, b) => a.localeCompare(b)) - }, [servers]) - - const visible = useMemo( - () => (tagFilter ? servers.filter((server) => (server.tags ?? []).includes(tagFilter)) : servers), - [servers, tagFilter], - ) - - const toggle = (id: string) => - setSelected((current) => { - const next = new Set(current) - if (next.has(id)) next.delete(id) - else next.add(id) - return next - }) - - const selectAllVisible = () => - setSelected((current) => { - const next = new Set(current) - const everyOn = visible.every((server) => next.has(server.id)) - for (const server of visible) { - if (everyOn) next.delete(server.id) - else next.add(server.id) - } - return next - }) - - const pickCommand = (command: FleetCommand) => { - setScript(command.script) - setLabel(command.name) - setCommandId(command.builtin ? null : command.id) - setInterpreter(command.interpreter) - setSudo(command.sudo) - setTimeoutSeconds(command.timeoutSeconds) - setHazards([]) - setHazardsConfirmed(false) - } - - const requestBody = useCallback( - () => ({ - connectionIds: [...selected], - script, - interpreter, - sudo, - ...(sudo && askSudoPassword && sudoPassword ? { sudoPassword } : {}), - workingDirectory: null, - timeoutSeconds, - concurrency, - onFailure: stopOnError ? ('stop' as const) : ('continue' as const), - hazardsConfirmed, - commandId, - label: label || script.slice(0, 60) || 'command', - }), - [ - selected, - script, - interpreter, - sudo, - askSudoPassword, - sudoPassword, - timeoutSeconds, - concurrency, - stopOnError, - hazardsConfirmed, - commandId, - label, - ], - ) - - const start = useCallback(async () => { - setError(null) - setFinished(null) - setReports(null) - - try { - // Preview first, always. It is the only thing standing between a typo - // and forty servers, and it costs one round trip with no side effects. - const preview = await unwrap(api()?.fleet.preview(requestBody())) - if (preview.hazards.length > 0 && !hazardsConfirmed) { - setHazards(preview.hazards) - return - } - - setHosts(preview.servers.map(blankHost)) - const started = await unwrap(api()?.fleet.start(requestBody())) - setRunId(started.runId) - } catch (caught) { - setError(caught instanceof Error ? caught.message : String(caught)) - } - }, [requestBody, hazardsConfirmed]) - - const check = useCallback(async () => { - setError(null) - setChecking(true) - setReports(null) - try { - setReports(await unwrap(api()?.fleet.check([...selected], concurrency))) - } catch (caught) { - setError(caught instanceof Error ? caught.message : String(caught)) - } finally { - setChecking(false) - } - }, [selected, concurrency]) - - const reset = () => { - setRunId(null) - setHosts([]) - setFinished(null) - setHazards([]) - setHazardsConfirmed(false) - } - - const canRun = selected.size > 0 && script.trim().length > 0 && !running - - return ( - !next && onClose()}> - -
- -
-

Fleet

-

Run one command on many servers.

-
-
- -
-
- - {error ? ( -
- - {error} -
- ) : null} - -
- {/* --- servers ------------------------------------------------- */} - - - {/* --- command and results ------------------------------------- */} -
-
-
- Recipes: - {commands.map((command) => ( - - ))} -
- -