add discrepancy mode for filebrowser
This commit is contained in:
@@ -1,4 +1,4 @@
|
|||||||
from pydantic import BaseModel, ConfigDict
|
from pydantic import BaseModel, ConfigDict, Field
|
||||||
from typing import Optional, List, Dict, Any
|
from typing import Optional, List, Dict, Any
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
|
||||||
@@ -7,6 +7,7 @@ class TreeNodeSchema(BaseModel):
|
|||||||
name: str
|
name: str
|
||||||
path: str
|
path: str
|
||||||
has_children: bool = True
|
has_children: bool = True
|
||||||
|
children: List["TreeNodeSchema"] = Field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
class ItemMetadataSchema(BaseModel):
|
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.delete(record)
|
||||||
db_session.commit()
|
db_session.commit()
|
||||||
return {"message": f"File record '{file_path}' permanently deleted"}
|
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)
|
target_record = path_to_record.get(file_path)
|
||||||
if not target_record:
|
if not target_record:
|
||||||
continue
|
continue
|
||||||
|
|
||||||
if file_path in batch_results:
|
if file_path in batch_results:
|
||||||
target_record.sha256_hash = batch_results[
|
target_record.sha256_hash = batch_results[
|
||||||
file_path
|
file_path
|
||||||
|
|||||||
@@ -145,11 +145,19 @@ def test_scan_sources_mocked(db_session, mocker):
|
|||||||
|
|
||||||
def test_run_hashing_mocked(db_session, mocker):
|
def test_run_hashing_mocked(db_session, mocker):
|
||||||
"""Tests the background hashing runner."""
|
"""Tests the background hashing runner."""
|
||||||
|
|
||||||
scanner = ScannerService()
|
scanner = ScannerService()
|
||||||
|
|
||||||
|
# Reset state
|
||||||
|
scanner.is_hashing = False
|
||||||
|
scanner.is_running = False
|
||||||
|
|
||||||
# Disable fast hash so the test uses the Python hashlib fallback path
|
# Disable fast hash so the test uses the Python hashlib fallback path
|
||||||
mocker.patch("app.services.scanner._FAST_HASH_BINARY", None)
|
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
|
# Setup unindexed file
|
||||||
f = models.FilesystemState(
|
f = models.FilesystemState(
|
||||||
file_path="/data/hash.me", size=10, mtime=1, is_ignored=False
|
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.
|
# 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.
|
# 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)
|
db_session.refresh(f)
|
||||||
assert f.sha256_hash == "mocked_hash"
|
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):
|
def test_existing_file_not_marked_deleted(db_session, mocker):
|
||||||
"""Tests that files found during scan retain is_deleted=False."""
|
"""Tests that files found during scan retain is_deleted=False."""
|
||||||
scanner = ScannerService()
|
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.services.scanner._FAST_FIND_BINARY", None)
|
||||||
mocker.patch("app.api.system.get_source_roots", return_value=["/mock_source"])
|
mocker.patch("app.api.system.get_source_roots", return_value=["/mock_source"])
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
@@ -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 });
|
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
|
* Health Heartbeat
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -1354,6 +1354,66 @@ export type GetSystemTreeSystemTreeGetResponses = {
|
|||||||
|
|
||||||
export type GetSystemTreeSystemTreeGetResponse = GetSystemTreeSystemTreeGetResponses[keyof 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 = {
|
export type ListDiscrepanciesSystemDiscrepanciesGetData = {
|
||||||
body?: never;
|
body?: never;
|
||||||
path?: never;
|
path?: never;
|
||||||
|
|||||||
@@ -18,6 +18,14 @@
|
|||||||
import FileBrowserRowItem from "./FileBrowserRowItem.svelte";
|
import FileBrowserRowItem from "./FileBrowserRowItem.svelte";
|
||||||
import type { FileItem, Breadcrumb } from "$lib/types";
|
import type { FileItem, Breadcrumb } from "$lib/types";
|
||||||
import { cn } from "$lib/utils";
|
import { cn } from "$lib/utils";
|
||||||
|
import {
|
||||||
|
getSystemTreeSystemTreeGet,
|
||||||
|
getArchiveTreeInventoryTreeGet,
|
||||||
|
browseSystemPathSystemBrowseGet,
|
||||||
|
browseArchiveIndexInventoryBrowseGet,
|
||||||
|
getDiscrepanciesTreeGet,
|
||||||
|
browseDiscrepanciesGet,
|
||||||
|
} from "$lib/api";
|
||||||
|
|
||||||
let {
|
let {
|
||||||
currentPath = $bindable("ROOT"),
|
currentPath = $bindable("ROOT"),
|
||||||
@@ -26,6 +34,8 @@
|
|||||||
onNavigate = (path: string) => {},
|
onNavigate = (path: string) => {},
|
||||||
onToggleTrack = (item: FileItem) => {},
|
onToggleTrack = (item: FileItem) => {},
|
||||||
onSelect = (item: FileItem) => {},
|
onSelect = (item: FileItem) => {},
|
||||||
|
onUndoDismiss = (item: FileItem) => {},
|
||||||
|
onDelete = (item: FileItem) => {},
|
||||||
mode = "host",
|
mode = "host",
|
||||||
isSearching = false,
|
isSearching = false,
|
||||||
pendingChanges = new Map<string, boolean>()
|
pendingChanges = new Map<string, boolean>()
|
||||||
@@ -36,7 +46,9 @@
|
|||||||
onNavigate?: (path: string) => void;
|
onNavigate?: (path: string) => void;
|
||||||
onToggleTrack?: (item: FileItem) => void;
|
onToggleTrack?: (item: FileItem) => void;
|
||||||
onSelect?: (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;
|
isSearching?: boolean;
|
||||||
pendingChanges?: Map<string, boolean>;
|
pendingChanges?: Map<string, boolean>;
|
||||||
}>();
|
}>();
|
||||||
@@ -173,9 +185,18 @@
|
|||||||
hasChildren: true
|
hasChildren: true
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const discrepancyRoot = $derived({
|
||||||
|
name: "Discrepancies",
|
||||||
|
path: "ROOT",
|
||||||
|
expanded: true,
|
||||||
|
children: [],
|
||||||
|
hasChildren: true
|
||||||
|
});
|
||||||
|
|
||||||
const activeRoot = $derived(
|
const activeRoot = $derived(
|
||||||
mode === "host" ? sourceDataRoot :
|
mode === "host" ? sourceDataRoot :
|
||||||
mode === "index" ? virtualIndexRoot :
|
mode === "index" ? virtualIndexRoot :
|
||||||
|
mode === "discrepancies" ? discrepancyRoot :
|
||||||
recoveryQueueRoot
|
recoveryQueueRoot
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -559,6 +580,8 @@
|
|||||||
onClick={(e) => handleRowClick(e, item)}
|
onClick={(e) => handleRowClick(e, item)}
|
||||||
onDoubleClick={() => handleRowDoubleClick(item)}
|
onDoubleClick={() => handleRowDoubleClick(item)}
|
||||||
onToggleTrack={() => onToggleTrack(item)}
|
onToggleTrack={() => onToggleTrack(item)}
|
||||||
|
onUndoDismiss={() => onUndoDismiss(item)}
|
||||||
|
onDelete={() => onDelete(item)}
|
||||||
/>
|
/>
|
||||||
{/each}
|
{/each}
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
@@ -13,7 +13,9 @@
|
|||||||
ShieldCheck,
|
ShieldCheck,
|
||||||
ShieldAlert,
|
ShieldAlert,
|
||||||
Square,
|
Square,
|
||||||
EyeOff
|
EyeOff,
|
||||||
|
Undo2,
|
||||||
|
Trash2
|
||||||
} from "lucide-svelte";
|
} from "lucide-svelte";
|
||||||
import { Checkbox } from "$lib/components/ui/checkbox";
|
import { Checkbox } from "$lib/components/ui/checkbox";
|
||||||
import { Button } from "$lib/components/ui/button";
|
import { Button } from "$lib/components/ui/button";
|
||||||
@@ -27,6 +29,8 @@
|
|||||||
onClick = (e: MouseEvent) => {},
|
onClick = (e: MouseEvent) => {},
|
||||||
onDoubleClick = () => {},
|
onDoubleClick = () => {},
|
||||||
onToggleTrack = () => {},
|
onToggleTrack = () => {},
|
||||||
|
onUndoDismiss = () => {},
|
||||||
|
onDelete = () => {},
|
||||||
mode = "host",
|
mode = "host",
|
||||||
colWidths = { mtime: 200, type: 150, size: 120 }
|
colWidths = { mtime: 200, type: 150, size: 120 }
|
||||||
} = $props<{
|
} = $props<{
|
||||||
@@ -36,7 +40,9 @@
|
|||||||
onClick?: (e: MouseEvent) => void;
|
onClick?: (e: MouseEvent) => void;
|
||||||
onDoubleClick?: () => void;
|
onDoubleClick?: () => void;
|
||||||
onToggleTrack?: () => 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 };
|
colWidths?: { mtime: number; type: number; size: number };
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
@@ -200,6 +206,20 @@
|
|||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
{/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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -229,13 +249,40 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- QUICK ACTIONS -->
|
<!-- 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">
|
||||||
<Button
|
{#if mode === "discrepancies"}
|
||||||
variant="ghost"
|
{#if item.discrepancy_id}
|
||||||
size="icon"
|
<Button
|
||||||
class="h-7 w-7 text-text-secondary hover:text-text-primary hover:bg-white/10"
|
variant="ghost"
|
||||||
>
|
size="icon"
|
||||||
<MoreVertical size={14} />
|
class="h-7 w-7 text-text-secondary hover:text-blue-400 hover:bg-blue-500/10"
|
||||||
</Button>
|
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>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -5,7 +5,7 @@
|
|||||||
import type { TreeNode } from "$lib/types";
|
import type { TreeNode } from "$lib/types";
|
||||||
import { cn } from "$lib/utils";
|
import { cn } from "$lib/utils";
|
||||||
import FileBrowserTreeItem from "./FileBrowserTreeItem.svelte";
|
import FileBrowserTreeItem from "./FileBrowserTreeItem.svelte";
|
||||||
import { getSystemTreeSystemTreeGet, getArchiveTreeInventoryTreeGet } from "$lib/api";
|
import { getSystemTreeSystemTreeGet, getArchiveTreeInventoryTreeGet, getDiscrepanciesTreeGet } from "$lib/api";
|
||||||
|
|
||||||
let {
|
let {
|
||||||
node,
|
node,
|
||||||
@@ -20,7 +20,7 @@
|
|||||||
onSelect?: (path: string) => void;
|
onSelect?: (path: string) => void;
|
||||||
level?: number;
|
level?: number;
|
||||||
isSpecial?: boolean;
|
isSpecial?: boolean;
|
||||||
mode?: "host" | "index" | "live";
|
mode?: "host" | "index" | "live" | "discrepancies";
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
let expanded = $state(false);
|
let expanded = $state(false);
|
||||||
@@ -60,10 +60,17 @@
|
|||||||
|
|
||||||
loading = true;
|
loading = true;
|
||||||
try {
|
try {
|
||||||
const fetchFn = (mode === "host" || mode === "live") ? getSystemTreeSystemTreeGet : getArchiveTreeInventoryTreeGet;
|
let response;
|
||||||
const response = await fetchFn({
|
if (mode === "discrepancies") {
|
||||||
query: { path: node.path }
|
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[];
|
const data = response.data as any[];
|
||||||
if (data && Array.isArray(data)) {
|
if (data && Array.isArray(data)) {
|
||||||
@@ -72,7 +79,8 @@
|
|||||||
path: d.path,
|
path: d.path,
|
||||||
children: [],
|
children: [],
|
||||||
expanded: false,
|
expanded: false,
|
||||||
hasChildren: d.has_children
|
hasChildren: d.has_children,
|
||||||
|
discrepancy_count: d.discrepancy_count
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
loaded = true;
|
loaded = true;
|
||||||
@@ -136,6 +144,11 @@
|
|||||||
<span class="text-[13px] font-medium truncate">
|
<span class="text-[13px] font-medium truncate">
|
||||||
{node.name === "ROOT" ? "Virtual Root" : node.name}
|
{node.name === "ROOT" ? "Virtual Root" : node.name}
|
||||||
</span>
|
</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>
|
</div>
|
||||||
|
|
||||||
{#if loading}
|
{#if loading}
|
||||||
|
|||||||
@@ -11,6 +11,10 @@ export interface FileItem {
|
|||||||
sha256_hash?: string | null;
|
sha256_hash?: string | null;
|
||||||
vulnerable?: boolean;
|
vulnerable?: boolean;
|
||||||
indeterminate?: boolean;
|
indeterminate?: boolean;
|
||||||
|
// Discrepancy fields
|
||||||
|
discrepancy_id?: number;
|
||||||
|
is_deleted?: boolean;
|
||||||
|
has_versions?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface TreeNode {
|
export interface TreeNode {
|
||||||
@@ -18,6 +22,8 @@ export interface TreeNode {
|
|||||||
path: string;
|
path: string;
|
||||||
children?: TreeNode[];
|
children?: TreeNode[];
|
||||||
expanded?: boolean;
|
expanded?: boolean;
|
||||||
|
hasChildren?: boolean;
|
||||||
|
discrepancy_count?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Breadcrumb {
|
export interface Breadcrumb {
|
||||||
|
|||||||
@@ -1,27 +1,11 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
import {
|
import { AlertTriangle, RotateCw, ShieldCheck, HardDriveDownload } from 'lucide-svelte';
|
||||||
AlertTriangle,
|
|
||||||
FileX,
|
|
||||||
FileQuestion,
|
|
||||||
RotateCw,
|
|
||||||
Check,
|
|
||||||
ShieldCheck,
|
|
||||||
EyeOff,
|
|
||||||
FolderOpen,
|
|
||||||
X,
|
|
||||||
ChevronDown,
|
|
||||||
ChevronRight,
|
|
||||||
HardDriveDownload
|
|
||||||
} from 'lucide-svelte';
|
|
||||||
import { Button } from '$lib/components/ui/button';
|
import { Button } from '$lib/components/ui/button';
|
||||||
import PageHeader from '$lib/components/ui/PageHeader.svelte';
|
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 StatCard from '$lib/components/ui/StatCard.svelte';
|
||||||
import EmptyState from '$lib/components/ui/EmptyState.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 { toast } from 'svelte-sonner';
|
||||||
import {
|
import {
|
||||||
listDiscrepanciesSystemDiscrepanciesGet,
|
listDiscrepanciesSystemDiscrepanciesGet,
|
||||||
@@ -30,43 +14,20 @@
|
|||||||
batchHardDeleteSystemDiscrepanciesBatchDeletePost,
|
batchHardDeleteSystemDiscrepanciesBatchDeletePost,
|
||||||
addFileToRecoveryQueueRestoresQueueFileFileIdPost,
|
addFileToRecoveryQueueRestoresQueueFileFileIdPost,
|
||||||
batchAddToRecoveryQueueRestoresQueueBatchPost,
|
batchAddToRecoveryQueueRestoresQueueBatchPost,
|
||||||
type DiscrepancySchema
|
browseDiscrepanciesGet,
|
||||||
|
getDiscrepanciesTreeGet,
|
||||||
|
type DiscrepancySchema,
|
||||||
|
type FileItem
|
||||||
} from '$lib/api';
|
} from '$lib/api';
|
||||||
|
import { POLL_FAST } from '$lib/config';
|
||||||
interface GroupedItem {
|
|
||||||
directory: string;
|
|
||||||
items: DiscrepancySchema[];
|
|
||||||
}
|
|
||||||
|
|
||||||
let discrepancies = $state<DiscrepancySchema[]>([]);
|
let discrepancies = $state<DiscrepancySchema[]>([]);
|
||||||
|
let files = $state<FileItem[]>([]);
|
||||||
let loading = $state(true);
|
let loading = $state(true);
|
||||||
let acknowledging = $state<number | null>(null);
|
let currentPath = $state("ROOT");
|
||||||
let recovering = $state<number | null>(null);
|
|
||||||
let selectedIds = $state<Set<number>>(new Set());
|
let selectedIds = $state<Set<number>>(new Set());
|
||||||
let batchAction = $state<'acknowledge' | 'recover' | null>(null);
|
let batchAction = $state<'acknowledge' | 'recover' | null>(null);
|
||||||
let batchLoading = $state(false);
|
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() {
|
async function loadDiscrepancies() {
|
||||||
loading = true;
|
loading = true;
|
||||||
@@ -74,6 +35,18 @@
|
|||||||
const response = await listDiscrepanciesSystemDiscrepanciesGet();
|
const response = await listDiscrepanciesSystemDiscrepanciesGet();
|
||||||
if (response.data) {
|
if (response.data) {
|
||||||
discrepancies = 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) {
|
} catch (error) {
|
||||||
console.error("Failed to load discrepancies:", error);
|
console.error("Failed to load discrepancies:", error);
|
||||||
@@ -83,139 +56,47 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function acknowledgeLoss(id: number) {
|
async function undoDismiss(item: FileItem) {
|
||||||
acknowledging = id;
|
if (!item.discrepancy_id) return;
|
||||||
try {
|
try {
|
||||||
await dismissDiscrepancySystemDiscrepanciesFileIdDismissPost({
|
await dismissDiscrepancySystemDiscrepanciesFileIdDismissPost({
|
||||||
path: { file_id: id }
|
path: { file_id: item.discrepancy_id }
|
||||||
});
|
});
|
||||||
toast.success("Loss acknowledged");
|
toast.success("Dismissal undone");
|
||||||
await loadDiscrepancies();
|
await loadDiscrepancies();
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
toast.error(error.body?.detail || "Failed to acknowledge loss");
|
toast.error(error.body?.detail || "Failed to undo dismiss");
|
||||||
} finally {
|
|
||||||
acknowledging = null;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function addToRecoveryQueue(id: number) {
|
async function deletePermanently(item: FileItem) {
|
||||||
recovering = id;
|
if (!item.discrepancy_id) return;
|
||||||
try {
|
try {
|
||||||
await addFileToRecoveryQueueRestoresQueueFileFileIdPost({
|
await batchHardDeleteSystemDiscrepanciesBatchDeletePost({
|
||||||
path: { file_id: id }
|
body: { ids: [item.discrepancy_id] }
|
||||||
});
|
});
|
||||||
toast.success("Added to recovery queue");
|
toast.success("File record deleted permanently");
|
||||||
await loadDiscrepancies();
|
await loadDiscrepancies();
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
toast.error(error.body?.detail || "Failed to add to recovery queue");
|
toast.error(error.body?.detail || "Failed to delete file record");
|
||||||
} finally {
|
|
||||||
recovering = null;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function executeBatchAction() {
|
async function navigateTo(path: string) {
|
||||||
if (!batchAction || selectedIds.size === 0) return;
|
currentPath = path;
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
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 missingItems = $derived(discrepancies.filter(d => d.is_deleted));
|
||||||
const pendingItems = $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>
|
</script>
|
||||||
|
|
||||||
<svelte:head>
|
<svelte:head>
|
||||||
<title>Discrepancies - TapeHoard</title>
|
<title>Discrepancies - TapeHoard</title>
|
||||||
</svelte:head>
|
</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
|
<PageHeader
|
||||||
title="Discrepancies"
|
title="Discrepancies"
|
||||||
description="Files missing from disk or confirmed deleted"
|
description="Files missing from disk or confirmed deleted"
|
||||||
@@ -248,223 +129,21 @@
|
|||||||
/>
|
/>
|
||||||
{:else}
|
{:else}
|
||||||
<!-- Summary Statistics -->
|
<!-- 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="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" />
|
<StatCard label="Pending confirmation" value={pendingItems.length} subLabel="Tracked files not yet confirmed" variant="warning" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Batch Selection Toolbar -->
|
<!-- FileBrowser Component in discrepancies mode -->
|
||||||
{#if selectedIds.size > 0}
|
<div class="flex-1 min-h-0 overflow-hidden">
|
||||||
<Card class="p-4 bg-blue-500/10 border-blue-500/30 flex items-center gap-4">
|
<FileBrowser
|
||||||
<span class="text-sm font-medium text-blue-400">
|
bind:currentPath={currentPath}
|
||||||
{selectedIds.size} file(s) selected
|
files={files}
|
||||||
{#if selectedWithBackups.length > 0 && selectedWithoutBackups.length > 0}
|
mode="discrepancies"
|
||||||
<span class="opacity-60">
|
onNavigate={navigateTo}
|
||||||
({selectedWithBackups.length} backed up, {selectedWithoutBackups.length} no backup)
|
onUndoDismiss={undoDismiss}
|
||||||
</span>
|
onDelete={deletePermanently}
|
||||||
{/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}
|
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user