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

@ -1488,6 +1488,76 @@ pub fn get_blob_by_sha256(conn: &Connection, sha256: &str) -> Result<Option<Blob
.map_err(anyhow::Error::from) .map_err(anyhow::Error::from)
} }
/// Returns `true` if any capture job in this archive is `pending` or `running`.
/// Call before scanning or deleting orphans: the capture pipeline moves files into
/// `raw/` before writing the DB rows, so a mid-capture scan can falsely flag live files.
pub fn has_active_capture_jobs(conn: &Connection) -> Result<bool> {
let n: i64 = conn.query_row(
"SELECT COUNT(*) FROM capture_jobs WHERE status IN ('pending', 'running')",
[],
|row| row.get(0),
)?;
Ok(n > 0)
}
/// Returns `(id, raw_relpath, byte_size)` for every blob row not referenced by any
/// `entry_artifacts.blob_id`. These DB rows are safe to delete regardless of whether
/// a disk file still exists at their `raw_relpath`.
pub fn list_orphaned_blob_rows(conn: &Connection) -> Result<Vec<(i64, String, i64)>> {
let mut stmt = conn.prepare(
"SELECT id, raw_relpath, byte_size FROM blobs \
WHERE id NOT IN \
(SELECT DISTINCT blob_id FROM entry_artifacts WHERE blob_id IS NOT NULL)",
)?;
let rows = stmt
.query_map([], |row| {
Ok((
row.get::<_, i64>(0)?,
row.get::<_, String>(1)?,
row.get::<_, i64>(2)?,
))
})?
.collect::<rusqlite::Result<Vec<_>>>()?;
Ok(rows)
}
/// Returns the set of all file relpaths (relative to `store_path`) that are currently
/// referenced by at least one live entry_artifact, either directly via
/// `entry_artifacts.relpath` or indirectly via a live blob's `raw_relpath`.
/// Any disk file whose relpath is in this set must NOT be deleted.
pub fn all_referenced_file_relpaths(conn: &Connection) -> Result<std::collections::HashSet<String>> {
let mut set = std::collections::HashSet::new();
{
let mut stmt = conn.prepare("SELECT DISTINCT relpath FROM entry_artifacts")?;
let mut rows = stmt.query([])?;
while let Some(row) = rows.next()? {
set.insert(row.get::<_, String>(0)?);
}
}
{
let mut stmt = conn.prepare(
"SELECT DISTINCT raw_relpath FROM blobs \
WHERE id IN \
(SELECT DISTINCT blob_id FROM entry_artifacts WHERE blob_id IS NOT NULL)",
)?;
let mut rows = stmt.query([])?;
while let Some(row) = rows.next()? {
set.insert(row.get::<_, String>(0)?);
}
}
Ok(set)
}
/// Delete every blob row not referenced by any `entry_artifacts.blob_id`.
/// Returns the number of rows deleted.
pub fn delete_orphaned_blob_rows(conn: &Connection) -> Result<usize> {
Ok(conn.execute(
"DELETE FROM blobs WHERE id NOT IN \
(SELECT DISTINCT blob_id FROM entry_artifacts WHERE blob_id IS NOT NULL)",
[],
)?)
}
pub fn create_archived_entry(conn: &Connection, entry: &NewEntry) -> Result<ArchivedEntry> { pub fn create_archived_entry(conn: &Connection, entry: &NewEntry) -> Result<ArchivedEntry> {
validate_visibility(&entry.visibility)?; validate_visibility(&entry.visibility)?;
let entry_uid = public_id("entry"); let entry_uid = public_id("entry");
@ -2931,4 +3001,169 @@ mod tests {
assert_eq!(after, 0, "cached_bytes must be 0 after whole subtree is deleted"); assert_eq!(after, 0, "cached_bytes must be 0 after whole subtree is deleted");
} }
// ── Orphan blob cleanup ───────────────────────────────────────────────────────
#[test]
fn has_active_capture_jobs_false_when_none() {
let conn = conn();
assert!(!has_active_capture_jobs(&conn).unwrap());
}
#[test]
fn has_active_capture_jobs_true_for_pending() {
let conn = conn();
create_capture_job(&conn, "test").unwrap();
assert!(has_active_capture_jobs(&conn).unwrap());
}
#[test]
fn has_active_capture_jobs_true_for_running() {
let conn = conn();
let uid = create_capture_job(&conn, "test").unwrap();
update_capture_job_status(&conn, &uid, "running", None, None).unwrap();
assert!(has_active_capture_jobs(&conn).unwrap());
}
#[test]
fn has_active_capture_jobs_false_for_completed() {
let conn = conn();
let uid = create_capture_job(&conn, "test").unwrap();
update_capture_job_status(&conn, &uid, "completed", Some("run_x"), None).unwrap();
assert!(!has_active_capture_jobs(&conn).unwrap());
}
#[test]
fn list_orphaned_blob_rows_empty_when_blob_is_referenced() {
let conn = conn();
let entry = create_entry_fixture(&conn, "private", None, None);
let blob = BlobRecord {
sha256: "aaa111".to_string(),
byte_size: 100,
mime_type: None,
extension: Some("mp4".to_string()),
raw_relpath: "raw/a/a/aaa111.mp4".to_string(),
};
let blob_id = upsert_blob(&conn, &blob).unwrap();
add_entry_artifact(&conn, &NewArtifact {
entry_id: entry.id,
artifact_role: "main".to_string(),
storage_area: "raw".to_string(),
relpath: blob.raw_relpath.clone(),
blob_id: Some(blob_id),
logical_path: None,
metadata_json: None,
}).unwrap();
assert!(list_orphaned_blob_rows(&conn).unwrap().is_empty(),
"referenced blob must not appear as orphan");
}
#[test]
fn list_orphaned_blob_rows_finds_unreferenced_blob() {
let conn = conn();
upsert_blob(&conn, &BlobRecord {
sha256: "bbb222".to_string(),
byte_size: 200,
mime_type: None,
extension: Some("jpg".to_string()),
raw_relpath: "raw/b/b/bbb222.jpg".to_string(),
}).unwrap();
let orphans = list_orphaned_blob_rows(&conn).unwrap();
assert_eq!(orphans.len(), 1, "unreferenced blob must appear as orphan");
assert_eq!(orphans[0].1, "raw/b/b/bbb222.jpg");
}
#[test]
fn all_referenced_file_relpaths_covers_blob_and_direct_artifact_relpaths() {
let conn = conn();
let entry = create_entry_fixture(&conn, "private", None, None);
// Live blob: linked via blob_id
let blob = BlobRecord {
sha256: "live1".to_string(), byte_size: 50,
mime_type: None, extension: None,
raw_relpath: "raw/l/i/live1".to_string(),
};
let blob_id = upsert_blob(&conn, &blob).unwrap();
add_entry_artifact(&conn, &NewArtifact {
entry_id: entry.id,
artifact_role: "main".to_string(),
storage_area: "raw".to_string(),
relpath: blob.raw_relpath.clone(),
blob_id: Some(blob_id),
logical_path: None, metadata_json: None,
}).unwrap();
// Artifact referencing a file directly (no blob_id)
add_entry_artifact(&conn, &NewArtifact {
entry_id: entry.id,
artifact_role: "sidecar".to_string(),
storage_area: "raw".to_string(),
relpath: "raw/s/i/sidecar.vtt".to_string(),
blob_id: None,
logical_path: None, metadata_json: None,
}).unwrap();
let refs = all_referenced_file_relpaths(&conn).unwrap();
assert!(refs.contains("raw/l/i/live1"), "live blob relpath must be protected");
assert!(refs.contains("raw/s/i/sidecar.vtt"), "direct artifact relpath must be protected");
}
#[test]
fn delete_orphaned_blob_rows_removes_only_unreferenced() {
let conn = conn();
let entry = create_entry_fixture(&conn, "private", None, None);
// Referenced blob
let live = BlobRecord {
sha256: "live9999".to_string(), byte_size: 10,
mime_type: None, extension: None,
raw_relpath: "raw/l/v/live9999".to_string(),
};
let live_id = upsert_blob(&conn, &live).unwrap();
add_entry_artifact(&conn, &NewArtifact {
entry_id: entry.id,
artifact_role: "main".to_string(),
storage_area: "raw".to_string(),
relpath: live.raw_relpath.clone(),
blob_id: Some(live_id),
logical_path: None, metadata_json: None,
}).unwrap();
// Orphaned blob
upsert_blob(&conn, &BlobRecord {
sha256: "dead0000".to_string(), byte_size: 20,
mime_type: None, extension: None,
raw_relpath: "raw/d/e/dead0000".to_string(),
}).unwrap();
let deleted = delete_orphaned_blob_rows(&conn).unwrap();
assert_eq!(deleted, 1, "only the unreferenced blob row should be deleted");
assert!(get_blob_by_sha256(&conn, "live9999").unwrap().is_some(),
"referenced blob row must be preserved");
}
#[test]
fn orphan_blob_row_whose_relpath_is_artifact_relpath_stays_in_referenced_set() {
// Blob row has no blob_id reference (would be deleted from DB),
// but an artifact points to the same file via relpath — the file must
// appear in all_referenced_file_relpaths so it won't be deleted from disk.
let conn = conn();
let entry = create_entry_fixture(&conn, "private", None, None);
let blob = BlobRecord {
sha256: "edgecase".to_string(), byte_size: 30,
mime_type: None, extension: None,
raw_relpath: "raw/e/d/edgecase".to_string(),
};
upsert_blob(&conn, &blob).unwrap();
// artifact uses same relpath but no blob_id
add_entry_artifact(&conn, &NewArtifact {
entry_id: entry.id,
artifact_role: "sidecar".to_string(),
storage_area: "raw".to_string(),
relpath: blob.raw_relpath.clone(),
blob_id: None,
logical_path: None, metadata_json: None,
}).unwrap();
// blob row is orphaned (no blob_id reference)
assert_eq!(list_orphaned_blob_rows(&conn).unwrap().len(), 1);
// but the file relpath is still protected
let refs = all_referenced_file_relpaths(&conn).unwrap();
assert!(refs.contains(&blob.raw_relpath),
"file must be protected because artifact.relpath references it directly");
}
} }

