1
Fork 0
mirror of https://github.com/thegeneralist01/archivr synced 2026-07-21 18:55:36 +02:00

feat: add orphan blob cleanup to Settings > Storage tab (#18)

- database.rs: add has_active_capture_jobs(), list_orphaned_blob_rows(),
  all_referenced_file_relpaths(), delete_orphaned_blob_rows()
- routes.rs: GET/DELETE /api/archives/:id/blob-cleanup (ROLE_ADMIN)
  - GET returns {orphaned_blob_rows, deletable_files, total_bytes}
  - DELETE has two active-capture guards (before and after disk walk)
    to prevent deleting files mid-capture; walks raw/ and raw_tweets/
  - Referenced set = entry_artifacts.relpath ∪ live blobs' raw_relpath,
    so a file is never deleted if any artifact still points at its path
- api.js: scanOrphanBlobs(), deleteOrphanBlobs()
- App.jsx: pass archiveId to SettingsView; add 'storage' to SETTINGS_TABS
  so /settings/storage survives refresh/back navigation
- SettingsView.jsx: new Storage tab (admin-only) with idle→scanning→
  scanned→deleting→done/error state machine; shows file/record counts
  and human-readable byte sizes before a btn-danger confirm

Tests (14 new, 248 total passing):
- database.rs: has_active_capture_jobs for pending/running/completed,
  list_orphaned_blob_rows, all_referenced_file_relpaths edge cases,
  delete_orphaned_blob_rows preserves referenced rows
- routes.rs: auth (401), active-capture 409 on GET and DELETE,
  end-to-end delete preserving referenced file and removing orphan
  blob file + extra disk-only file
This commit is contained in:
TheGeneralist 2026-07-06 14:01:38 +02:00 committed by GitHub
parent b8e496457f
commit 339076e6a2
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 739 additions and 48 deletions

View file

@ -18,7 +18,7 @@ import ToastStack from './components/ToastStack'
export const AuthContext = createContext(null);
const VIEWS = ['archive','tags','collections','runs','admin','settings']
const SETTINGS_TABS = ['profile','tokens','instance']
const SETTINGS_TABS = ['profile','tokens','instance','storage']
function parseLocation() {
const parts = window.location.pathname.split('/').filter(Boolean)
@ -326,7 +326,7 @@ export default function App() {
<CollectionsView archiveId={archiveId} />
)}
{view === 'settings' && (
<SettingsView tab={settingsTab} onTabChange={setSettingsTab} />
<SettingsView tab={settingsTab} onTabChange={setSettingsTab} archiveId={archiveId} />
)}
</div>
<ContextRail

View file

@ -354,6 +354,17 @@ export async function deleteCollection(archiveId, collUid) {
if (!res.ok) { const e = await res.json().catch(() => ({})); throw new Error(e.error ?? res.statusText) }
}
// ── Blob / orphan cleanup ─────────────────────────────────────────────────────
export async function scanOrphanBlobs(archiveId) {
return getJson(`/api/archives/${archiveId}/blob-cleanup`)
}
export async function deleteOrphanBlobs(archiveId) {
const res = await fetch(`/api/archives/${archiveId}/blob-cleanup`, { method: 'DELETE' })
if (!res.ok) { const e = await res.json().catch(() => ({})); throw new Error(e.error ?? res.statusText) }
return res.json()
}
// ── 401 interceptor ───────────────────────────────────────────────────────────
const _origFetch = window.fetch;
window.fetch = async (...args) => {

View file

@ -4,16 +4,17 @@ import {
updateProfile, changePassword, patchMe,
listTokens, createToken, deleteToken,
getInstanceSettings, updateInstanceSettings,
scanOrphanBlobs, deleteOrphanBlobs,
} from '../api.js'
const ROLE_ADMIN = 4
export default function SettingsView({ tab, onTabChange }) {
export default function SettingsView({ tab, onTabChange, archiveId }) {
const { currentUser, setCurrentUser } = useContext(AuthContext) ?? {}
const isAdmin = currentUser && ((currentUser.role_bits & ROLE_ADMIN) !== 0)
const tabs = ['profile', 'tokens', ...(isAdmin ? ['instance'] : [])]
const tabLabels = { profile: 'Profile', tokens: 'API Tokens', instance: 'Instance' }
const tabs = ['profile', 'tokens', ...(isAdmin ? ['instance', 'storage'] : [])]
const tabLabels = { profile: 'Profile', tokens: 'API Tokens', instance: 'Instance', storage: 'Storage' }
return (
<section className="admin-view">
@ -31,6 +32,7 @@ export default function SettingsView({ tab, onTabChange }) {
{tab === 'profile' && <ProfileTab currentUser={currentUser} setCurrentUser={setCurrentUser} />}
{tab === 'tokens' && <TokensTab />}
{tab === 'instance' && isAdmin && <InstanceTab />}
{tab === 'storage' && isAdmin && <StorageTab archiveId={archiveId} />}
</section>
)
}
@ -208,7 +210,7 @@ function TokensTab() {
</form>
{error && <div className="form-msg form-msg--err">{error}</div>}
{loading ? (
<div className="muted">Loading\u2026</div>
<div className="muted">Loading</div>
) : (
<div>
{tokens.length === 0 && <div className="muted">No tokens yet.</div>}
@ -262,7 +264,7 @@ function InstanceTab() {
}
}
if (loading) return <div className="muted">Loading\u2026</div>
if (loading) return <div className="muted">Loading</div>
if (error) return <div className="form-msg form-msg--err">{error}</div>
if (!settings) return null
@ -300,3 +302,131 @@ function InstanceTab() {
</div>
)
}
function formatBytes(bytes) {
if (bytes === 0) return '0 B'
const units = ['B', 'KB', 'MB', 'GB', 'TB']
const i = Math.floor(Math.log(bytes) / Math.log(1024))
return `${(bytes / Math.pow(1024, i)).toFixed(i === 0 ? 0 : 1)} ${units[i]}`
}
function StorageTab({ archiveId }) {
// phase: 'idle' | 'scanning' | 'scanned' | 'deleting' | 'done' | 'error'
const [phase, setPhase] = useState('idle')
const [scanResult, setScanResult] = useState(null)
const [deleteResult, setDeleteResult] = useState(null)
const [errorMsg, setErrorMsg] = useState(null)
function reset() {
setPhase('idle')
setScanResult(null)
setDeleteResult(null)
setErrorMsg(null)
}
async function handleScan() {
setPhase('scanning')
setErrorMsg(null)
setScanResult(null)
try {
const result = await scanOrphanBlobs(archiveId)
setScanResult(result)
setPhase('scanned')
} catch (e) {
setErrorMsg(e.message)
setPhase('error')
}
}
async function handleDelete() {
setPhase('deleting')
setErrorMsg(null)
try {
const result = await deleteOrphanBlobs(archiveId)
setDeleteResult(result)
setPhase('done')
} catch (e) {
setErrorMsg(e.message)
setPhase('error')
}
}
const nothing = scanResult && scanResult.deletable_files === 0 && scanResult.orphaned_blob_rows === 0
return (
<div style={{ maxWidth: 440 }}>
<div className="form-section">
<h2>Orphan Cleanup</h2>
<p className="muted" style={{ marginBottom: 16 }}>
Scan for blob files and database records that are no longer referenced by
any archive entry and safely delete them.
{' '}<strong>Cleanup is blocked while captures are running.</strong>
</p>
{!archiveId && (
<div className="muted">No archive selected.</div>
)}
{archiveId && phase === 'idle' && (
<button className="btn-ghost" onClick={handleScan}>
Scan for orphaned blobs
</button>
)}
{phase === 'scanning' && (
<div className="muted">Scanning</div>
)}
{phase === 'scanned' && scanResult && nothing && (
<>
<div className="form-msg form-msg--ok">Archive is clean &mdash; nothing to remove.</div>
<button className="btn-ghost" style={{ marginTop: 10 }} onClick={reset}>Done</button>
</>
)}
{phase === 'scanned' && scanResult && !nothing && (
<div>
<div style={{ marginBottom: 14, lineHeight: 1.6 }}>
Found <strong>{scanResult.deletable_files}</strong> unreferenced file{scanResult.deletable_files !== 1 ? 's' : ''}
{' '}and <strong>{scanResult.orphaned_blob_rows}</strong> orphaned DB record{scanResult.orphaned_blob_rows !== 1 ? 's' : ''}
{' '}&mdash; <strong>{formatBytes(scanResult.total_bytes)}</strong> recoverable.
</div>
<div style={{ display: 'flex', gap: 8 }}>
<button className="btn-danger" onClick={handleDelete}>
Delete ({formatBytes(scanResult.total_bytes)})
</button>
<button className="btn-ghost" onClick={reset}>Cancel</button>
</div>
</div>
)}
{phase === 'deleting' && (
<div className="muted">Deleting</div>
)}
{phase === 'done' && deleteResult && (
<div>
<div className="form-msg form-msg--ok">
Freed <strong>{formatBytes(deleteResult.freed_bytes)}</strong>
{' '}&mdash; removed {deleteResult.deleted_files} file{deleteResult.deleted_files !== 1 ? 's' : ''}
{' '}and {deleteResult.deleted_blob_rows} DB record{deleteResult.deleted_blob_rows !== 1 ? 's' : ''}.
</div>
{deleteResult.errors && deleteResult.errors.length > 0 && (
<div className="form-msg form-msg--err" style={{ marginTop: 6 }}>
{deleteResult.errors.length} file{deleteResult.errors.length !== 1 ? 's' : ''} could not be deleted.
</div>
)}
<button className="btn-ghost" style={{ marginTop: 10 }} onClick={reset}>Scan again</button>
</div>
)}
{phase === 'error' && (
<div>
<div className="form-msg form-msg--err">{errorMsg}</div>
<button className="btn-ghost" style={{ marginTop: 10 }} onClick={reset}>Try again</button>
</div>
)}
</div>
</div>
)
}