Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 40 additions & 5 deletions apps/desktop/src/components/fleet-sync-panel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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'

Expand All @@ -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
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -116,7 +128,7 @@ export function FleetSyncPanel() {
const initial: Record<string, DestView> = {}
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)

Expand Down Expand Up @@ -240,9 +252,32 @@ export function FleetSyncPanel() {
style={{ width: `${d.percent}%` }}
/>
</div>
<span className="w-10 text-right text-faint">{d.percent}%</span>
{d.message && d.state !== 'error' ? <span className="max-w-[40%] truncate text-faint">{d.message}</span> : null}
<span className="w-12 text-right text-faint tabular-nums">{formatPercent(d.percent)}</span>
{d.state === 'running' && d.detail ? (
<span className="max-w-[45%] truncate text-faint tabular-nums" title={d.detail}>
{d.detail.split(' · ').slice(1).join(' · ')}
</span>
) : d.message && d.state !== 'error' ? (
<span className="max-w-[40%] truncate text-faint">{d.message}</span>
) : null}
</div>
{d.files.length ? (
<details className="mx-2 mb-2 rounded-md border border-line text-[12px]" open={d.state === 'running'}>
<summary className="cursor-pointer px-2 py-1 text-faint select-none">
{d.state === 'running' ? 'Sending' : 'Sent'} {d.fileCount.toLocaleString()} file{d.fileCount === 1 ? '' : 's'}
{d.state === 'running' && d.files[0] ? <span className="ml-2 font-mono text-foreground">{d.files[0].path}</span> : null}
</summary>
<ul className="max-h-40 overflow-auto px-2 pb-1 font-mono text-[11px] selectable">
{d.files.map((f, i) => (
<li key={f.path} className={`flex gap-2 ${i === 0 && d.state === 'running' ? 'text-foreground' : 'text-faint'}`}>
<span className="w-12 shrink-0">{f.action === 'delete' ? 'delete' : i === 0 && d.state === 'running' ? 'now' : f.action === 'update' ? 'update' : 'new'}</span>
<span className="flex-1 truncate" title={f.path}>{f.path}</span>
<span className="shrink-0 tabular-nums">{f.size === null ? '' : formatBytes(f.size)}</span>
</li>
))}
</ul>
</details>
) : null}
{d.state === 'error' ? (
<div className="mx-2 mb-2 rounded-md border border-destructive/40 bg-destructive/5 p-2 text-[12px]" role="alert">
<div className="flex items-start gap-2">
Expand Down
38 changes: 37 additions & 1 deletion apps/desktop/src/lib/fleet-sync.test.ts
Original file line number Diff line number Diff line change
@@ -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', () => {
Expand Down Expand Up @@ -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([])
})
})
44 changes: 44 additions & 0 deletions apps/desktop/src/lib/fleet-sync.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand All @@ -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)
}
Loading