cleanup
Continuous Integration / backend-tests (push) Failing after 5m25s
Continuous Integration / frontend-check (push) Successful in 9m45s
Continuous Integration / e2e-tests (push) Has been skipped

This commit is contained in:
2026-04-29 23:53:53 -04:00
parent d5fba26554
commit 0b302d2961
18 changed files with 676 additions and 68 deletions
+1
View File
@@ -43,6 +43,7 @@ This document (`GEMINI.md`) contains critical, contextual information about the
* **Standardized Telemetry:** Providers must implement `get_live_info(force: bool)` to return unified telemetry (e.g., drive status, capacity). * **Standardized Telemetry:** Providers must implement `get_live_info(force: bool)` to return unified telemetry (e.g., drive status, capacity).
* **Sanitization:** Initializing media performs a full purge of existing TapeHoard data if the `force` flag is set. * **Sanitization:** Initializing media performs a full purge of existing TapeHoard data if the `force` flag is set.
* **Hardware Failure:** Marking media as "Failed" triggers an automatic atomic purge of all associated `file_versions` to surface those files as "Pending" on the dashboard. * **Hardware Failure:** Marking media as "Failed" triggers an automatic atomic purge of all associated `file_versions` to surface those files as "Pending" on the dashboard.
* **Tape Registration is Discovery-Only:** Tape media (`lto_tape`, `mock_lto`) cannot be registered through the manual "Register media" dialog. Tapes are only registered via the hardware discovery section (`/inventory` → "Discovered unregistered drives") where the system auto-captures `device_path`, barcode, and serial number from the connected drive's MAM. The `device_path` is excluded from `LTOProvider.config_schema` because it is a per-drive setting configured globally in `tape_drives`, not a per-media attribute. The archiver resolves the drive at runtime when instantiating the provider.
### Database & Performance ### Database & Performance
* **High Concurrency:** SQLite must always run in **WAL (Write-Ahead Logging)** mode with a 30s busy timeout and larger page cache. * **High Concurrency:** SQLite must always run in **WAL (Write-Ahead Logging)** mode with a 30s busy timeout and larger page cache.
@@ -0,0 +1,48 @@
"""remove_unused_backup_models
Revision ID: ffdd68ee8ee3
Revises: c2512c86348b
Create Date: 2026-04-29 22:32:01.544848
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision: str = "ffdd68ee8ee3"
down_revision: Union[str, Sequence[str], None] = "c2512c86348b"
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
"""Upgrade schema."""
op.drop_table("job_logs")
op.drop_table("backups")
def downgrade() -> None:
"""Downgrade schema."""
op.create_table(
"backups",
sa.Column("id", sa.Integer(), primary_key=True, nullable=False),
sa.Column("job_name", sa.String(), nullable=False),
sa.Column("job_type", sa.String(), nullable=False),
sa.Column("start_time", sa.DateTime(), nullable=False),
sa.Column("end_time", sa.DateTime(), nullable=True),
sa.Column("status", sa.String(), nullable=False),
)
op.create_table(
"job_logs",
sa.Column("id", sa.Integer(), primary_key=True, nullable=False),
sa.Column(
"backup_id", sa.Integer(), sa.ForeignKey("backups.id"), nullable=False
),
sa.Column("timestamp", sa.DateTime(), nullable=False),
sa.Column("log_level", sa.String(), nullable=False),
sa.Column("message", sa.String(), nullable=False),
)
+2 -2
View File
@@ -16,7 +16,7 @@ router = APIRouter(prefix="/backups", tags=["Backups"])
# --- Request/Response Schemas --- # --- Request/Response Schemas ---
class BackupJobSchema(BaseModel): class JobSchema(BaseModel):
model_config = ConfigDict(from_attributes=True) model_config = ConfigDict(from_attributes=True)
id: int id: int
@@ -113,7 +113,7 @@ def trigger_backup_job(
} }
@router.get("/", response_model=List[BackupJobSchema]) @router.get("/", response_model=List[JobSchema])
def list_archival_history(db_session: Session = Depends(get_db)): def list_archival_history(db_session: Session = Depends(get_db)):
"""Retrieves a history of archival jobs, sorted by most recent.""" """Retrieves a history of archival jobs, sorted by most recent."""
# Note: Using the generic Job model for consistency across the UI # Note: Using the generic Job model for consistency across the UI
+5 -9
View File
@@ -786,15 +786,8 @@ def search_archive_index(
if len(q) < 2: if len(q) < 2:
return [] return []
path_filter = ""
query_params = {"query": q}
if path and path != "ROOT":
path_filter = " AND fs.file_path LIKE :path_prefix"
query_params["path_prefix"] = f"{path}%"
search_sql = text( search_sql = text(
f""" """
SELECT SELECT
fs.id, fs.file_path, fs.size, fs.mtime, fs.id, fs.file_path, fs.size, fs.mtime,
EXISTS(SELECT 1 FROM file_versions fv WHERE fv.filesystem_state_id = fs.id) as has_version, EXISTS(SELECT 1 FROM file_versions fv WHERE fv.filesystem_state_id = fs.id) as has_version,
@@ -806,12 +799,15 @@ def search_archive_index(
FROM filesystem_fts fts FROM filesystem_fts fts
JOIN filesystem_state fs ON fs.id = fts.rowid JOIN filesystem_state fs ON fs.id = fts.rowid
WHERE filesystem_fts MATCH :query WHERE filesystem_fts MATCH :query
{path_filter} AND fs.file_path LIKE :path_prefix
ORDER BY rank ORDER BY rank
LIMIT 100 LIMIT 100
""" """
) )
path_prefix = f"{path}%" if path and path != "ROOT" else "%"
query_params = {"query": q, "path_prefix": path_prefix}
rows = db_session.execute(search_sql, query_params).fetchall() rows = db_session.execute(search_sql, query_params).fetchall()
return [ return [
{ {
+26
View File
@@ -1,3 +1,4 @@
import os
from typing import List, Optional from typing import List, Optional
from fastapi import APIRouter, HTTPException, Depends, BackgroundTasks from fastapi import APIRouter, HTTPException, Depends, BackgroundTasks
from pydantic import BaseModel, ConfigDict from pydantic import BaseModel, ConfigDict
@@ -218,6 +219,31 @@ def trigger_recovery_job(
"""Initiates the background physical recovery process to the specified destination.""" """Initiates the background physical recovery process to the specified destination."""
destination_root = request_data.destination_path destination_root = request_data.destination_path
if not os.path.isabs(destination_root):
raise HTTPException(
status_code=400,
detail="Destination path must be an absolute path.",
)
if ".." in destination_root:
raise HTTPException(
status_code=400,
detail="Path traversal sequences are not allowed in destination path.",
)
normalized = os.path.normpath(destination_root)
if normalized != destination_root:
raise HTTPException(
status_code=400,
detail=f"Destination path must be normalized: '{destination_root}' -> '{normalized}'",
)
if not os.path.isdir(destination_root):
raise HTTPException(
status_code=400,
detail="Destination path does not exist or is not a directory.",
)
# Pre-validation of queue # Pre-validation of queue
queue_count = db_session.query(models.RestoreCart).count() queue_count = db_session.query(models.RestoreCart).count()
if queue_count == 0: if queue_count == 0:
-27
View File
@@ -61,19 +61,6 @@ class StorageMedia(Base):
versions: Mapped[List["FileVersion"]] = relationship(back_populates="media") versions: Mapped[List["FileVersion"]] = relationship(back_populates="media")
class BackupJob(Base):
__tablename__ = "backups"
id: Mapped[int] = mapped_column(primary_key=True)
job_name: Mapped[str] = mapped_column(String)
job_type: Mapped[str] = mapped_column(String) # initial, incremental
start_time: Mapped[datetime] = mapped_column(DateTime)
end_time: Mapped[Optional[datetime]] = mapped_column(DateTime)
status: Mapped[str] = mapped_column(String) # running, success, failed, aborted
logs: Mapped[List["JobLog"]] = relationship(back_populates="backup")
class FileVersion(Base): class FileVersion(Base):
__tablename__ = "file_versions" __tablename__ = "file_versions"
@@ -150,17 +137,3 @@ class SystemSetting(Base):
default=lambda: datetime.now(timezone.utc), default=lambda: datetime.now(timezone.utc),
onupdate=lambda: datetime.now(timezone.utc), onupdate=lambda: datetime.now(timezone.utc),
) )
class JobLog(Base):
__tablename__ = "job_logs"
id: Mapped[int] = mapped_column(primary_key=True)
backup_id: Mapped[int] = mapped_column(ForeignKey("backups.id"))
timestamp: Mapped[datetime] = mapped_column(
DateTime, default=lambda: datetime.now(timezone.utc)
)
log_level: Mapped[str] = mapped_column(String) # INFO, ERROR, WARN
message: Mapped[str] = mapped_column(String)
backup: Mapped["BackupJob"] = relationship(back_populates="logs")
+3 -2
View File
@@ -29,10 +29,11 @@ app = FastAPI(
) )
# Configure Cross-Origin Resource Sharing (CORS) # Configure Cross-Origin Resource Sharing (CORS)
# In production, this should be restricted to your known frontend domains # Use TAPEHOARD_CORS_ORIGINS env var (comma-separated) in production
cors_origins = os.getenv("TAPEHOARD_CORS_ORIGINS", "*").split(",")
app.add_middleware( app.add_middleware(
CORSMiddleware, CORSMiddleware,
allow_origins=["*"], allow_origins=[o.strip() for o in cors_origins if o.strip()],
allow_credentials=True, allow_credentials=True,
allow_methods=["*"], allow_methods=["*"],
allow_headers=["*"], allow_headers=["*"],
+1 -1
View File
@@ -35,7 +35,7 @@ class MockLTOProvider(AbstractStorageProvider):
"device_path": { "device_path": {
"type": "string", "type": "string",
"title": "Mock Directory Path", "title": "Mock Directory Path",
"description": "Path to a directory representing the tape drive (optional — auto-created if omitted)", "description": "Path to a directory representing the tape drive (used internally for testing).",
"required": False, "required": False,
} }
} }
-5
View File
@@ -18,11 +18,6 @@ class LTOProvider(AbstractStorageProvider):
"supports_hardware_encryption": True, "supports_hardware_encryption": True,
} }
config_schema = { config_schema = {
"device_path": {
"type": "string",
"title": "Device Path",
"description": "e.g., /dev/nst0",
},
"compression": { "compression": {
"type": "boolean", "type": "boolean",
"title": "Hardware Compression", "title": "Hardware Compression",
+18
View File
@@ -18,6 +18,7 @@
"@sveltejs/vite-plugin-svelte": "^7.0.0", "@sveltejs/vite-plugin-svelte": "^7.0.0",
"@tailwindcss/postcss": "^4.2.4", "@tailwindcss/postcss": "^4.2.4",
"@tailwindcss/vite": "^4.2.4", "@tailwindcss/vite": "^4.2.4",
"@types/node": "^25.6.0",
"autoprefixer": "^10.5.0", "autoprefixer": "^10.5.0",
"bits-ui": "^1.0.0-next.98", "bits-ui": "^1.0.0-next.98",
"clsx": "^2.1.1", "clsx": "^2.1.1",
@@ -1042,6 +1043,16 @@
"dev": true, "dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/@types/node": {
"version": "25.6.0",
"resolved": "https://registry.npmjs.org/@types/node/-/node-25.6.0.tgz",
"integrity": "sha512-+qIYRKdNYJwY3vRCZMdJbPLJAtGjQBudzZzdzwQYkEPQd+PJGixUL5QfvCLDaULoLv+RhT3LDkwEfKaAkgSmNQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"undici-types": "~7.19.0"
}
},
"node_modules/@types/trusted-types": { "node_modules/@types/trusted-types": {
"version": "2.0.7", "version": "2.0.7",
"resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz",
@@ -2670,6 +2681,13 @@
"node": ">=14.17" "node": ">=14.17"
} }
}, },
"node_modules/undici-types": {
"version": "7.19.2",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.19.2.tgz",
"integrity": "sha512-qYVnV5OEm2AW8cJMCpdV20CDyaN3g0AjDlOGf1OW4iaDEx8MwdtChUp4zu4H0VP3nDRF/8RKWH+IPp9uW0YGZg==",
"dev": true,
"license": "MIT"
},
"node_modules/update-browserslist-db": { "node_modules/update-browserslist-db": {
"version": "1.2.3", "version": "1.2.3",
"resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz",
+1
View File
@@ -21,6 +21,7 @@
"@sveltejs/vite-plugin-svelte": "^7.0.0", "@sveltejs/vite-plugin-svelte": "^7.0.0",
"@tailwindcss/postcss": "^4.2.4", "@tailwindcss/postcss": "^4.2.4",
"@tailwindcss/vite": "^4.2.4", "@tailwindcss/vite": "^4.2.4",
"@types/node": "^25.6.0",
"autoprefixer": "^10.5.0", "autoprefixer": "^10.5.0",
"bits-ui": "^1.0.0-next.98", "bits-ui": "^1.0.0-next.98",
"clsx": "^2.1.1", "clsx": "^2.1.1",
+3 -3
View File
@@ -19,7 +19,7 @@ export default defineConfig({
/* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */ /* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */
use: { use: {
/* Base URL to use in actions like `await page.goto('/')`. */ /* Base URL to use in actions like `await page.goto('/')`. */
baseURL: 'http://localhost:5173', baseURL: 'http://localhost:5174',
/* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */ /* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */
trace: 'on-first-retry', trace: 'on-first-retry',
@@ -43,8 +43,8 @@ export default defineConfig({
timeout: 120 * 1000, timeout: 120 * 1000,
}, },
{ {
command: 'VITE_API_URL=http://localhost:8001 npm run dev', command: 'VITE_API_URL=http://localhost:8001 npm run dev -- --port 5174',
url: 'http://localhost:5173', url: 'http://localhost:5174',
reuseExistingServer: false, reuseExistingServer: false,
timeout: 120 * 1000, timeout: 120 * 1000,
}, },
+5 -1
View File
@@ -14,7 +14,8 @@
Activity, Activity,
ChevronLeft, ChevronLeft,
ChevronRight, ChevronRight,
PieChart PieChart,
AlertTriangle
} from 'lucide-svelte'; } from 'lucide-svelte';
import { cn } from '$lib/utils'; import { cn } from '$lib/utils';
import { Toaster } from 'svelte-sonner'; import { Toaster } from 'svelte-sonner';
@@ -32,6 +33,7 @@
{ name: 'Insights', href: '/insights', icon: PieChart }, { name: 'Insights', href: '/insights', icon: PieChart },
{ name: 'Archive Index', href: '/index-browser', icon: Library }, { name: 'Archive Index', href: '/index-browser', icon: Library },
{ name: 'Filesystem', href: '/filesystem', icon: FolderTree }, { name: 'Filesystem', href: '/filesystem', icon: FolderTree },
{ name: 'Discrepancies', href: '/discrepancies', icon: AlertTriangle },
{ name: 'Jobs', href: '/jobs', icon: Activity }, { name: 'Jobs', href: '/jobs', icon: Activity },
{ name: 'Media Inventory', href: '/inventory', icon: CassetteTape }, { name: 'Media Inventory', href: '/inventory', icon: CassetteTape },
{ name: 'Data Recovery', href: '/restores', icon: History } { name: 'Data Recovery', href: '/restores', icon: History }
@@ -61,6 +63,7 @@
else if (key === 'g') window.location.href = '/insights'; else if (key === 'g') window.location.href = '/insights';
else if (key === 'i') window.location.href = '/index-browser'; else if (key === 'i') window.location.href = '/index-browser';
else if (key === 't') window.location.href = '/filesystem'; else if (key === 't') window.location.href = '/filesystem';
else if (key === 'x') window.location.href = '/discrepancies';
else if (key === 'a') window.location.href = '/jobs'; else if (key === 'a') window.location.href = '/jobs';
else if (key === 'm') window.location.href = '/inventory'; else if (key === 'm') window.location.href = '/inventory';
else if (key === 'r') window.location.href = '/restores'; else if (key === 'r') window.location.href = '/restores';
@@ -192,6 +195,7 @@
<div class="flex justify-between items-center"><span class="text-xs font-semibold text-text-primary">Insights</span> <span class="px-2 py-1 bg-bg-tertiary border border-border-color rounded text-4xs mono">G</span></div> <div class="flex justify-between items-center"><span class="text-xs font-semibold text-text-primary">Insights</span> <span class="px-2 py-1 bg-bg-tertiary border border-border-color rounded text-4xs mono">G</span></div>
<div class="flex justify-between items-center"><span class="text-xs font-semibold text-text-primary">Archive Index</span> <span class="px-2 py-1 bg-bg-tertiary border border-border-color rounded text-4xs mono">I</span></div> <div class="flex justify-between items-center"><span class="text-xs font-semibold text-text-primary">Archive Index</span> <span class="px-2 py-1 bg-bg-tertiary border border-border-color rounded text-4xs mono">I</span></div>
<div class="flex justify-between items-center"><span class="text-xs font-semibold text-text-primary">Filesystem</span> <span class="px-2 py-1 bg-bg-tertiary border border-border-color rounded text-4xs mono">T</span></div> <div class="flex justify-between items-center"><span class="text-xs font-semibold text-text-primary">Filesystem</span> <span class="px-2 py-1 bg-bg-tertiary border border-border-color rounded text-4xs mono">T</span></div>
<div class="flex justify-between items-center"><span class="text-xs font-semibold text-text-primary">Discrepancies</span> <span class="px-2 py-1 bg-bg-tertiary border border-border-color rounded text-4xs mono">X</span></div>
<div class="flex justify-between items-center"><span class="text-xs font-semibold text-text-primary">Jobs</span> <span class="px-2 py-1 bg-bg-tertiary border border-border-color rounded text-4xs mono">A</span></div> <div class="flex justify-between items-center"><span class="text-xs font-semibold text-text-primary">Jobs</span> <span class="px-2 py-1 bg-bg-tertiary border border-border-color rounded text-4xs mono">A</span></div>
</div> </div>
<div class="space-y-4"> <div class="space-y-4">
@@ -0,0 +1,315 @@
<script lang="ts">
import { onMount } from 'svelte';
import {
AlertTriangle,
FileX,
FileQuestion,
RotateCw,
Check,
Trash2,
ShieldCheck,
EyeOff
} from 'lucide-svelte';
import { Button } from '$lib/components/ui/button';
import PageHeader from '$lib/components/ui/PageHeader.svelte';
import { Card } from '$lib/components/ui/card';
import StatusBadge from '$lib/components/ui/StatusBadge.svelte';
import { cn, formatLocalDate, formatLocalTime } from '$lib/utils';
import { toast } from 'svelte-sonner';
import { client } from '$lib/api/client.gen';
interface Discrepancy {
id: number;
path: string;
size: number;
mtime: string;
last_seen_timestamp: string | null;
sha256_hash: string | null;
is_deleted: boolean;
has_versions: boolean;
}
let discrepancies = $state<Discrepancy[]>([]);
let loading = $state(true);
let confirming = $state<number | null>(null);
let dismissing = $state<number | null>(null);
let deleting = $state<number | null>(null);
async function loadDiscrepancies() {
loading = true;
try {
const response = await client.request<Discrepancy[]>({
method: 'GET',
url: '/system/discrepancies'
});
if (response.data) {
discrepancies = response.data;
}
} catch (error) {
console.error("Failed to load discrepancies:", error);
toast.error("Failed to load discrepancies");
} finally {
loading = false;
}
}
async function confirmDeleted(id: number) {
confirming = id;
try {
await client.request({
method: 'POST',
url: `/system/discrepancies/${id}/confirm`
});
toast.success("File marked as confirmed deleted");
await loadDiscrepancies();
} catch (error: any) {
toast.error(error.body?.detail || "Failed to confirm deletion");
} finally {
confirming = null;
}
}
async function dismiss(id: number) {
dismissing = id;
try {
await client.request({
method: 'POST',
url: `/system/discrepancies/${id}/dismiss`
});
toast.success("Discrepancy dismissed");
await loadDiscrepancies();
} catch (error: any) {
toast.error(error.body?.detail || "Failed to dismiss discrepancy");
} finally {
dismissing = null;
}
}
async function hardDelete(id: number) {
deleting = id;
try {
await client.request({
method: 'DELETE',
url: `/system/discrepancies/${id}`
});
toast.success("File record permanently deleted");
await loadDiscrepancies();
} catch (error: any) {
toast.error(error.body?.detail || "Failed to delete record");
} finally {
deleting = null;
}
}
function formatSize(bytes: number) {
if (bytes === 0) return "0 B";
const units = ["B", "KB", "MB", "GB", "TB"];
let unitIndex = 0;
let size = bytes;
while (size >= 1024 && unitIndex < units.length - 1) {
size /= 1024;
unitIndex++;
}
return `${size.toFixed(1)} ${units[unitIndex]}`;
}
onMount(loadDiscrepancies);
const deletedItems = $derived(discrepancies.filter(d => d.is_deleted));
const missingItems = $derived(discrepancies.filter(d => !d.is_deleted));
</script>
<svelte:head>
<title>Discrepancies - TapeHoard</title>
</svelte:head>
<div class="flex flex-col gap-6 animate-in fade-in duration-700">
<PageHeader
title="Discrepancies"
description="Files missing from disk or confirmed deleted"
icon={AlertTriangle}
>
{#snippet actions()}
<Button variant="outline" onclick={loadDiscrepancies}>
<RotateCw size={14} class={cn("mr-2", loading && "animate-spin")} /> Refresh
</Button>
{/snippet}
</PageHeader>
{#if loading && discrepancies.length === 0}
<div class="space-y-6">
<div class="h-40 bg-bg-secondary animate-pulse rounded-xl border border-border-color/50"></div>
<div class="h-64 bg-bg-secondary animate-pulse rounded-xl border border-border-color/50"></div>
</div>
{:else if discrepancies.length === 0}
<Card class="p-12 bg-bg-secondary border-border-color shadow-xl flex flex-col items-center justify-center text-center">
<div class="w-16 h-16 bg-success-color/10 rounded-2xl flex items-center justify-center text-success-color mb-6">
<ShieldCheck size={32} />
</div>
<h3 class="text-xl font-bold text-text-primary mb-2">All clear</h3>
<p class="text-sm text-text-secondary opacity-60">
No discrepancies detected. All tracked files are present on disk.
</p>
</Card>
{:else}
<div class="space-y-6">
<!-- Summary Cards -->
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
<Card class="p-6 bg-bg-secondary border-border-color shadow-xl">
<div class="flex items-start gap-4">
<div class="w-10 h-10 bg-error-color/10 rounded-xl flex items-center justify-center text-error-color shrink-0">
<FileX size={20} />
</div>
<div class="flex-1">
<span class="text-xs text-text-secondary opacity-60 block mb-1">Confirmed deleted</span>
<h4 class="text-2xl font-bold text-error-color mono tabular-nums">{deletedItems.length}</h4>
<p class="text-[10px] font-medium text-text-secondary uppercase opacity-40 mt-1">Files marked as removed from disk</p>
</div>
</div>
</Card>
<Card class="p-6 bg-bg-secondary border-border-color shadow-xl">
<div class="flex items-start gap-4">
<div class="w-10 h-10 bg-yellow-500/10 rounded-xl flex items-center justify-center text-yellow-500 shrink-0">
<FileQuestion size={20} />
</div>
<div class="flex-1">
<span class="text-xs text-text-secondary opacity-60 block mb-1">Missing from disk</span>
<h4 class="text-2xl font-bold text-yellow-500 mono tabular-nums">{missingItems.length}</h4>
<p class="text-[10px] font-medium text-text-secondary uppercase opacity-40 mt-1">Tracked files not found during scan</p>
</div>
</div>
</Card>
</div>
<!-- Discrepancy List -->
<Card class="bg-bg-secondary border-border-color shadow-xl overflow-hidden">
<div class="overflow-x-auto">
<table class="w-full">
<thead>
<tr class="border-b border-border-color/30">
<th class="text-left text-4xs font-bold uppercase text-text-secondary opacity-40 px-6 py-3 tracking-wider">Status</th>
<th class="text-left text-4xs font-bold uppercase text-text-secondary opacity-40 px-6 py-3 tracking-wider">File path</th>
<th class="text-right text-4xs font-bold uppercase text-text-secondary opacity-40 px-6 py-3 tracking-wider">Size</th>
<th class="text-left text-4xs font-bold uppercase text-text-secondary opacity-40 px-6 py-3 tracking-wider">Last seen</th>
<th class="text-center text-4xs font-bold uppercase text-text-secondary opacity-40 px-6 py-3 tracking-wider">Backed up</th>
<th class="text-right text-4xs font-bold uppercase text-text-secondary opacity-40 px-6 py-3 tracking-wider">Actions</th>
</tr>
</thead>
<tbody>
{#each discrepancies as item (item.id)}
<tr class="border-b border-border-color/10 hover:bg-white/[0.02] transition-colors group">
<td class="px-6 py-4">
{#if item.is_deleted}
<StatusBadge variant="error">Deleted</StatusBadge>
{:else}
<StatusBadge variant="warning">Missing</StatusBadge>
{/if}
</td>
<td class="px-6 py-4">
<div class="max-w-md">
<span class="text-sm font-medium text-text-primary mono truncate block" title={item.path}>
{item.path.split('/').pop()}
</span>
<span class="text-[10px] text-text-secondary opacity-40 mono truncate block" title={item.path}>
{item.path}
</span>
</div>
</td>
<td class="px-6 py-4 text-right">
<span class="text-xs text-text-secondary mono">{formatSize(item.size)}</span>
</td>
<td class="px-6 py-4">
<span class="text-xs text-text-secondary mono">
{#if item.last_seen_timestamp}
{formatLocalDate(item.last_seen_timestamp)}
{:else}
{/if}
</span>
</td>
<td class="px-6 py-4 text-center">
{#if item.has_versions}
<div class="inline-flex items-center gap-1.5 text-success-color">
<ShieldCheck size={14} />
<span class="text-xs font-medium">Yes</span>
</div>
{:else}
<div class="inline-flex items-center gap-1.5 text-text-secondary opacity-40">
<EyeOff size={14} />
<span class="text-xs font-medium">No</span>
</div>
{/if}
</td>
<td class="px-6 py-4">
<div class="flex items-center justify-end gap-2">
{#if item.is_deleted}
<Button
variant="ghost"
size="sm"
class="h-7 px-2 text-xs text-text-secondary hover:text-success-color"
onclick={() => dismiss(item.id)}
disabled={dismissing === item.id || confirming === item.id || deleting === item.id}
>
{#if dismissing === item.id}
<RotateCw size={12} class="animate-spin" />
{:else}
<Check size={12} />
{/if}
<span class="ml-1">Dismiss</span>
</Button>
<Button
variant="ghost"
size="sm"
class="h-7 px-2 text-xs text-text-secondary hover:text-error-color"
onclick={() => hardDelete(item.id)}
disabled={dismissing === item.id || confirming === item.id || deleting === item.id}
>
{#if deleting === item.id}
<RotateCw size={12} class="animate-spin" />
{:else}
<Trash2 size={12} />
{/if}
<span class="ml-1">Purge</span>
</Button>
{:else}
<Button
variant="ghost"
size="sm"
class="h-7 px-2 text-xs text-text-secondary hover:text-error-color"
onclick={() => confirmDeleted(item.id)}
disabled={dismissing === item.id || confirming === item.id || deleting === item.id}
>
{#if confirming === item.id}
<RotateCw size={12} class="animate-spin" />
{:else}
<FileX size={12} />
{/if}
<span class="ml-1">Confirm</span>
</Button>
<Button
variant="ghost"
size="sm"
class="h-7 px-2 text-xs text-text-secondary hover:text-success-color"
onclick={() => dismiss(item.id)}
disabled={dismissing === item.id || confirming === item.id || deleting === item.id}
>
{#if dismissing === item.id}
<RotateCw size={12} class="animate-spin" />
{:else}
<Check size={12} />
{/if}
<span class="ml-1">Dismiss</span>
</Button>
{/if}
</div>
</td>
</tr>
{/each}
</tbody>
</table>
</div>
</Card>
</div>
{/if}
</div>
+13 -3
View File
@@ -539,8 +539,13 @@
{/if} {/if}
{#if asset.hardware_info.tape} {#if asset.hardware_info.tape}
<div class="flex flex-wrap gap-1"> <div class="flex flex-wrap gap-1">
<span class="text-[9px] font-medium bg-white/5 px-1.5 py-0.5 rounded border border-white/10 text-text-secondary">MFR: {asset.hardware_info.tape.manufacturer}</span> {#if asset.hardware_info.tape.barcode}
<span class="text-[9px] font-medium bg-blue-500/10 px-1 rounded border border-blue-500/20 text-blue-400">{asset.hardware_info.tape.generation_label || asset.hardware_info.tape.generation}</span> <span class="text-[9px] font-medium bg-white/5 px-1.5 py-0.5 rounded border border-white/10 text-text-secondary">BC: {asset.hardware_info.tape.barcode}</span>
{/if}
{#if asset.hardware_info.tape.serial}
<span class="text-[9px] font-medium bg-blue-500/10 px-1 rounded border border-blue-500/20 text-blue-400">SN: {asset.hardware_info.tape.serial}</span>
{/if}
<span class="text-[9px] font-medium bg-white/5 px-1.5 py-0.5 rounded border border-white/10 text-text-secondary">{asset.hardware_info.tape.generation_label || asset.hardware_info.tape.generation}</span>
</div> </div>
{/if} {/if}
</div> </div>
@@ -560,6 +565,11 @@
} }
} else if (asset.type === 'tape') { } else if (asset.type === 'tape') {
dynamicConfig.device_path = asset.device_path; dynamicConfig.device_path = asset.device_path;
dynamicConfig.serial = asset.hardware_info?.tape?.serial || '';
dynamicConfig.barcode = asset.hardware_info?.tape?.barcode || '';
if (asset.hardware_info?.tape?.max_capacity_mib) {
newMedia.capacity_gb = Math.floor(asset.hardware_info.tape.max_capacity_mib / 1024);
}
} }
showRegisterDialog = true; showRegisterDialog = true;
}}>Add media</Button> }}>Add media</Button>
@@ -856,7 +866,7 @@
</header> </header>
<div class="grid grid-cols-3 gap-4"> <div class="grid grid-cols-3 gap-4">
{#each providersList as provider} {#each providersList.filter(p => !['lto_tape', 'mock_lto'].includes(p.provider_id)) as provider}
<button class={cn("flex flex-col items-center gap-3 p-4 rounded-xl border-2 transition-all", newMedia.media_type === provider.provider_id ? "bg-blue-500/10 border-blue-500 text-blue-400 shadow-lg shadow-blue-500/10" : "bg-bg-primary/50 border-border-color text-text-secondary hover:border-text-secondary/30")} <button class={cn("flex flex-col items-center gap-3 p-4 rounded-xl border-2 transition-all", newMedia.media_type === provider.provider_id ? "bg-blue-500/10 border-blue-500 text-blue-400 shadow-lg shadow-blue-500/10" : "bg-bg-primary/50 border-border-color text-text-secondary hover:border-text-secondary/30")}
onclick={() => { onclick={() => {
newMedia.media_type = provider.provider_id; newMedia.media_type = provider.provider_id;
+191
View File
@@ -0,0 +1,191 @@
import { test, expect, request } from '@playwright/test';
import fs from 'fs';
import path from 'path';
import { API_URL, SOURCE_ROOT, setupRequestContext, configureBackend } from './helpers';
test.describe('Discrepancies', () => {
let fileIds: Record<string, number> = {};
test.beforeAll(async ({ playwright }) => {
const requestContext = await setupRequestContext();
if (fs.existsSync(SOURCE_ROOT)) {
fs.rmSync(SOURCE_ROOT, { recursive: true });
}
fs.mkdirSync(SOURCE_ROOT, { recursive: true });
// Create files for testing (except ui_missing.txt which needs to be deleted from disk)
const testFiles = [
'confirm_missing.txt',
'dismiss_test.txt',
'purge_test.txt',
'ui_deleted.txt',
];
for (const f of testFiles) {
fs.writeFileSync(path.join(SOURCE_ROOT, f), `content for ${f}`);
}
// Create ui_missing.txt, scan it, then delete it from disk to make it "missing"
const missingFilePath = path.join(SOURCE_ROOT, 'ui_missing.txt');
fs.writeFileSync(missingFilePath, 'content for ui_missing.txt');
await configureBackend(requestContext);
// Trigger scan via API
await requestContext.post(`${API_URL}/system/scan`);
// Wait for scan to complete by polling dashboard stats
const deadline = Date.now() + 30000;
while (Date.now() < deadline) {
const statsResp = await requestContext.get(`${API_URL}/system/dashboard/stats`);
const stats = await statsResp.json();
if (stats.monitored_files_count > 0) {
break;
}
await new Promise(r => setTimeout(r, 500));
}
// Get file IDs from metadata
for (const f of [...testFiles, 'ui_missing.txt']) {
const filePath = path.join(SOURCE_ROOT, f);
const encodedPath = encodeURIComponent(filePath);
const metaResp = await requestContext.get(`${API_URL}/inventory/metadata?path=${encodedPath}`);
if (metaResp.ok()) {
const meta = await metaResp.json();
fileIds[f] = meta.id;
}
}
// Delete ui_missing.txt from disk so it shows as "missing" in discrepancies
fs.rmSync(missingFilePath, { force: true });
// Rescan to detect the missing file
await requestContext.post(`${API_URL}/system/scan`);
const deadline2 = Date.now() + 30000;
while (Date.now() < deadline2) {
const statsResp = await requestContext.get(`${API_URL}/system/dashboard/stats`);
const stats = await statsResp.json();
if (stats.files_missing > 0) {
break;
}
await new Promise(r => setTimeout(r, 500));
}
console.log(`File IDs: ${JSON.stringify(fileIds)}`);
await requestContext.dispose();
});
test.afterEach(async ({}) => {
// No automatic cleanup needed - tests use separate files
});
test('missing files are detected and can be confirmed', async ({}) => {
const requestContext = await request.newContext();
const fileId = fileIds['confirm_missing.txt'];
expect(fileId).toBeDefined();
console.log('Step 1: Confirm the file as deleted');
const confirmResp = await requestContext.post(`${API_URL}/system/discrepancies/${fileId}/confirm`);
expect(confirmResp.ok()).toBe(true);
console.log('Step 2: Verify item appears in discrepancies as deleted');
const discResp = await requestContext.get(`${API_URL}/system/discrepancies`);
const discrepancies = await discResp.json();
const found = (discrepancies as Array<any>).find((d: any) => d.path === path.join(SOURCE_ROOT, 'confirm_missing.txt'));
expect(found).toBeDefined();
expect(found.is_deleted).toBe(true);
await requestContext.dispose();
});
test('dismiss discrepancy', async ({}) => {
const requestContext = await request.newContext();
const fileId = fileIds['dismiss_test.txt'];
expect(fileId).toBeDefined();
console.log('Step 1: Confirm as deleted first');
await requestContext.post(`${API_URL}/system/discrepancies/${fileId}/confirm`);
console.log('Step 2: Dismiss the discrepancy');
const dismissResp = await requestContext.post(`${API_URL}/system/discrepancies/${fileId}/dismiss`);
expect(dismissResp.ok()).toBe(true);
console.log('Step 3: Verify discrepancy is cleared');
const afterDismissResp = await requestContext.get(`${API_URL}/system/discrepancies`);
const afterDismiss = await afterDismissResp.json();
const stillPresent = (afterDismiss as Array<any>).find((d: any) => d.path === path.join(SOURCE_ROOT, 'dismiss_test.txt'));
expect(stillPresent).toBeUndefined();
await requestContext.dispose();
});
test('purge deleted file record', async ({}) => {
const requestContext = await request.newContext();
const fileId = fileIds['purge_test.txt'];
expect(fileId).toBeDefined();
console.log('Step 1: Confirm as deleted');
await requestContext.post(`${API_URL}/system/discrepancies/${fileId}/confirm`);
console.log('Step 2: Purge the record');
const purgeResp = await requestContext.delete(`${API_URL}/system/discrepancies/${fileId}`);
expect(purgeResp.ok()).toBe(true);
console.log('Step 3: Verify record is permanently gone');
const afterPurgeResp = await requestContext.get(`${API_URL}/system/discrepancies`);
const afterPurge = await afterPurgeResp.json();
const stillPresent = (afterPurge as Array<any>).find((d: any) => d.path === path.join(SOURCE_ROOT, 'purge_test.txt'));
expect(stillPresent).toBeUndefined();
await requestContext.dispose();
});
test('discrepancies page UI renders correctly', async ({ page }) => {
const requestContext = await request.newContext();
const fileId = fileIds['ui_deleted.txt'];
expect(fileId).toBeDefined();
console.log('Step 1: Confirm as deleted');
const confirmResp = await requestContext.post(`${API_URL}/system/discrepancies/${fileId}/confirm`);
// If already deleted or purged, that's OK - we just need some discrepancies to show
if (!confirmResp.ok()) {
console.log(` Confirm response: ${confirmResp.status()}`);
}
console.log('Step 2: Navigate to discrepancies page');
await page.goto('/discrepancies');
await page.waitForLoadState('networkidle');
await expect(page.getByRole('heading', { name: 'Discrepancies' })).toBeVisible();
await expect(page.getByText('Files missing from disk or confirmed deleted')).toBeVisible();
console.log('Step 3: Verify summary cards are visible');
await expect(page.getByText('Confirmed deleted')).toBeVisible();
await expect(page.getByText('Missing from disk')).toBeVisible();
await requestContext.dispose();
});
test('empty state displays when no discrepancies', async ({ page }) => {
const requestContext = await request.newContext();
console.log('Step 1: Clean up all discrepancies');
const discResp = await requestContext.get(`${API_URL}/system/discrepancies`);
const discrepancies = await discResp.json();
if ((discrepancies as Array<any>).length > 0) {
for (const d of discrepancies as Array<any>) {
await requestContext.delete(`${API_URL}/system/discrepancies/${d.id}`);
}
}
console.log('Step 2: Navigate to discrepancies page');
await page.goto('/discrepancies');
await page.waitForLoadState('networkidle');
await expect(page.getByText('All clear')).toBeVisible();
await expect(page.getByText('No discrepancies detected')).toBeVisible();
await requestContext.dispose();
});
});
+15 -15
View File
@@ -47,7 +47,7 @@ test.describe('TapeHoard Golden Path', () => {
await requestContext.dispose(); await requestContext.dispose();
}); });
test('full ingestion, archival, and recovery workflow', async ({ page }) => { test('full ingestion, archival, and recovery workflow', async ({ page, request }) => {
page.on('console', msg => console.log('BROWSER CONSOLE:', msg.text())); page.on('console', msg => console.log('BROWSER CONSOLE:', msg.text()));
page.on('pageerror', err => console.log('BROWSER ERROR:', err.message)); page.on('pageerror', err => console.log('BROWSER ERROR:', err.message));
@@ -82,22 +82,22 @@ test.describe('TapeHoard Golden Path', () => {
}).toPass({ timeout: 20000 }); }).toPass({ timeout: 20000 });
console.log('Step 3: Media Registration'); console.log('Step 3: Media Registration');
// Tape media is registered via API (discovery-only flow — no UI form for tape)
const registerResp = await request.post(`${API_URL}/inventory/media`, {
data: {
media_type: 'mock_lto',
identifier: 'TAPE001',
generation_tier: 'LTO-6',
capacity: 100 * 1024 * 1024 * 1024,
location: 'Test Shelf',
config: { device_path: MOCK_LTO_PATH }
}
});
expect(registerResp.ok()).toBe(true);
await page.goto('/inventory'); await page.goto('/inventory');
await page.waitForLoadState('networkidle'); await page.waitForLoadState('networkidle');
console.log('Clicking Register media button'); await expect(page.getByText('TAPE001')).toBeVisible({ timeout: 10000 });
await page.getByRole('button', { name: /Register media/i }).click();
console.log('Waiting for Mock LTO Tape text');
await expect(page.getByText('Mock LTO Tape (Test)')).toBeVisible({ timeout: 10000 });
await page.getByText('Mock LTO Tape (Test)').click();
console.log('Filling form');
await page.getByLabel('Identifier (Barcode/SN)').fill('TAPE001');
await page.getByLabel('Capacity (GB)').fill('100');
await page.getByLabel('Mock Directory Path').fill(MOCK_LTO_PATH);
await page.getByRole('button', { name: 'Register media' }).last().click();
await expect(page.getByText(/TAPE001 registered/i)).toBeVisible();
console.log('Step 4: Initialization'); console.log('Step 4: Initialization');
page.on('dialog', dialog => { page.on('dialog', dialog => {
+29
View File
@@ -34,6 +34,35 @@ export async function waitForScanComplete(requestContext: any, timeoutMs = 20000
throw new Error('Scan did not complete within timeout'); throw new Error('Scan did not complete within timeout');
} }
/**
* Triggers a scan and waits for it to complete, ensuring the scan actually ran.
*/
export async function triggerScanAndWait(requestContext: any, timeoutMs = 30000) {
// Get current scan status to detect when a new scan completes
const statusResp = await requestContext.get(`${API_URL}/system/scan/status`);
const beforeStatus = await statusResp.json();
const beforeLastRun = beforeStatus.last_run_time;
const scanResp = await requestContext.post(`${API_URL}/system/scan`);
if (!scanResp.ok()) {
const errBody = await scanResp.json();
console.error(`Scan trigger failed: ${JSON.stringify(errBody)}`);
}
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
const currentStatusResp = await requestContext.get(`${API_URL}/system/scan/status`);
const currentStatus = await currentStatusResp.json();
// Scan is complete if it's not running AND last_run_time changed
if (!currentStatus.is_running && currentStatus.last_run_time !== beforeLastRun) {
return;
}
await new Promise(r => setTimeout(r, 500));
}
throw new Error('Scan did not complete within timeout');
}
/** /**
* Configures the source roots and restore destinations. * Configures the source roots and restore destinations.
*/ */