import { useQueryClient } from '@tanstack/react-query'; import { CircleAlertIcon, CircleCheckIcon, LoaderCircleIcon, } from 'lucide-react'; import { useState } from 'react'; import { Badge } from '#/components/atoms/badge.jsx'; import { Button } from '#/components/atoms/button.jsx'; import { Notice } from '#/components/atoms/notice.jsx'; import { Panel } from '#/components/atoms/panel.jsx'; import { RowIcon } from '#/components/atoms/row-icon.jsx'; import { ErrorNotice } from '#/components/molecules/error-notice.jsx'; import { ListRow } from '#/components/molecules/list-row.jsx'; import { Section } from '#/components/molecules/section.jsx'; import { post } from '#/lib/api.js'; import { bytes, num, timeAgo } from '#/lib/format.js'; import { queryKeys } from '#/lib/query.js'; const STATUS = { ok: { icon: CircleCheckIcon, label: 'Backed up' }, error: { icon: CircleAlertIcon, label: 'Backup failed' }, running: { icon: LoaderCircleIcon, label: 'Backing up' }, }; /** * What each backup did, newest first, with the "Back up now" trigger. A manual * run blocks the button until the server answers with the finished run (or an * error), then the whole backups query refreshes so the list shows it. * @param {{data: {runs: object[]}}} props */ export function BackupRunsCard({ data }) { const qc = useQueryClient(); const [busy, setBusy] = useState(false); const [error, setError] = useState(''); const [notice, setNotice] = useState(''); const runs = data.runs || []; const runNow = async () => { setError(''); setNotice(''); setBusy(true); try { const run = await post('/backups/run'); setNotice( `Backup finished — ${bytes(run.bytesUploaded) || '0 B'} uploaded to ${run.snapshot}.`, ); } catch (err) { setError(err instanceof Error ? err.message : String(err)); } finally { setBusy(false); qc.invalidateQueries({ queryKey: queryKeys.backups }); } }; return (
{busy && } {busy ? 'Backing up…' : 'Back up now'} } > {notice && !error && ( {notice} )}
{runs.map((run) => { const status = STATUS[run.status] || STATUS.running; const Icon = status.icon; const facts = [ // "Started" fits a run still going; a finished run says when it // wrapped up so it does not read as stuck. run.status === 'running' ? run.startedAt && `Started ${timeAgo(run.startedAt)}` : run.finishedAt && `Finished ${timeAgo(run.finishedAt)}`, // Mid-run the total is still being discovered; show only what // has actually been copied so far. Finished runs upload only // blobs the target does not already hold, so say "new" rather // than implying a progress fraction. run.status === 'running' ? `${num(run.blobsCopied)} blobs copied` : `${num(run.blobsCopied)} new blobs, ${num(run.blobsTotal)} total`, run.bytesUploaded != null && `${bytes(run.bytesUploaded)} uploaded`, ].filter(Boolean); return ( } title={ <> {status.label} {run.trigger} {run.keyIncluded && ( {run.keyStale ? 'stale key' : 'key included'} )} {run.blobsFailed > 0 && ( {num(run.blobsFailed)}{' '} {run.blobsFailed === 1 ? 'blob' : 'blobs'} not copied )} } description={ <> {facts.join(' · ')} {run.error && ( {run.error} )} {/* A run that copied the repo but not every blob. The next run tries those blobs again. */} {!run.error && run.blobError && ( {run.blobError} )} } /> ); })} {runs.length === 0 && (

No backups have run yet.

)}
); }