Compare commits
2 Commits
79986066bf
...
d6250986b8
| Author | SHA1 | Date | |
|---|---|---|---|
| d6250986b8 | |||
| e939e6a86d |
@@ -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):
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
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
File diff suppressed because one or more lines are too long
@@ -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,7 +249,32 @@
|
||||
</div>
|
||||
|
||||
<!-- QUICK ACTIONS -->
|
||||
<div class="w-10 shrink-0 flex justify-center opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<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: MouseEvent) => { 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: MouseEvent) => { 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"
|
||||
@@ -238,4 +283,6 @@
|
||||
<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({
|
||||
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}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
} from '$lib/api';
|
||||
|
||||
interface GroupedItem {
|
||||
directory: string;
|
||||
items: DiscrepancySchema[];
|
||||
}
|
||||
import { type FileItem } from '$lib/types';
|
||||
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: 'file',
|
||||
size: d.size,
|
||||
mtime: d.mtime ? new Date(d.mtime).getTime() / 1000 : undefined,
|
||||
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`);
|
||||
async function navigateTo(path: string) {
|
||||
currentPath = path;
|
||||
}
|
||||
selectedIds = new Set();
|
||||
batchAction = null;
|
||||
await loadDiscrepancies();
|
||||
} catch (error: any) {
|
||||
toast.error(error.body?.detail || "Batch action failed");
|
||||
} finally {
|
||||
batchLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
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)}
|
||||
<!-- 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}
|
||||
/>
|
||||
|
||||
<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}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user