View file

@ -287,6 +287,10 @@ pub fn app_with_state(state: AppState) -> Router {
"/api/archives/:archive_id/entries/:entry_uid/collections", "/api/archives/:archive_id/entries/:entry_uid/collections",
get(list_entry_collections_handler), get(list_entry_collections_handler),
) )
.route(
"/api/archives/:archive_id/blob-cleanup",
get(blob_cleanup_scan_handler).delete(blob_cleanup_delete_handler),
)
.nest_service("/assets", ServeDir::new(static_dir.join("assets"))) .nest_service("/assets", ServeDir::new(static_dir.join("assets")))
.fallback_service(ServeFile::new(static_dir.join("index.html"))) .fallback_service(ServeFile::new(static_dir.join("index.html")))
.layer(axum::middleware::from_fn_with_state(state.clone(), setup_guard)) .layer(axum::middleware::from_fn_with_state(state.clone(), setup_guard))
@ -994,6 +998,150 @@ async fn update_instance_settings_handler(
Ok(StatusCode::NO_CONTENT) Ok(StatusCode::NO_CONTENT)
} }
// ── Blob / orphan cleanup ─────────────────────────────────────────────────────
#[derive(serde::Serialize)]
struct BlobCleanupScanResponse {
orphaned_blob_rows: usize,
deletable_files: usize,
total_bytes: u64,
}
/// GET /api/archives/:archive_id/blob-cleanup
/// Returns stats on orphaned blob DB rows and unreferenced raw files.
/// Returns 409 if any capture job is pending or running.
async fn blob_cleanup_scan_handler(
State(state): State<AppState>,
auth_user: AuthUser,
Path(archive_id): Path<String>,
) -> Result<Json<BlobCleanupScanResponse>, ApiError> {
auth_user.require_role(ROLE_ADMIN)?;
let mounted = mounted_archive(&state, &archive_id)?;
let paths = archive::read_archive_paths(&mounted.archive_path)?;
let conn = database::open_or_initialize(&mounted.archive_path)?;
if database::has_active_capture_jobs(&conn)? {
return Err(ApiError::conflict(
"captures are in progress; wait for them to finish before scanning",
));
}
let referenced = database::all_referenced_file_relpaths(&conn)?;
let orphaned_blob_rows = database::list_orphaned_blob_rows(&conn)?.len();
let orphaned_files = collect_orphaned_disk_files(&paths.store_path, &referenced)
.map_err(|e| ApiError::internal(&format!("disk scan failed: {e:#}")))?;
let total_bytes: u64 = orphaned_files.iter().map(|(_, sz)| sz).sum();
Ok(Json(BlobCleanupScanResponse {
orphaned_blob_rows,
deletable_files: orphaned_files.len(),
total_bytes,
}))
}
/// DELETE /api/archives/:archive_id/blob-cleanup
/// Deletes orphaned blob DB rows and unreferenced raw files.
/// Re-checks for active captures immediately before executing to close the TOCTOU window.
async fn blob_cleanup_delete_handler(
State(state): State<AppState>,
auth_user: AuthUser,
Path(archive_id): Path<String>,
) -> Result<Json<serde_json::Value>, ApiError> {
auth_user.require_role(ROLE_ADMIN)?;
let mounted = mounted_archive(&state, &archive_id)?;
let paths = archive::read_archive_paths(&mounted.archive_path)?;
let conn = database::open_or_initialize(&mounted.archive_path)?;
// Re-check immediately before acting (closes the TOCTOU gap between scan and delete).
if database::has_active_capture_jobs(&conn)? {
return Err(ApiError::conflict(
"captures are in progress; wait for them to finish before cleaning up",
));
}
// Collect the set of protected relpaths and the files to delete BEFORE mutating the DB.
// This ensures the referenced set is consistent with the rows we're about to remove.
let referenced = database::all_referenced_file_relpaths(&conn)?;
let files_to_delete = collect_orphaned_disk_files(&paths.store_path, &referenced)
.map_err(|e| ApiError::internal(&format!("disk scan failed: {e:#}")))?;
// Second guard: re-check after the disk walk, which can be slow.
// A capture that started during the walk may have moved files into raw/ before
// writing its DB rows; those files would appear orphaned but must not be deleted.
if database::has_active_capture_jobs(&conn)? {
return Err(ApiError::conflict(
"a capture started during the scan; retry after all captures finish",
));
}
// Delete orphaned blob rows from the database.
let deleted_blob_rows = database::delete_orphaned_blob_rows(&conn)?;
// Delete the unreferenced disk files.
let mut freed_bytes: u64 = 0;
let mut deleted_files: usize = 0;
let mut errors: Vec<String> = Vec::new();
for (path, size) in &files_to_delete {
match std::fs::remove_file(path) {
Ok(()) => {
freed_bytes += *size;
deleted_files += 1;
}
Err(e) => errors.push(format!("{}: {e}", path.display())),
}
}
eprintln!(
"info: blob cleanup for '{}': {} blob rows, {} files deleted, {} bytes freed, {} errors",
archive_id, deleted_blob_rows, deleted_files, freed_bytes, errors.len()
);
Ok(Json(serde_json::json!({
"deleted_blob_rows": deleted_blob_rows,
"deleted_files": deleted_files,
"freed_bytes": freed_bytes,
"errors": errors,
})))
}
/// Walk `raw/` and `raw_tweets/` under `store_path` and return every file whose
/// relpath (relative to `store_path`, forward-slash separated) is absent from
/// `referenced`. Each entry is `(absolute_path, byte_size)`.
fn collect_orphaned_disk_files(
store_path: &std::path::Path,
referenced: &std::collections::HashSet<String>,
) -> anyhow::Result<Vec<(std::path::PathBuf, u64)>> {
let mut result = Vec::new();
for subdir in &["raw", "raw_tweets"] {
let dir = store_path.join(subdir);
if !dir.exists() {
continue;
}
let mut stack = vec![dir];
while let Some(current) = stack.pop() {
for entry in std::fs::read_dir(&current)? {
let entry = entry?;
let path = entry.path();
let ft = entry.file_type()?;
if ft.is_dir() {
stack.push(path);
} else if ft.is_file() {
if let Ok(rel) = path.strip_prefix(store_path) {
// Normalise to forward slashes (relevant on Windows if ever deployed there).
let relpath = rel.to_string_lossy().replace('\\', "/");
if !referenced.contains(&relpath) {
let size = entry.metadata().map(|m| m.len()).unwrap_or(0);
result.push((path, size));
}
}
}
}
}
}
Ok(result)
}
async fn create_token( async fn create_token(
State(state): State<AppState>, State(state): State<AppState>,
auth_user: AuthUser, auth_user: AuthUser,
@ -1210,6 +1358,10 @@ impl ApiError {
pub fn forbidden(message: &str) -> Self { pub fn forbidden(message: &str) -> Self {
Self { status: StatusCode::FORBIDDEN, message: message.to_string() } Self { status: StatusCode::FORBIDDEN, message: message.to_string() }
} }
fn conflict(message: &str) -> Self {
Self { status: StatusCode::CONFLICT, message: message.to_string() }
}
} }
impl<E> From<E> for ApiError impl<E> From<E> for ApiError
@ -3212,4 +3364,167 @@ mod tests {
assert_eq!(response.status(), StatusCode::UNAUTHORIZED); assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
} }
// ── Blob cleanup route tests ──────────────────────────────────────────────────
#[tokio::test]
async fn blob_cleanup_scan_requires_auth() {
let dir = tempfile::tempdir().unwrap();
let (registry, _, auth_path) = make_test_registry(&dir);
let response = app(registry, auth_path)
.oneshot(
Request::builder()
.uri("/api/archives/test/blob-cleanup")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
}
#[tokio::test]
async fn blob_cleanup_delete_requires_auth() {
let dir = tempfile::tempdir().unwrap();
let (registry, _, auth_path) = make_test_registry(&dir);
let response = app(registry, auth_path)
.oneshot(
Request::builder()
.method("DELETE")
.uri("/api/archives/test/blob-cleanup")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
}
#[tokio::test]
async fn blob_cleanup_scan_returns_409_when_capture_pending() {
let dir = tempfile::tempdir().unwrap();
let (registry, archive_path, auth_path) = make_test_registry(&dir);
let session = make_test_session(&auth_path);
{
let conn = database::open_or_initialize(&archive_path).unwrap();
database::create_capture_job(&conn, "test").unwrap();
}
let response = app(registry, auth_path)
.oneshot(
Request::builder()
.uri("/api/archives/test/blob-cleanup")
.header("cookie", &session)
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::CONFLICT);
}
#[tokio::test]
async fn blob_cleanup_delete_returns_409_when_capture_pending() {
let dir = tempfile::tempdir().unwrap();
let (registry, archive_path, auth_path) = make_test_registry(&dir);
let session = make_test_session(&auth_path);
{
let conn = database::open_or_initialize(&archive_path).unwrap();
database::create_capture_job(&conn, "test").unwrap();
}
let response = app(registry, auth_path)
.oneshot(
Request::builder()
.method("DELETE")
.uri("/api/archives/test/blob-cleanup")
.header("cookie", &session)
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::CONFLICT);
}
#[tokio::test]
async fn blob_cleanup_delete_removes_orphan_and_preserves_referenced() {
let dir = tempfile::tempdir().unwrap();
let store_path = dir.path().join("store");
let paths = archivr_core::archive::initialize_archive(
dir.path(), &store_path, "test", false,
).unwrap();
let auth_path = dir.path().join("auth.sqlite");
{
let conn = archivr_core::database::open_auth_db(&auth_path).unwrap();
archivr_core::database::create_owner(&conn, "testowner", "dummy").unwrap();
}
let session = make_test_session(&auth_path);
let entry = make_test_entry(&paths.archive_path);
let live_relpath = "raw/l/i/live.bin";
let orphan_relpath = "raw/o/r/orphan.bin";
let extra_relpath = "raw/x/t/extra.bin"; // on disk only, no blob row
{
let conn = database::open_or_initialize(&paths.archive_path).unwrap();
// Referenced blob
let live_id = database::upsert_blob(&conn, &database::BlobRecord {
sha256: "aaaa1111bbbb2222cccc3333dddd4444aaaa1111bbbb2222cccc3333dddd4444".to_string(),
byte_size: 10, mime_type: None, extension: Some("bin".to_string()),
raw_relpath: live_relpath.to_string(),
}).unwrap();
database::add_entry_artifact(&conn, &database::NewArtifact {
entry_id: entry.id,
artifact_role: "main".to_string(),
storage_area: "raw".to_string(),
relpath: live_relpath.to_string(),
blob_id: Some(live_id),
logical_path: None, metadata_json: None,
}).unwrap();
// Orphaned blob (no artifact references it)
database::upsert_blob(&conn, &database::BlobRecord {
sha256: "deadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeefdeadbeef".to_string(),
byte_size: 20, mime_type: None, extension: Some("bin".to_string()),
raw_relpath: orphan_relpath.to_string(),
}).unwrap();
}
// Write all three files to disk
for relpath in &[live_relpath, orphan_relpath, extra_relpath] {
let abs = store_path.join(relpath);
std::fs::create_dir_all(abs.parent().unwrap()).unwrap();
std::fs::write(&abs, b"content").unwrap();
}
let registry = ServerRegistry {
archives: vec![MountedArchive {
id: "test".to_string(),
label: "Test".to_string(),
archive_path: paths.archive_path.clone(),
}],
bind: None,
auth_db_path: None,
};
let response = app(registry, auth_path)
.oneshot(
Request::builder()
.method("DELETE")
.uri("/api/archives/test/blob-cleanup")
.header("cookie", &session)
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::OK);
let body = body_json(response).await;
assert_eq!(body["deleted_blob_rows"], 1, "one orphaned DB row removed");
assert_eq!(body["deleted_files"], 2, "orphan blob file and extra disk file removed");
assert!(body["errors"].as_array().unwrap().is_empty());
assert!(store_path.join(live_relpath).exists(), "referenced file must be preserved");
assert!(!store_path.join(orphan_relpath).exists(), "orphaned blob file must be deleted");
assert!(!store_path.join(extra_relpath).exists(), "extra disk-only file must be deleted");
}
} }

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -4,7 +4,7 @@
<meta charset="utf-8" /> <meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" /> <meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Archivr</title> <title>Archivr</title>
<script type="module" crossorigin src="/assets/index-BHg5-TAr.js"></script> <script type="module" crossorigin src="/assets/index-bXPw5mQ7.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-BW0QKHXE.css"> <link rel="stylesheet" crossorigin href="/assets/index-BW0QKHXE.css">
</head> </head>
<body> <body>

