Something went wrong. Try again.
find unattached blobs for removal
Something went wrong. Try again.
12 kB · 339 lines
JavaScript
at deploy
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340// 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 }) => ( <button className={`report-tab${active ? ' report-tab-active' : ''}`} onClick={onClick} role="tab" aria-selected={active} > {label} <span className="tab-count">{count.toLocaleString()}</span> </button>);
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 ( <div className="scan-report"> <div className="report-head"> <h2>Scan report</h2> <button onClick={onRescan} className="rescan-button"> Re-scan </button> </div>
{!trustworthy && ( <div className="banner banner-warning"> <strong>Scan incomplete — deletion disabled.</strong> The following step(s) failed, so the orphan list may be wrong: <ul> {errors.map((e, i) => ( <li key={i}> <code>{e.step}</code>: {e.message} </li> ))} </ul> The report below is read-only. Fix the underlying issue and re-scan. </div> )}
{orphans.length === 0 && trustworthy && ( <div className="clean-state"> <p> <strong>Your repo is clean</strong> — every blob your PDS holds is referenced by a record. This is the expected, healthy outcome. </p> </div> )}
{/* Tabs — double as headline numbers / filters */} <div className="report-tabs" role="tablist"> <Tab active={tab === 'orphans'} count={orphans.length} label="Orphans" onClick={() => setTab('orphans')} /> <Tab active={tab === 'held'} count={held.length} label="Blobs held" onClick={() => setTab('held')} /> <Tab active={tab === 'missing'} count={missing.length} label="Missing" onClick={() => setTab('missing')} /> </div> <p className="muted tab-note"> Blobs referenced: {referenced.length.toLocaleString()} — shown inside Blobs held. </p>
{/* Sticky action bar */} <div className="action-bar"> {tab !== 'missing' && activeCids.length > 0 && ( <label className="select-all"> <input type="checkbox" checked={allSelected} onChange={toggleAll} /> Select all </label> )} <button onClick={downloadSelected} disabled={!hasSelection} className="action-button" > Download ({selectedInTab.length}) </button> <button onClick={exportBackup} disabled={!hasSelection || exporting} className="action-button" > {exporting ? 'Packaging…' : `Export backup (${selectedInTab.length})`} </button> {tab === 'orphans' && ( <button onClick={handleDelete} disabled={!canDelete} className={confirmDelete ? 'danger action-button' : 'action-button delete-action'} > {deleting ? 'Starting…' : confirmDelete ? '⚠ Click again to confirm' : `Delete (${selectedInTab.length})`} </button> )} {exportDone && <span className="export-done">✓ Backup downloaded</span>} {exportError && <span className="error">Backup failed: {exportError}</span>} </div>
{confirmDelete && tab === 'orphans' && ( <div className="banner banner-warning delete-warning"> Deletion is irreversible and cannot be undone. Sifter briefly creates and deletes a throwaway record in your repo for each blob (collection{' '} <code>{'com.psingletary.sifter.tempRef'}</code>); 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 <strong>Delete</strong> again to proceed. </div> )} {!trustworthy && tab === 'orphans' && ( <p className="error">Deletion is disabled because the scan is not trustworthy.</p> )}
{/* Grid for the active tab */} {tab === 'orphans' && ( <BlobGrid cids={orphans} pdsUrl={pdsUrl} did={did} agent={agent} selected={selected} onToggleOne={toggleOne} title={`Orphans (${orphans.length})`} /> )} {tab === 'held' && ( <BlobGrid cids={held} pdsUrl={pdsUrl} did={did} agent={agent} selected={selected} onToggleOne={toggleOne} title={`Blobs held (${held.length})`} /> )} {tab === 'missing' && ( <div className="missing-zone"> <h3>Missing blobs ({missing.length})</h3> <p className="muted"> Records reference blobs your PDS doesn't have. This is a <em>records</em>{' '} problem, not a storage problem — Sifter reports these but never fixes them. Check your import or migration. </p> {missing.length === 0 ? ( <p className="muted">Nothing missing.</p> ) : ( <ul className="missing-list"> {missing.slice(0, 100).map((m) => ( <li key={m.cid}> <code>{m.cid}</code> — referenced by{' '} {m.refs.slice(0, 2).join(', ')} {m.refs.length > 2 ? ` (+${m.refs.length - 2} more)` : ''} </li> ))} </ul> )} {missing.length > 100 && ( <p className="muted">…and {missing.length - 100} more.</p> )} </div> )}
{/* Informational sections below the grid */} {collections.length > 0 && ( <> <h3>Per-collection accounting</h3> <p className="muted"> How many distinct blob refs each collection contributed — your sanity check that the scan walked everything. </p> <table className="report-table"> <thead> <tr> <th>Collection</th> <th>Records</th> <th>Blob refs</th> </tr> </thead> <tbody> {collections.map((c, i) => ( <tr key={`${c.nsid}-${i}`}> <td><code>{c.nsid}</code></td> <td>{c.records.toLocaleString()}</td> <td>{c.blobRefs.toLocaleString()}</td> </tr> ))} </tbody> </table> </> )}
{did && ( <p className="muted verify-link"> Verify independently:{' '} <a href={`https://atproto.at/uri/at://${did}#blobs`} target="_blank" rel="noreferrer" > atproto.at/uri/at://{did}#blobs </a> </p> )}
{cleanupState && ( <div className="banner"> 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. </div> )} </div> );};
export default ScanReport;