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) => ( - - ))} -
- -