cleanup
This commit is contained in:
@@ -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),
|
||||||
|
)
|
||||||
@@ -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
|
||||||
|
|||||||
@@ -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 [
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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
@@ -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=["*"],
|
||||||
|
|||||||
@@ -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,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
Generated
+18
@@ -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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -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;
|
||||||
|
|||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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 => {
|
||||||
|
|||||||
@@ -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.
|
||||||
*/
|
*/
|
||||||
|
|||||||
Reference in New Issue
Block a user