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).
* **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.
* **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
* **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 ---
class BackupJobSchema(BaseModel):
class JobSchema(BaseModel):
model_config = ConfigDict(from_attributes=True)
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)):
"""Retrieves a history of archival jobs, sorted by most recent."""
# 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:
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(
f"""
"""
SELECT
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,
@@ -806,12 +799,15 @@ def search_archive_index(
FROM filesystem_fts fts
JOIN filesystem_state fs ON fs.id = fts.rowid
WHERE filesystem_fts MATCH :query
{path_filter}
AND fs.file_path LIKE :path_prefix
ORDER BY rank
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()
return [
{
+26
View File
@@ -1,3 +1,4 @@
import os
from typing import List, Optional
from fastapi import APIRouter, HTTPException, Depends, BackgroundTasks
from pydantic import BaseModel, ConfigDict
@@ -218,6 +219,31 @@ def trigger_recovery_job(
"""Initiates the background physical recovery process to the specified destination."""
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
queue_count = db_session.query(models.RestoreCart).count()
if queue_count == 0:
-27
View File
@@ -61,19 +61,6 @@ class StorageMedia(Base):
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):
__tablename__ = "file_versions"
@@ -150,17 +137,3 @@ class SystemSetting(Base):
default=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)
# 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(
CORSMiddleware,
allow_origins=["*"],
allow_origins=[o.strip() for o in cors_origins if o.strip()],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
+1 -1
View File
@@ -35,7 +35,7 @@ class MockLTOProvider(AbstractStorageProvider):
"device_path": {
"type": "string",
"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,
}
}
-5
View File
@@ -18,11 +18,6 @@ class LTOProvider(AbstractStorageProvider):
"supports_hardware_encryption": True,
}
config_schema = {
"device_path": {
"type": "string",
"title": "Device Path",
"description": "e.g., /dev/nst0",
},
"compression": {
"type": "boolean",
"title": "Hardware Compression",
+18
View File
@@ -18,6 +18,7 @@
"@sveltejs/vite-plugin-svelte": "^7.0.0",
"@tailwindcss/postcss": "^4.2.4",
"@tailwindcss/vite": "^4.2.4",
"@types/node": "^25.6.0",
"autoprefixer": "^10.5.0",
"bits-ui": "^1.0.0-next.98",
"clsx": "^2.1.1",
@@ -1042,6 +1043,16 @@
"dev": true,
"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": {
"version": "2.0.7",
"resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz",
@@ -2670,6 +2681,13 @@
"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": {
"version": "1.2.3",
"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",
"@tailwindcss/postcss": "^4.2.4",
"@tailwindcss/vite": "^4.2.4",
"@types/node": "^25.6.0",
"autoprefixer": "^10.5.0",
"bits-ui": "^1.0.0-next.98",
"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. */
use: {
/* 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 */
trace: 'on-first-retry',
@@ -43,8 +43,8 @@ export default defineConfig({
timeout: 120 * 1000,
},
{
command: 'VITE_API_URL=http://localhost:8001 npm run dev',
url: 'http://localhost:5173',
command: 'VITE_API_URL=http://localhost:8001 npm run dev -- --port 5174',
url: 'http://localhost:5174',
reuseExistingServer: false,
timeout: 120 * 1000,
},
+5 -1
View File
@@ -14,7 +14,8 @@
Activity,
ChevronLeft,
ChevronRight,
PieChart
PieChart,
AlertTriangle
} from 'lucide-svelte';
import { cn } from '$lib/utils';
import { Toaster } from 'svelte-sonner';
@@ -32,6 +33,7 @@
{ name: 'Insights', href: '/insights', icon: PieChart },
{ name: 'Archive Index', href: '/index-browser', icon: Library },
{ name: 'Filesystem', href: '/filesystem', icon: FolderTree },
{ name: 'Discrepancies', href: '/discrepancies', icon: AlertTriangle },
{ name: 'Jobs', href: '/jobs', icon: Activity },
{ name: 'Media Inventory', href: '/inventory', icon: CassetteTape },
{ name: 'Data Recovery', href: '/restores', icon: History }
@@ -61,6 +63,7 @@
else if (key === 'g') window.location.href = '/insights';
else if (key === 'i') window.location.href = '/index-browser';
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 === 'm') window.location.href = '/inventory';
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">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">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>
<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 asset.hardware_info.tape}
<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>
<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>
{#if asset.hardware_info.tape.barcode}
<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>
{/if}
</div>
@@ -560,6 +565,11 @@
}
} else if (asset.type === 'tape') {
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;
}}>Add media</Button>
@@ -856,7 +866,7 @@
</header>
<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")}
onclick={() => {
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();
});
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('pageerror', err => console.log('BROWSER ERROR:', err.message));
@@ -82,22 +82,22 @@ test.describe('TapeHoard Golden Path', () => {
}).toPass({ timeout: 20000 });
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.waitForLoadState('networkidle');
console.log('Clicking Register media button');
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();
await expect(page.getByText('TAPE001')).toBeVisible({ timeout: 10000 });
console.log('Step 4: Initialization');
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');
}
/**
* 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.
*/