// src/components/ScanReport.js — the report state // // Tabs (which double as the headline numbers / filters): // Orphans (default when > 0) | Blobs held | Missing // Sticky action bar at the top: select all, download selected, export backup, // delete selected (only meaningful in the Orphans tab). Grid below. import React, { useState, useCallback, useEffect, useMemo } from 'react'; import { buildBackupZip, fetchBlob, downloadBlob } from '../lib/backup'; import BlobGrid from './BlobGrid'; import './ScanReport.css'; const Tab = ({ active, count, label, onClick }) => ( ); const ScanReport = ({ result, pdsUrl, did, agent, onStartCleanup, onRescan, cleanupState }) => { const [tab, setTab] = useState('orphans'); // orphans | held | missing const [selected, setSelected] = useState(() => new Set()); const [exporting, setExporting] = useState(false); const [exportError, setExportError] = useState(null); const [exportDone, setExportDone] = useState(false); const [confirmDelete, setConfirmDelete] = useState(false); const [deleting, setDeleting] = useState(false); const { trustworthy, held, referenced, orphans, missing, collections, errors } = result; // Default tab: orphans when any exist, else blobs held. useEffect(() => { setTab(orphans.length > 0 ? 'orphans' : 'held'); // eslint-disable-next-line react-hooks/exhaustive-deps }, []); // Reset confirm/export state when selection changes useEffect(() => { setConfirmDelete(false); setExportDone(false); }, [selected]); const activeCids = useMemo(() => { if (tab === 'orphans') return orphans; if (tab === 'held') return held; return []; }, [tab, orphans, held]); const allSelected = activeCids.length > 0 && selected.size === activeCids.length; const selectedInTab = useMemo(() => { const s = new Set(activeCids); return [...selected].filter((c) => s.has(c)); }, [selected, activeCids]); const toggleAll = () => { if (allSelected) { const s = new Set(activeCids); setSelected((prev) => { const next = new Set(prev); for (const c of s) next.delete(c); return next; }); } else { setSelected(new Set(activeCids)); } }; const toggleOne = (cid) => { setSelected((prev) => { const next = new Set(prev); if (next.has(cid)) next.delete(cid); else next.add(cid); return next; }); }; const downloadSelected = useCallback(async () => { if (!agent || !did) return; for (const cid of selectedInTab) { try { const res = await agent.api.com.atproto.sync.getBlob({ did, cid }); const data = res?.data; let blob; if (data instanceof Blob) blob = data; else if (data instanceof ArrayBuffer) blob = new Blob([data]); else if (data && typeof data.byteLength === 'number') blob = new Blob([data]); else continue; downloadBlob(blob, `${cid}.bin`); } catch (err) { console.error(`Download failed for ${cid}:`, err); } } }, [agent, did, selectedInTab]); const exportBackup = useCallback(async () => { if (!agent || !did || !pdsUrl) return; setExporting(true); setExportError(null); try { const items = []; for (const cid of selectedInTab) { const fetched = await fetchBlob( (params) => agent.api.com.atproto.sync.getBlob(params), { did, cid } ); items.push({ cid, blob: fetched.blob, mimeType: fetched.mimeType, size: fetched.size, url: `${pdsUrl}/xrpc/com.atproto.sync.getBlob?did=${did}&cid=${cid}`, }); } const zip = await buildBackupZip(items); downloadBlob(zip, `sifter-backup-${did}-${Date.now()}.zip`); setExportDone(true); } catch (err) { console.error('Backup failed:', err); setExportError(err?.message || String(err)); } finally { setExporting(false); } }, [agent, did, pdsUrl, selectedInTab]); const handleDelete = () => { if (!confirmDelete) { setConfirmDelete(true); return; } setDeleting(true); onStartCleanup([...selectedInTab]); }; const canDelete = trustworthy && tab === 'orphans' && selectedInTab.length > 0 && !exporting; const hasSelection = selectedInTab.length > 0; return (

Scan report

{!trustworthy && (
Scan incomplete — deletion disabled. The following step(s) failed, so the orphan list may be wrong: The report below is read-only. Fix the underlying issue and re-scan.
)} {orphans.length === 0 && trustworthy && (

Your repo is clean — every blob your PDS holds is referenced by a record. This is the expected, healthy outcome.

)} {/* Tabs — double as headline numbers / filters */}
setTab('orphans')} /> setTab('held')} /> setTab('missing')} />

Blobs referenced: {referenced.length.toLocaleString()} — shown inside Blobs held.

{/* Sticky action bar */}
{tab !== 'missing' && activeCids.length > 0 && ( )} {tab === 'orphans' && ( )} {exportDone && ✓ Backup downloaded} {exportError && Backup failed: {exportError}}
{confirmDelete && tab === 'orphans' && (
Deletion is irreversible and cannot be undone. Sifter briefly creates and deletes a throwaway record in your repo for each blob (collection{' '} {'com.psingletary.sifter.tempRef'}); firehose consumers will see those events. Deleting a blob will not necessarily reduce storage anywhere — blobs are deduplicated across accounts. On Bluesky-hosted PDSs you are not billed for blob storage; this is housekeeping. Click Delete again to proceed.
)} {!trustworthy && tab === 'orphans' && (

Deletion is disabled because the scan is not trustworthy.

)} {/* Grid for the active tab */} {tab === 'orphans' && ( )} {tab === 'held' && ( )} {tab === 'missing' && (

Missing blobs ({missing.length})

Records reference blobs your PDS doesn't have. This is a records{' '} problem, not a storage problem — Sifter reports these but never fixes them. Check your import or migration.

{missing.length === 0 ? (

Nothing missing.

) : (
    {missing.slice(0, 100).map((m) => (
  • {m.cid} — referenced by{' '} {m.refs.slice(0, 2).join(', ')} {m.refs.length > 2 ? ` (+${m.refs.length - 2} more)` : ''}
  • ))}
)} {missing.length > 100 && (

…and {missing.length - 100} more.

)}
)} {/* Informational sections below the grid */} {collections.length > 0 && ( <>

Per-collection accounting

How many distinct blob refs each collection contributed — your sanity check that the scan walked everything.

{collections.map((c, i) => ( ))}
Collection Records Blob refs
{c.nsid} {c.records.toLocaleString()} {c.blobRefs.toLocaleString()}
)} {did && (

Verify independently:{' '} atproto.at/uri/at://{did}#blobs

)} {cleanupState && (
A cleanup run is saved for this account ({cleanupState.orphans.filter((o) => o.status === 'pending').length} left). You can resume it from the main screen.
)}
); }; export default ScanReport;