From eca0006471272d2ca83b2a00c4c22a4e3c44b7d7 Mon Sep 17 00:00:00 2001 From: Anthony Ettinger Date: Sat, 17 Oct 2026 11:10:46 +0000 Subject: [PATCH] feat(fleet-sync): show progress detail and the files being sent A 300 GB sync read "0%" for hours with nothing else on screen. Each destination now shows a one-decimal percent, bytes of the estimated total, rate and time left, plus a list of the files it is receiving (newest first, the one in flight on top, capped at 200 rows). Co-Authored-By: Claude Opus 5.5 --- .../src/components/fleet-sync-panel.tsx | 45 ++++++++++++++++--- apps/desktop/src/lib/fleet-sync.test.ts | 38 +++++++++++++++- apps/desktop/src/lib/fleet-sync.ts | 44 ++++++++++++++++++ 3 files changed, 121 insertions(+), 6 deletions(-) diff --git a/apps/desktop/src/components/fleet-sync-panel.tsx b/apps/desktop/src/components/fleet-sync-panel.tsx index 0b062c3..e5bcfea 100644 --- a/apps/desktop/src/components/fleet-sync-panel.tsx +++ b/apps/desktop/src/components/fleet-sync-panel.tsx @@ -5,7 +5,8 @@ import { CircleAlert, CircleCheck, CircleDashed, Copy, HardDriveDownload, Loader import { Button } from '@/components/ui/button' import { Checkbox } from '@/components/ui/checkbox' import { PathInput, type PathSource } from '@/components/path-input' -import { destinationFor, errorReport } from '@/lib/fleet-sync' +import { destinationFor, errorReport, formatPercent, progressDetail, recordFile, type FileRow } from '@/lib/fleet-sync' +import { formatBytes } from '@/lib/format' import { ScrollArea } from '@/components/ui/scroll-area' import { api, unwrap, type Connection, type FleetSyncEvent } from '@/lib/api' @@ -29,6 +30,12 @@ type DestView = { message: string /** rsync's own stderr, kept so a failure shows its real cause, not just the summary. */ log: string[] + /** "0.2% · 570 MB of ~300 GB · 4.1 MB/s · ~20 h left" while it runs. */ + detail: string + /** Files sent (or being sent), newest first; the first one is in flight while it runs. */ + files: FileRow[] + /** How many files this destination has been sent in all, past the list's cap. */ + fileCount: number } const LOG_LINES = 30 @@ -73,7 +80,12 @@ export function FleetSyncPanel() { if (e.type === 'begin') next.state = 'running' else if (e.type === 'progress') { next.state = 'running' - next.percent = Math.max(0, Math.min(100, Math.round(e.progress.percent))) + next.percent = Math.max(0, Math.min(100, e.progress.percent)) + next.detail = progressDetail(e.progress) + } else if (e.type === 'change') { + const before = cur.files.length + next.files = recordFile(cur.files, e.change) + if (next.files.length !== before || next.files[0]?.path !== cur.files[0]?.path) next.fileCount = cur.fileCount + 1 } else if (e.type === 'stderr') { next.message = e.line next.log = [...cur.log, e.line].slice(-LOG_LINES) @@ -116,7 +128,7 @@ export function FleetSyncPanel() { const initial: Record = {} for (const id of ids) { const s = servers.find((x) => x.id === id) - initial[id] = { id, name: s?.name ?? id, host: s?.host ?? '', state: 'pending', percent: 0, message: '', log: [] } + initial[id] = { id, name: s?.name ?? id, host: s?.host ?? '', state: 'pending', percent: 0, message: '', log: [], detail: '', files: [], fileCount: 0 } } setDests(initial) @@ -240,9 +252,32 @@ export function FleetSyncPanel() { style={{ width: `${d.percent}%` }} /> - {d.percent}% - {d.message && d.state !== 'error' ? {d.message} : null} + {formatPercent(d.percent)} + {d.state === 'running' && d.detail ? ( + + {d.detail.split(' · ').slice(1).join(' · ')} + + ) : d.message && d.state !== 'error' ? ( + {d.message} + ) : null} + {d.files.length ? ( +
+ + {d.state === 'running' ? 'Sending' : 'Sent'} {d.fileCount.toLocaleString()} file{d.fileCount === 1 ? '' : 's'} + {d.state === 'running' && d.files[0] ? {d.files[0].path} : null} + +
    + {d.files.map((f, i) => ( +
  • + {f.action === 'delete' ? 'delete' : i === 0 && d.state === 'running' ? 'now' : f.action === 'update' ? 'update' : 'new'} + {f.path} + {f.size === null ? '' : formatBytes(f.size)} +
  • + ))} +
+
+ ) : null} {d.state === 'error' ? (
diff --git a/apps/desktop/src/lib/fleet-sync.test.ts b/apps/desktop/src/lib/fleet-sync.test.ts index e768bd2..b119e8d 100644 --- a/apps/desktop/src/lib/fleet-sync.test.ts +++ b/apps/desktop/src/lib/fleet-sync.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import { destinationFor, errorReport } from './fleet-sync' +import { destinationFor, errorReport, formatPercent, progressDetail, recordFile, type FileRow } from './fleet-sync' describe('errorReport', () => { it('is the summary, the host, and rsync\'s own lines: what a person pastes', () => { @@ -36,3 +36,39 @@ describe('destinationFor', () => { expect(destinationFor('~/Desktop/me/ideas', '/backup/ideas')).toBe('/backup/ideas') }) }) + +describe('progress', () => { + it('shows a decimal below 10%, so a 300 GB transfer does not read 0% for hours', () => { + expect(formatPercent(0.19)).toBe('0.1%') + expect(formatPercent(0)).toBe('0.0%') + expect(formatPercent(42.7)).toBe('42%') + expect(formatPercent(100)).toBe('100%') + }) + + it('works out the total and the time left from bytes, percent and rate', () => { + const line = progressDetail({ percent: 0.19, bytesTransferred: 570e6, bytesPerSecond: 4.2e6 }) + // 570 MB is 0.19% of 300 GB; at 4.2 MB/s the other 299.4 GB take about 19 h 48 m. + expect(line).toMatch(/^0\.1% · 570\.0 MB of ~300\.0 GB · 4 MB\/s · ~19:48:\d\d left$/) + }) + + it('says only what it knows at the very start', () => { + expect(progressDetail({ percent: 0, bytesTransferred: 0, bytesPerSecond: 0 })).toMatch(/^0\.0% · 0/) + }) +}) + +describe('the file list', () => { + it('keeps the newest first, one row per path, capped', () => { + let rows: FileRow[] = [] + rows = recordFile(rows, { action: 'add', path: 'a.mkv', size: 10 }, 3) + rows = recordFile(rows, { action: 'add', path: 'b.mkv', size: 20 }, 3) + rows = recordFile(rows, { action: 'update', path: 'a.mkv', size: 11 }, 3) + rows = recordFile(rows, { action: 'add', path: 'c.mkv', size: 30 }, 3) + rows = recordFile(rows, { action: 'add', path: 'd.mkv', size: 40 }, 3) + expect(rows.map((r) => r.path)).toEqual(['d.mkv', 'c.mkv', 'a.mkv']) + }) + + it('ignores what is not a file being sent', () => { + expect(recordFile([], { action: 'unchanged', path: 'x', size: 1 })).toEqual([]) + expect(recordFile([], { action: 'add', path: 'dir/', size: null, isDirectory: true })).toEqual([]) + }) +}) diff --git a/apps/desktop/src/lib/fleet-sync.ts b/apps/desktop/src/lib/fleet-sync.ts index 269beef..54da92c 100644 --- a/apps/desktop/src/lib/fleet-sync.ts +++ b/apps/desktop/src/lib/fleet-sync.ts @@ -3,6 +3,8 @@ * without the app's module aliases. */ +import { formatBytes, formatDuration, formatRate } from './format' + export type FailedDestination = { name: string; host: string; message: string; log: string[] } /** The text a person pastes into an issue or a chat: what failed, where, and rsync's own words. */ @@ -27,3 +29,45 @@ export function destinationFor(sourcePath: string, destPath: string): string { if (slash === -1) return '.' return source.slice(0, slash + 1) } + +export type ProgressNumbers = { percent: number; bytesTransferred: number; bytesPerSecond: number } + +/** A percentage that does not read 0% for the first hours of a big transfer: one decimal below 10%. */ +export function formatPercent(percent: number): string { + const p = Math.max(0, Math.min(100, percent)) + return p < 10 ? `${(Math.floor(p * 10) / 10).toFixed(1)}%` : `${Math.floor(p)}%` +} + +/** + * The line under a running destination: how much, how fast, how long. rsync's + * progress gives bytes moved and a percentage of the whole, so the total and the + * time left are worked out from those, and only once there is enough to go on. + */ +export function progressDetail(p: ProgressNumbers): string { + const parts = [formatPercent(p.percent)] + const total = p.percent >= 0.1 ? p.bytesTransferred / (p.percent / 100) : null + parts.push(total ? `${formatBytes(p.bytesTransferred)} of ~${formatBytes(total)}` : formatBytes(p.bytesTransferred)) + if (p.bytesPerSecond > 0) { + parts.push(formatRate(p.bytesPerSecond)) + if (total && total > p.bytesTransferred) parts.push(`~${formatDuration((total - p.bytesTransferred) / p.bytesPerSecond)} left`) + } + return parts.join(' · ') +} + +export type FileRow = { path: string; size: number | null; action: 'add' | 'update' | 'delete' } +export const FILE_ROWS = 200 + +/** + * The files a destination is receiving, newest first: rsync itemizes each one as + * it starts, so the top row is the file in flight. Directories and unchanged + * files are not traffic and are left out; a path seen again moves to the top. + */ +export function recordFile( + rows: readonly FileRow[], + change: { action: string; path: string; size?: number | null; isDirectory?: boolean }, + limit = FILE_ROWS, +): FileRow[] { + if (change.isDirectory || !['add', 'update', 'delete'].includes(change.action)) return [...rows] + const row: FileRow = { path: change.path, size: change.size ?? null, action: change.action as FileRow['action'] } + return [row, ...rows.filter((r) => r.path !== change.path)].slice(0, limit) +}