View file

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

View file

@ -4,16 +4,17 @@ import {
updateProfile, changePassword, patchMe, updateProfile, changePassword, patchMe,
listTokens, createToken, deleteToken, listTokens, createToken, deleteToken,
getInstanceSettings, updateInstanceSettings, getInstanceSettings, updateInstanceSettings,
scanOrphanBlobs, deleteOrphanBlobs,
} from '../api.js' } from '../api.js'
const ROLE_ADMIN = 4 const ROLE_ADMIN = 4
export default function SettingsView({ tab, onTabChange }) { export default function SettingsView({ tab, onTabChange, archiveId }) {
const { currentUser, setCurrentUser } = useContext(AuthContext) ?? {} const { currentUser, setCurrentUser } = useContext(AuthContext) ?? {}
const isAdmin = currentUser && ((currentUser.role_bits & ROLE_ADMIN) !== 0) const isAdmin = currentUser && ((currentUser.role_bits & ROLE_ADMIN) !== 0)
const tabs = ['profile', 'tokens', ...(isAdmin ? ['instance'] : [])] const tabs = ['profile', 'tokens', ...(isAdmin ? ['instance', 'storage'] : [])]
const tabLabels = { profile: 'Profile', tokens: 'API Tokens', instance: 'Instance' } const tabLabels = { profile: 'Profile', tokens: 'API Tokens', instance: 'Instance', storage: 'Storage' }
return ( return (
<section className="admin-view"> <section className="admin-view">
@ -31,6 +32,7 @@ export default function SettingsView({ tab, onTabChange }) {
{tab === 'profile' && <ProfileTab currentUser={currentUser} setCurrentUser={setCurrentUser} />} {tab === 'profile' && <ProfileTab currentUser={currentUser} setCurrentUser={setCurrentUser} />}
{tab === 'tokens' && <TokensTab />} {tab === 'tokens' && <TokensTab />}
{tab === 'instance' && isAdmin && <InstanceTab />} {tab === 'instance' && isAdmin && <InstanceTab />}
{tab === 'storage' && isAdmin && <StorageTab archiveId={archiveId} />}
</section> </section>
) )
} }
@ -208,7 +210,7 @@ function TokensTab() {
</form> </form>
{error && <div className="form-msg form-msg--err">{error}</div>} {error && <div className="form-msg form-msg--err">{error}</div>}
{loading ? ( {loading ? (
<div className="muted">Loading\u2026</div> <div className="muted">Loading</div>
) : ( ) : (
<div> <div>
{tokens.length === 0 && <div className="muted">No tokens yet.</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 (error) return <div className="form-msg form-msg--err">{error}</div>
if (!settings) return null if (!settings) return null
@ -300,3 +302,131 @@ function InstanceTab() {
</div> </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>
)
}