add discrepancy mode for filebrowser

This commit is contained in:
2026-05-01 10:55:27 -04:00
parent 79986066bf
commit e939e6a86d
12 changed files with 382 additions and 391 deletions
+2 -1
View File
@@ -1,4 +1,4 @@
from pydantic import BaseModel, ConfigDict
from pydantic import BaseModel, ConfigDict, Field
from typing import Optional, List, Dict, Any
from datetime import datetime
@@ -7,6 +7,7 @@ class TreeNodeSchema(BaseModel):
name: str
path: str
has_children: bool = True
children: List["TreeNodeSchema"] = Field(default_factory=list)
class ItemMetadataSchema(BaseModel):
+131
View File
@@ -1399,3 +1399,134 @@ def delete_file_record(file_id: int, db_session: Session = Depends(get_db)):
db_session.delete(record)
db_session.commit()
return {"message": f"File record '{file_path}' permanently deleted"}
# --- Discrepancy Tree & Browse Endpoints ---
@router.get("/discrepancies/tree", response_model=List[TreeNodeSchema])
def get_discrepancies_tree(db_session: Session = Depends(get_db)):
"""Returns tree of directories that contain discrepancy files, grouped by source root."""
from app.api.inventory import TreeNodeSchema, get_source_roots
roots = get_source_roots(db_session)
# Query all discrepancy files
records = (
db_session.query(models.FilesystemState)
.filter(
models.FilesystemState.is_ignored.is_(False),
models.FilesystemState.missing_acknowledged_at.is_(None),
(
models.FilesystemState.is_deleted.is_(True)
| models.FilesystemState.sha256_hash.is_(None)
),
)
.all()
)
# Build directory nodes keyed by directory path
dir_nodes: Dict[str, TreeNodeSchema] = {}
for record in records:
directory = (
record.file_path.rsplit("/", 1)[0] if "/" in record.file_path else ""
)
if directory not in dir_nodes:
dir_nodes[directory] = TreeNodeSchema(
name=directory.split("/")[-1] or directory,
path=directory,
has_children=True,
children=[],
)
dir_nodes[directory].children.append(
TreeNodeSchema(
name=record.file_path.split("/")[-1],
path=record.file_path,
has_children=False,
)
)
# Build top-level nodes from source roots
result = []
for root in roots:
root_dirs = [d for d in dir_nodes.keys() if d.startswith(root) or d == root]
if root_dirs:
children = [dir_nodes[d] for d in sorted(root_dirs)]
result.append(
TreeNodeSchema(
name=root, path=root, has_children=True, children=children
)
)
return result
@router.get("/discrepancies/browse", response_model=List[DiscrepancySchema])
def browse_discrepancies(
path: Optional[str] = None, db_session: Session = Depends(get_db)
):
"""Returns discrepancy files under a given directory path."""
# Reuse the query logic from list_discrepancies
deleted_records = db_session.query(models.FilesystemState).filter(
models.FilesystemState.is_deleted.is_(True),
models.FilesystemState.is_ignored.is_(False),
models.FilesystemState.missing_acknowledged_at.is_(None),
)
unhashed_missing = db_session.query(models.FilesystemState).filter(
models.FilesystemState.sha256_hash.is_(None),
models.FilesystemState.is_ignored.is_(False),
models.FilesystemState.is_deleted.is_(False),
models.FilesystemState.missing_acknowledged_at.is_(None),
)
# Batch-load valid version flags
all_records = deleted_records.all() + unhashed_missing.all()
record_ids = {r.id for r in all_records}
ids_with_valid_versions = set()
if record_ids:
valid_version_rows = (
db_session.query(models.FileVersion.filesystem_state_id)
.join(models.StorageMedia)
.filter(
models.FileVersion.filesystem_state_id.in_(record_ids),
models.StorageMedia.status.in_(["active", "full"]),
)
.distinct()
.all()
)
ids_with_valid_versions = {row[0] for row in valid_version_rows}
# Filter by path prefix if specified
results = []
seen_ids = set()
for record in all_records:
if record.id in seen_ids:
continue
seen_ids.add(record.id)
# Filter by path prefix
if (
path
and not record.file_path.startswith(path + "/")
and record.file_path != path
):
continue
has_valid_versions = record.id in ids_with_valid_versions
if record.is_deleted or not os.path.exists(record.file_path):
results.append(
DiscrepancySchema(
id=record.id,
path=record.file_path,
size=record.size,
mtime=datetime.fromtimestamp(record.mtime, tz=timezone.utc),
last_seen_timestamp=record.last_seen_timestamp,
sha256_hash=record.sha256_hash,
is_deleted=record.is_deleted,
has_versions=has_valid_versions,
)
)
return results
+1
View File
@@ -817,6 +817,7 @@ class ScannerService:
target_record = path_to_record.get(file_path)
if not target_record:
continue
if file_path in batch_results:
target_record.sha256_hash = batch_results[
file_path
+17 -1
View File
@@ -145,11 +145,19 @@ def test_scan_sources_mocked(db_session, mocker):
def test_run_hashing_mocked(db_session, mocker):
"""Tests the background hashing runner."""
scanner = ScannerService()
# Reset state
scanner.is_hashing = False
scanner.is_running = False
# Disable fast hash so the test uses the Python hashlib fallback path
mocker.patch("app.services.scanner._FAST_HASH_BINARY", None)
# Mock compute_sha256 to return a fixed hash
mocker.patch.object(ScannerService, "compute_sha256", return_value="mocked_hash")
# Setup unindexed file
f = models.FilesystemState(
file_path="/data/hash.me", size=10, mtime=1, is_ignored=False
@@ -162,7 +170,13 @@ def test_run_hashing_mocked(db_session, mocker):
# run_hashing runs in a loop until work is done.
# Since we aren't in 'is_running' state, it should process the 1 file and stop.
scanner.run_hashing()
try:
scanner.run_hashing()
except Exception as e:
print(f"DEBUG: run_hashing raised exception: {e}")
import traceback
traceback.print_exc()
db_session.refresh(f)
assert f.sha256_hash == "mocked_hash"
@@ -247,6 +261,8 @@ def test_missing_file_marked_deleted_at_end_of_scan(db_session, mocker):
def test_existing_file_not_marked_deleted(db_session, mocker):
"""Tests that files found during scan retain is_deleted=False."""
scanner = ScannerService()
print(f"DEBUG test_existing: scanner.is_running = {scanner.is_running}")
print(f"DEBUG test_existing: scanner.is_hashing = {scanner.is_hashing}")
mocker.patch("app.services.scanner._FAST_FIND_BINARY", None)
mocker.patch("app.api.system.get_source_roots", return_value=["/mock_source"])
File diff suppressed because one or more lines are too long
+14
View File
@@ -509,6 +509,20 @@ export const browseRecoveryQueueVirtualFsRestoresQueueBrowseGet = <ThrowOnError
*/
export const getRecoveryQueueTreeRestoresQueueTreeGet = <ThrowOnError extends boolean = false>(options?: Options<GetRecoveryQueueTreeRestoresQueueTreeGetData, ThrowOnError>) => (options?.client ?? client).get<GetRecoveryQueueTreeRestoresQueueTreeGetResponses, GetRecoveryQueueTreeRestoresQueueTreeGetErrors, ThrowOnError>({ url: '/restores/queue/tree', ...options });
/**
* Get Discrepancies Tree
*
* Returns a nested tree structure for the discrepancies view, grouped by source root.
*/
export const getDiscrepanciesTreeGet = <ThrowOnError extends boolean = false>(options?: Options<GetDiscrepanciesTreeGetData, ThrowOnError>) => (options?.client ?? client).get<GetDiscrepanciesTreeGetResponses, GetDiscrepanciesTreeGetErrors, ThrowOnError>({ url: '/system/discrepancies/tree', ...options });
/**
* Browse Discrepancies
*
* Browse the discrepancies filesystem with optional path prefix filtering.
*/
export const browseDiscrepanciesGet = <ThrowOnError extends boolean = false>(options?: Options<BrowseDiscrepanciesGetData, ThrowOnError>) => (options?.client ?? client).get<BrowseDiscrepanciesGetResponses, BrowseDiscrepanciesGetErrors, ThrowOnError>({ url: '/system/discrepancies/browse', ...options });
/**
* Health Heartbeat
*
+60
View File
@@ -1354,6 +1354,66 @@ export type GetSystemTreeSystemTreeGetResponses = {
export type GetSystemTreeSystemTreeGetResponse = GetSystemTreeSystemTreeGetResponses[keyof GetSystemTreeSystemTreeGetResponses];
export type GetDiscrepanciesTreeGetData = {
body?: never;
path?: never;
query?: {
/**
* Path
*/
path?: string | null;
};
url: '/system/discrepancies/tree';
};
export type GetDiscrepanciesTreeGetErrors = {
/**
* Validation Error
*/
422: HttpValidationError;
};
export type GetDiscrepanciesTreeGetError = GetDiscrepanciesTreeGetErrors[keyof GetDiscrepanciesTreeGetErrors];
export type GetDiscrepanciesTreeGetResponses = {
/**
* Successful Response
*/
200: Array<TreeNodeSchema>;
};
export type GetDiscrepanciesTreeGetResponse = GetDiscrepanciesTreeGetResponses[keyof GetDiscrepanciesTreeGetResponses];
export type BrowseDiscrepanciesGetData = {
body?: never;
path?: never;
query?: {
/**
* Path
*/
path?: string | null;
};
url: '/system/discrepancies/browse';
};
export type BrowseDiscrepanciesGetErrors = {
/**
* Validation Error
*/
422: HttpValidationError;
};
export type BrowseDiscrepanciesGetError = BrowseDiscrepanciesGetErrors[keyof BrowseDiscrepanciesGetErrors];
export type BrowseDiscrepanciesGetResponses = {
/**
* Successful Response
*/
200: BrowseResponseSchema;
};
export type BrowseDiscrepanciesGetResponse = BrowseDiscrepanciesGetResponses[keyof BrowseDiscrepanciesGetResponses];
export type ListDiscrepanciesSystemDiscrepanciesGetData = {
body?: never;
path?: never;
@@ -18,6 +18,14 @@
import FileBrowserRowItem from "./FileBrowserRowItem.svelte";
import type { FileItem, Breadcrumb } from "$lib/types";
import { cn } from "$lib/utils";
import {
getSystemTreeSystemTreeGet,
getArchiveTreeInventoryTreeGet,
browseSystemPathSystemBrowseGet,
browseArchiveIndexInventoryBrowseGet,
getDiscrepanciesTreeGet,
browseDiscrepanciesGet,
} from "$lib/api";
let {
currentPath = $bindable("ROOT"),
@@ -26,6 +34,8 @@
onNavigate = (path: string) => {},
onToggleTrack = (item: FileItem) => {},
onSelect = (item: FileItem) => {},
onUndoDismiss = (item: FileItem) => {},
onDelete = (item: FileItem) => {},
mode = "host",
isSearching = false,
pendingChanges = new Map<string, boolean>()
@@ -36,7 +46,9 @@
onNavigate?: (path: string) => void;
onToggleTrack?: (item: FileItem) => void;
onSelect?: (item: FileItem) => void;
mode?: "host" | "index" | "cart" | "live";
onUndoDismiss?: (item: FileItem) => void;
onDelete?: (item: FileItem) => void;
mode?: "host" | "index" | "cart" | "live" | "discrepancies";
isSearching?: boolean;
pendingChanges?: Map<string, boolean>;
}>();
@@ -173,9 +185,18 @@
hasChildren: true
});
const discrepancyRoot = $derived({
name: "Discrepancies",
path: "ROOT",
expanded: true,
children: [],
hasChildren: true
});
const activeRoot = $derived(
mode === "host" ? sourceDataRoot :
mode === "index" ? virtualIndexRoot :
mode === "discrepancies" ? discrepancyRoot :
recoveryQueueRoot
);
@@ -559,6 +580,8 @@
onClick={(e) => handleRowClick(e, item)}
onDoubleClick={() => handleRowDoubleClick(item)}
onToggleTrack={() => onToggleTrack(item)}
onUndoDismiss={() => onUndoDismiss(item)}
onDelete={() => onDelete(item)}
/>
{/each}
{/if}
@@ -13,7 +13,9 @@
ShieldCheck,
ShieldAlert,
Square,
EyeOff
EyeOff,
Undo2,
Trash2
} from "lucide-svelte";
import { Checkbox } from "$lib/components/ui/checkbox";
import { Button } from "$lib/components/ui/button";
@@ -27,6 +29,8 @@
onClick = (e: MouseEvent) => {},
onDoubleClick = () => {},
onToggleTrack = () => {},
onUndoDismiss = () => {},
onDelete = () => {},
mode = "host",
colWidths = { mtime: 200, type: 150, size: 120 }
} = $props<{
@@ -36,7 +40,9 @@
onClick?: (e: MouseEvent) => void;
onDoubleClick?: () => void;
onToggleTrack?: () => void;
mode?: "host" | "index" | "live" | "cart";
onUndoDismiss?: () => void;
onDelete?: () => void;
mode?: "host" | "index" | "live" | "cart" | "discrepancies";
colWidths?: { mtime: number; type: number; size: number };
}>();
@@ -200,6 +206,20 @@
</div>
{/if}
{/if}
{#if mode === "discrepancies"}
{#if item.is_deleted}
<span class="inline-flex items-center gap-1 bg-red-500/10 text-red-400 text-[10px] px-1.5 py-0.5 rounded border border-red-500/20 font-medium">
<Trash2 size={10} />
Deleted
</span>
{/if}
{#if item.has_versions}
<span class="inline-flex items-center gap-1 bg-green-500/10 text-green-400 text-[10px] px-1.5 py-0.5 rounded border border-green-500/20 font-medium">
<ShieldCheck size={10} />
Has Versions
</span>
{/if}
{/if}
</div>
</div>
</div>
@@ -229,13 +249,40 @@
</div>
<!-- QUICK ACTIONS -->
<div class="w-10 shrink-0 flex justify-center opacity-0 group-hover:opacity-100 transition-opacity">
<Button
variant="ghost"
size="icon"
class="h-7 w-7 text-text-secondary hover:text-text-primary hover:bg-white/10"
>
<MoreVertical size={14} />
</Button>
<div class="w-24 shrink-0 flex items-center justify-end gap-1 px-2">
{#if mode === "discrepancies"}
{#if item.discrepancy_id}
<Button
variant="ghost"
size="icon"
class="h-7 w-7 text-text-secondary hover:text-blue-400 hover:bg-blue-500/10"
onclick={(e) => { e.stopPropagation(); onUndoDismiss(); }}
title="Undo dismiss"
>
<Undo2 size={14} />
</Button>
{/if}
{#if item.is_deleted}
<Button
variant="ghost"
size="icon"
class="h-7 w-7 text-text-secondary hover:text-red-400 hover:bg-red-500/10"
onclick={(e) => { e.stopPropagation(); onDelete(); }}
title="Delete permanently"
>
<Trash2 size={14} />
</Button>
{/if}
{:else}
<div class="opacity-0 group-hover:opacity-100 transition-opacity">
<Button
variant="ghost"
size="icon"
class="h-7 w-7 text-text-secondary hover:text-text-primary hover:bg-white/10"
>
<MoreVertical size={14} />
</Button>
</div>
{/if}
</div>
</div>
@@ -5,7 +5,7 @@
import type { TreeNode } from "$lib/types";
import { cn } from "$lib/utils";
import FileBrowserTreeItem from "./FileBrowserTreeItem.svelte";
import { getSystemTreeSystemTreeGet, getArchiveTreeInventoryTreeGet } from "$lib/api";
import { getSystemTreeSystemTreeGet, getArchiveTreeInventoryTreeGet, getDiscrepanciesTreeGet } from "$lib/api";
let {
node,
@@ -20,7 +20,7 @@
onSelect?: (path: string) => void;
level?: number;
isSpecial?: boolean;
mode?: "host" | "index" | "live";
mode?: "host" | "index" | "live" | "discrepancies";
}>();
let expanded = $state(false);
@@ -60,10 +60,17 @@
loading = true;
try {
const fetchFn = (mode === "host" || mode === "live") ? getSystemTreeSystemTreeGet : getArchiveTreeInventoryTreeGet;
const response = await fetchFn({
query: { path: node.path }
});
let response;
if (mode === "discrepancies") {
response = await getDiscrepanciesTreeGet({
query: { path: node.path }
});
} else {
const fetchFn = (mode === "host" || mode === "live") ? getSystemTreeSystemTreeGet : getArchiveTreeInventoryTreeGet;
response = await fetchFn({
query: { path: node.path }
});
}
const data = response.data as any[];
if (data && Array.isArray(data)) {
@@ -72,7 +79,8 @@
path: d.path,
children: [],
expanded: false,
hasChildren: d.has_children
hasChildren: d.has_children,
discrepancy_count: d.discrepancy_count
}));
}
loaded = true;
@@ -136,6 +144,11 @@
<span class="text-[13px] font-medium truncate">
{node.name === "ROOT" ? "Virtual Root" : node.name}
</span>
{#if node.discrepancy_count && node.discrepancy_count > 0}
<span class="ml-auto bg-red-500/20 text-red-400 text-[10px] px-1.5 py-0.5 rounded-full font-medium">
{node.discrepancy_count}
</span>
{/if}
</div>
{#if loading}
+6
View File
@@ -11,6 +11,10 @@ export interface FileItem {
sha256_hash?: string | null;
vulnerable?: boolean;
indeterminate?: boolean;
// Discrepancy fields
discrepancy_id?: number;
is_deleted?: boolean;
has_versions?: boolean;
}
export interface TreeNode {
@@ -18,6 +22,8 @@ export interface TreeNode {
path: string;
children?: TreeNode[];
expanded?: boolean;
hasChildren?: boolean;
discrepancy_count?: number;
}
export interface Breadcrumb {
+48 -369
View File
@@ -1,27 +1,11 @@
<script lang="ts">
import { onMount } from 'svelte';
import {
AlertTriangle,
FileX,
FileQuestion,
RotateCw,
Check,
ShieldCheck,
EyeOff,
FolderOpen,
X,
ChevronDown,
ChevronRight,
HardDriveDownload
} from 'lucide-svelte';
import { AlertTriangle, RotateCw, ShieldCheck, HardDriveDownload } from 'lucide-svelte';
import { Button } from '$lib/components/ui/button';
import PageHeader from '$lib/components/ui/PageHeader.svelte';
import SectionHeader from '$lib/components/ui/SectionHeader.svelte';
import { Card } from '$lib/components/ui/card';
import StatusBadge from '$lib/components/ui/StatusBadge.svelte';
import StatCard from '$lib/components/ui/StatCard.svelte';
import EmptyState from '$lib/components/ui/EmptyState.svelte';
import { cn, formatLocalDate, formatSize as formatSizeUtil } from '$lib/utils';
import FileBrowser from '$lib/components/file-browser/FileBrowser.svelte';
import { toast } from 'svelte-sonner';
import {
listDiscrepanciesSystemDiscrepanciesGet,
@@ -30,43 +14,20 @@
batchHardDeleteSystemDiscrepanciesBatchDeletePost,
addFileToRecoveryQueueRestoresQueueFileFileIdPost,
batchAddToRecoveryQueueRestoresQueueBatchPost,
type DiscrepancySchema
browseDiscrepanciesGet,
getDiscrepanciesTreeGet,
type DiscrepancySchema,
type FileItem
} from '$lib/api';
interface GroupedItem {
directory: string;
items: DiscrepancySchema[];
}
import { POLL_FAST } from '$lib/config';
let discrepancies = $state<DiscrepancySchema[]>([]);
let files = $state<FileItem[]>([]);
let loading = $state(true);
let acknowledging = $state<number | null>(null);
let recovering = $state<number | null>(null);
let currentPath = $state("ROOT");
let selectedIds = $state<Set<number>>(new Set());
let batchAction = $state<'acknowledge' | 'recover' | null>(null);
let batchLoading = $state(false);
let collapsedDirs = $state<Record<string, boolean>>({});
function toggleCollapse(dir: string) {
collapsedDirs[dir] = !collapsedDirs[dir];
}
const groupedItems = $derived.by(() => {
collapsedDirs;
const map = new Map<string, DiscrepancySchema[]>();
for (const d of discrepancies) {
const parts = d.path.split('/');
const dir = parts.length > 1 ? parts.slice(0, -1).join('/') : '/';
if (!map.has(dir)) map.set(dir, []);
map.get(dir)!.push(d);
}
const result: GroupedItem[] = [];
for (const [dir, items] of map) {
result.push({ directory: dir, items });
}
result.sort((a, b) => a.directory.localeCompare(b.directory));
return result;
});
async function loadDiscrepancies() {
loading = true;
@@ -74,6 +35,18 @@
const response = await listDiscrepanciesSystemDiscrepanciesGet();
if (response.data) {
discrepancies = response.data;
// Convert discrepancies to FileItem format for FileBrowser
files = response.data.map((d: DiscrepancySchema) => ({
name: d.path.split('/').pop() || d.path,
path: d.path,
type: d.is_deleted ? 'file' : 'file',
size: d.size,
mtime: d.mtime,
discrepancy_id: d.id,
is_deleted: d.is_deleted,
has_versions: d.has_versions,
ignored: false
}));
}
} catch (error) {
console.error("Failed to load discrepancies:", error);
@@ -83,139 +56,47 @@
}
}
async function acknowledgeLoss(id: number) {
acknowledging = id;
async function undoDismiss(item: FileItem) {
if (!item.discrepancy_id) return;
try {
await dismissDiscrepancySystemDiscrepanciesFileIdDismissPost({
path: { file_id: id }
path: { file_id: item.discrepancy_id }
});
toast.success("Loss acknowledged");
toast.success("Dismissal undone");
await loadDiscrepancies();
} catch (error: any) {
toast.error(error.body?.detail || "Failed to acknowledge loss");
} finally {
acknowledging = null;
toast.error(error.body?.detail || "Failed to undo dismiss");
}
}
async function addToRecoveryQueue(id: number) {
recovering = id;
async function deletePermanently(item: FileItem) {
if (!item.discrepancy_id) return;
try {
await addFileToRecoveryQueueRestoresQueueFileFileIdPost({
path: { file_id: id }
await batchHardDeleteSystemDiscrepanciesBatchDeletePost({
body: { ids: [item.discrepancy_id] }
});
toast.success("Added to recovery queue");
toast.success("File record deleted permanently");
await loadDiscrepancies();
} catch (error: any) {
toast.error(error.body?.detail || "Failed to add to recovery queue");
} finally {
recovering = null;
toast.error(error.body?.detail || "Failed to delete file record");
}
}
async function executeBatchAction() {
if (!batchAction || selectedIds.size === 0) return;
batchLoading = true;
const ids = Array.from(selectedIds);
try {
if (batchAction === 'recover') {
await batchAddToRecoveryQueueRestoresQueueBatchPost({
body: { ids }
});
toast.success(`${selectedIds.size} file(s) added to recovery queue`);
} else if (batchAction === 'acknowledge') {
await batchDismissSystemDiscrepanciesBatchDismissPost({
body: { ids }
});
toast.success(`${selectedIds.size} file(s) acknowledged as lost`);
}
selectedIds = new Set();
batchAction = null;
await loadDiscrepancies();
} catch (error: any) {
toast.error(error.body?.detail || "Batch action failed");
} finally {
batchLoading = false;
}
async function navigateTo(path: string) {
currentPath = path;
}
function toggleSelect(id: number) {
const next = new Set(selectedIds);
if (next.has(id)) {
next.delete(id);
} else {
next.add(id);
}
selectedIds = next;
}
function selectAllInGroup(items: DiscrepancySchema[]) {
const next = new Set(selectedIds);
const allSelected = items.every(i => next.has(i.id));
if (allSelected) {
items.forEach(i => next.delete(i.id));
} else {
items.forEach(i => next.add(i.id));
}
selectedIds = next;
}
function selectAllMissing() {
const ids = missingItems.map(d => d.id);
const allSelected = ids.every(id => selectedIds.has(id));
if (allSelected) {
ids.forEach(id => selectedIds.delete(id));
} else {
ids.forEach(id => selectedIds.add(id));
}
selectedIds = new Set(selectedIds);
}
function selectAllPending() {
const ids = pendingItems.map(d => d.id);
const allSelected = ids.every(id => selectedIds.has(id));
if (allSelected) {
ids.forEach(id => selectedIds.delete(id));
} else {
ids.forEach(id => selectedIds.add(id));
}
selectedIds = new Set(selectedIds);
}
function clearSelection() {
selectedIds = new Set();
}
const selectedItems = $derived(discrepancies.filter(d => selectedIds.has(d.id)));
const selectedWithBackups = $derived(selectedItems.filter(d => d.has_versions));
const selectedWithoutBackups = $derived(selectedItems.filter(d => !d.has_versions));
const formatSize = formatSizeUtil;
function formatPath(path: string) {
const parts = path.split('/');
if (parts.length <= 3) return path;
const headParts = parts.slice(0, -2);
const tailParts = parts.slice(-2);
return {
head: headParts.join('/'),
tail: tailParts.join('/')
};
}
onMount(loadDiscrepancies);
const missingItems = $derived(discrepancies.filter(d => d.is_deleted));
const pendingItems = $derived(discrepancies.filter(d => !d.is_deleted));
const allMissingSelected = $derived(missingItems.length > 0 && missingItems.every(d => selectedIds.has(d.id)));
const allPendingSelected = $derived(pendingItems.length > 0 && pendingItems.every(d => selectedIds.has(d.id)));
onMount(loadDiscrepancies);
</script>
<svelte:head>
<title>Discrepancies - TapeHoard</title>
</svelte:head>
<div class="flex flex-col gap-6 animate-in fade-in duration-700">
<div class="flex flex-col gap-6 flex-1 min-h-0 animate-in fade-in duration-700">
<PageHeader
title="Discrepancies"
description="Files missing from disk or confirmed deleted"
@@ -248,223 +129,21 @@
/>
{:else}
<!-- Summary Statistics -->
<div class="grid grid-cols-2 gap-3">
<div class="grid grid-cols-2 gap-3 shrink-0">
<StatCard label="Missing from disk" value={missingItems.length} subLabel="Files the scanner did not find" variant="error" />
<StatCard label="Pending confirmation" value={pendingItems.length} subLabel="Tracked files not yet confirmed" variant="warning" />
</div>
<!-- Batch Selection Toolbar -->
{#if selectedIds.size > 0}
<Card class="p-4 bg-blue-500/10 border-blue-500/30 flex items-center gap-4">
<span class="text-sm font-medium text-blue-400">
{selectedIds.size} file(s) selected
{#if selectedWithBackups.length > 0 && selectedWithoutBackups.length > 0}
<span class="opacity-60">
({selectedWithBackups.length} backed up, {selectedWithoutBackups.length} no backup)
</span>
{/if}
</span>
<div class="flex-1"></div>
{#if selectedWithBackups.length > 0}
<Button
variant="outline"
size="sm"
class="h-8 text-xs border-success-color/30 text-success-color hover:bg-success-color/10"
onclick={() => batchAction = 'recover'}
>
<HardDriveDownload size={12} class="mr-1.5" /> Add to recovery
</Button>
{/if}
{#if selectedWithoutBackups.length > 0}
<Button
variant="outline"
size="sm"
class="h-8 text-xs border-yellow-500/30 text-yellow-400 hover:bg-yellow-500/10"
onclick={() => batchAction = 'acknowledge'}
>
<ShieldCheck size={12} class="mr-1.5" /> Acknowledge loss
</Button>
{/if}
<Button
variant="ghost"
size="sm"
class="h-8 text-xs text-text-secondary"
onclick={clearSelection}
>
<X size={12} class="mr-1.5" /> Clear
</Button>
</Card>
{/if}
<!-- Batch Action Confirmation -->
{#if batchAction}
<Card class="p-6 bg-bg-secondary border-border-color">
<div class="flex items-center justify-between">
<div>
{#if batchAction === 'recover'}
<h4 class="text-sm font-bold text-success-color">
Add {selectedWithBackups.length} file(s) to recovery queue?
</h4>
<p class="text-xs text-text-secondary opacity-60 mt-1">
These files will be queued for restoration from archive media.
</p>
{:else}
<h4 class="text-sm font-bold text-yellow-400">
Acknowledge loss of {selectedWithoutBackups.length} file(s)?
</h4>
<p class="text-xs text-text-secondary opacity-60 mt-1">
These files have no backup on archive media. They will be marked as acknowledged lost.
</p>
{/if}
</div>
<div class="flex items-center gap-2">
<Button variant="outline" size="sm" onclick={() => batchAction = null}>
Cancel
</Button>
<Button
size="sm"
class={cn(
batchAction === 'recover' && 'bg-success-color hover:bg-success-color/90',
batchAction === 'acknowledge' && 'bg-yellow-500 hover:bg-yellow-500/90 text-black'
)}
onclick={executeBatchAction}
disabled={batchLoading}
>
{#if batchLoading}
<RotateCw size={14} class="mr-2 animate-spin" />
{/if}
{#if batchAction === 'recover'}
Add to recovery
{:else}
Acknowledge loss
{/if}
</Button>
</div>
</div>
</Card>
{/if}
<div class="space-y-8">
<!-- MISSING ITEMS SECTION -->
{#if missingItems.length > 0}
<section class="space-y-4">
<div class="flex items-center gap-3">
<SectionHeader title="Missing from disk" icon={FileX} iconColor="text-error-color" class="flex-1" />
<button
class="text-[10px] font-medium text-text-secondary uppercase tracking-wider hover:text-text-primary transition-colors px-2"
onclick={selectAllMissing}
>
{allMissingSelected ? 'Deselect all' : 'Select all'}
</button>
</div>
<div class="grid grid-cols-1 gap-3">
{#each groupedItems.filter(g => g.items.some(i => i.is_deleted)) as group}
<div class="space-y-2">
<div class="flex items-center gap-2 px-1">
<button
class="flex items-center gap-2 text-xs font-medium text-text-secondary hover:text-text-primary transition-colors"
onclick={() => toggleCollapse('missing-' + group.directory)}
>
{#if collapsedDirs['missing-' + group.directory]}
<ChevronRight size={14} />
{:else}
<ChevronDown size={14} />
{/if}
<FolderOpen size={14} />
<span class="mono text-xs">{group.directory}</span>
<span class="text-[10px] font-normal opacity-40">({group.items.filter(i => i.is_deleted).length})</span>
</button>
<button
class="text-[10px] text-text-secondary opacity-40 hover:opacity-70 hover:text-text-primary transition-colors ml-auto"
onclick={() => selectAllInGroup(group.items.filter(i => i.is_deleted))}
>
{group.items.filter(i => i.is_deleted).every(i => selectedIds.has(i.id)) ? 'Deselect group' : 'Select group'}
</button>
</div>
{#if !collapsedDirs['missing-' + group.directory]}
{#each group.items.filter(i => i.is_deleted) as item (item.id)}
{@const path = formatPath(item.path)}
<Card class="px-5 py-3 bg-bg-secondary/40 border-border-color/40 hover:bg-bg-secondary transition-colors group">
<div class="flex items-center gap-4">
<input
type="checkbox"
class="rounded border-border-color/30 bg-transparent cursor-pointer shrink-0"
checked={selectedIds.has(item.id)}
onchange={() => toggleSelect(item.id)}
/>
<StatusBadge variant="error">Missing</StatusBadge>
<div class="flex-1 min-w-0">
{#if typeof path === 'string'}
<span class="text-sm font-medium text-text-primary mono truncate block" title={item.path}>
{path}
</span>
{:else}
<div class="flex flex-col gap-0.5">
<span class="text-xs font-medium text-text-secondary mono leading-tight" title={item.path}>
{path.head}
</span>
<span class="text-sm font-medium text-text-primary mono leading-tight" title={item.path}>
{path.tail}
</span>
</div>
{/if}
<p class="text-xs text-text-secondary mt-0.5 opacity-60">{formatSize(item.size)}</p>
</div>
<div class="flex items-center gap-1.5 shrink-0">
{#if item.has_versions}
<div class="inline-flex items-center gap-1.5 px-2 py-0.5 rounded-md bg-success-color/10 text-success-color">
<ShieldCheck size={11} />
<span class="text-[10px] font-medium">On archive</span>
</div>
<Button
variant="ghost"
size="icon"
class="h-8 w-8 text-success-color hover:bg-success-color/10 opacity-40 group-hover:opacity-100 transition-opacity"
onclick={() => addToRecoveryQueue(item.id)}
disabled={recovering === item.id}
title="Add to recovery queue"
>
{#if recovering === item.id}
<RotateCw size={14} class="animate-spin" />
{:else}
<HardDriveDownload size={14} />
{/if}
</Button>
{:else}
<div class="inline-flex items-center gap-1.5 px-2 py-0.5 rounded-md bg-yellow-500/10 text-yellow-500">
<EyeOff size={11} />
<span class="text-[10px] font-medium">No backup</span>
</div>
<Button
variant="ghost"
size="icon"
class="h-8 w-8 text-yellow-500 hover:bg-yellow-500/10 opacity-40 group-hover:opacity-100 transition-opacity"
onclick={() => acknowledgeLoss(item.id)}
disabled={acknowledging === item.id}
title="Acknowledge loss"
>
{#if acknowledging === item.id}
<RotateCw size={14} class="animate-spin" />
{:else}
<ShieldCheck size={14} />
{/if}
</Button>
{/if}
</div>
</div>
</Card>
{/each}
{/if}
</div>
{/each}
</div>
</section>
{/if}
<!-- FileBrowser Component in discrepancies mode -->
<div class="flex-1 min-h-0 overflow-hidden">
<FileBrowser
bind:currentPath={currentPath}
files={files}
mode="discrepancies"
onNavigate={navigateTo}
onUndoDismiss={undoDismiss}
onDelete={deletePermanently}
/>
</div>
{/if}
</div>