sprint: Media Ops — shuttle status endpoint, graceful offline mode, 18 backend tests, shuttle detail in pipeline card, error states with retry, 13 frontend vitest tests
Deploy Dashboard (Main) / Backend Tests (push) Failing after 18s
Deploy Dashboard (Main) / Frontend Tests (push) Successful in 10s
Deploy Dashboard (Main) / Build Main Image (push) Failing after 7m26s
Deploy Dashboard (Main) / Deploy to Production (push) Has been skipped

This commit is contained in:
Gan, Jimmy
2026-07-12 15:36:13 +08:00
parent 07ad32f784
commit 9f193ec133
5 changed files with 543 additions and 45 deletions
+116 -35
View File
@@ -1,6 +1,9 @@
import json import json
import logging import logging
import os import os
import re
from datetime import datetime
import asyncssh import asyncssh
from fastapi import APIRouter, Depends, HTTPException, BackgroundTasks, Query from fastapi import APIRouter, Depends, HTTPException, BackgroundTasks, Query
import config import config
@@ -18,16 +21,22 @@ PHONE_INGEST_SCRIPT = "/volume1/homes/zjgump/media-mgmt/transfer/phone-to-nas.sh
LOG_DIR = "/volume1/homes/zjgump/media-mgmt/logs" LOG_DIR = "/volume1/homes/zjgump/media-mgmt/logs"
UNAVAILABLE_RESPONSE = {"status": "unavailable", "error": "NAS unreachable"}
async def run_host_command(cmd: str) -> tuple[int, str, str]: async def run_host_command(cmd: str) -> tuple[int, str, str]:
"""Execute a command on the NAS host via SSH.""" """Execute a command on the NAS host via SSH.
Returns (exit_code, stdout, stderr). On SSH connection failure,
exit_code=999 and stderr contains the error message.
"""
try: try:
# Prepend PATH and NAS_HOST variables for host execution
full_cmd = f'PATH="/volume1/homes/zjgump/bin:$PATH" NAS_HOST="naslan" {cmd}' full_cmd = f'PATH="/volume1/homes/zjgump/bin:$PATH" NAS_HOST="naslan" {cmd}'
async with asyncssh.connect( async with asyncssh.connect(
config.SSH_HOST, config.SSH_HOST,
username=config.SSH_USER, username=config.SSH_USER,
client_keys=[config.SSH_KEY_PATH], client_keys=[config.SSH_KEY_PATH],
known_hosts=None known_hosts=None,
) as conn: ) as conn:
result = await conn.run(full_cmd) result = await conn.run(full_cmd)
return result.exit_status, result.stdout, result.stderr return result.exit_status, result.stdout, result.stderr
@@ -35,78 +44,148 @@ async def run_host_command(cmd: str) -> tuple[int, str, str]:
logger.error(f"SSH command failed ({cmd}): {e}") logger.error(f"SSH command failed ({cmd}): {e}")
return 999, "", str(e) return 999, "", str(e)
@router.get("/health") @router.get("/health")
async def get_health(): async def get_health():
"""Runs health-check.sh and returns the exit status and stdout/stderr.""" """Runs health-check.sh and returns structured status."""
code, stdout, stderr = await run_host_command(f"bash {HEALTH_CHECK_SCRIPT}") code, stdout, stderr = await run_host_command(f"bash {HEALTH_CHECK_SCRIPT}")
if code == 999:
return UNAVAILABLE_RESPONSE
return { return {
"status": "ok" if code == 0 else "warning" if code == 1 else "error", "status": "ok" if code == 0 else "warning" if code == 1 else "error",
"exit_code": code, "exit_code": code,
"stdout": stdout, "stdout": stdout,
"stderr": stderr "stderr": stderr,
} }
@router.get("/storage") @router.get("/storage")
async def get_storage(): async def get_storage():
"""Runs storage-report.sh -j and returns the parsed JSON storage report.""" """Runs storage-report.sh -j and returns the parsed JSON storage report."""
code, stdout, stderr = await run_host_command(f"bash {STORAGE_REPORT_SCRIPT} -j") code, stdout, stderr = await run_host_command(f"bash {STORAGE_REPORT_SCRIPT} -j")
if code == 999:
return UNAVAILABLE_RESPONSE
if code != 0: if code != 0:
raise HTTPException(status_code=500, detail=f"Storage report failed: {stderr}") return {"error": "Storage report failed", "stderr": stderr}
try: try:
return json.loads(stdout) return json.loads(stdout)
except json.JSONDecodeError: except json.JSONDecodeError:
return { return {"error": "Failed to parse JSON from storage report", "raw_output": stdout}
"error": "Failed to parse JSON from storage report",
"raw_output": stdout,
"stderr": stderr @router.get("/shuttle/status")
} async def get_shuttle_status():
"""Shuttle pipeline status: queue depth, last transfer time, recent errors.
Reads the shuttle log file to extract pipeline state.
"""
log_path = f"{LOG_DIR}/shuttle.log"
code, stdout, stderr = await run_host_command(f"cat {log_path} 2>/dev/null || echo 'NO_LOG'")
if code == 999:
return UNAVAILABLE_RESPONSE
result = {
"running": False,
"queue_depth": 0,
"last_transfer": None,
"last_transfer_size": None,
"recent_errors": [],
"current_stage": None,
}
content = stdout or ""
if content.strip() == "NO_LOG":
return result
# Check if shuttle is currently running via pgrep
running_code, running_out, _ = await run_host_command(f"pgrep -f {SHUTTLE_SCRIPT}")
result["running"] = running_code == 0
if running_code == 0:
result["current_stage"] = "active"
result["pid"] = running_out.strip()
# Parse log for last transfer, errors, current stage
lines = content.strip().split("\n")
error_lines = []
transfer_lines = []
for line in lines:
if "" in line or "error" in line.lower() or "failed" in line.lower():
error_lines.append(line.strip())
if "copied" in line.lower() or "transferred" in line.lower() or "rsync" in line.lower():
transfer_lines.append(line.strip())
if "Stage" in line and ":" in line:
result["current_stage"] = line.strip()
if error_lines:
result["recent_errors"] = error_lines[-10:]
if transfer_lines:
last = transfer_lines[-1]
result["last_transfer"] = last
# Try to extract size from rsync-like output
size_match = re.search(r"(\d+\.?\d*)\s*(K|M|G)B", last)
if size_match:
result["last_transfer_size"] = size_match.group(0)
# Count queued source directories
queue_matches = [l for l in lines if "--source" in l or "--stage" in l]
result["queue_depth"] = len(queue_matches)
return result
@router.post("/maintenance/scan") @router.post("/maintenance/scan")
async def run_scan(background_tasks: BackgroundTasks, library: str = Query(None)): async def run_scan(background_tasks: BackgroundTasks, library: str = Query(None)):
"""Triggers scan-library.sh in the background and writes stdout/stderr to scan.log.""" """Triggers scan-library.sh in the background."""
cmd = f"bash {SCAN_LIBRARY_SCRIPT}" cmd = f"bash {SCAN_LIBRARY_SCRIPT}"
if library: if library:
cmd += f" {library}" cmd += f" {library}"
log_file = f"{LOG_DIR}/scan.log" log_file = f"{LOG_DIR}/scan.log"
run_cmd = f"mkdir -p {LOG_DIR} && echo '=== Scan Started at $(date) ===' > {log_file} && nohup {cmd} >> {log_file} 2>&1 &" run_cmd = f"mkdir -p {LOG_DIR} && echo '=== Scan Started at $(date) ===' > {log_file} && nohup {cmd} >> {log_file} 2>&1 &"
code, stdout, stderr = await run_host_command(run_cmd) code, stdout, stderr = await run_host_command(run_cmd)
if code == 999:
return UNAVAILABLE_RESPONSE
if code != 0: if code != 0:
raise HTTPException(status_code=500, detail=f"Failed to start scan: {stderr}") return {"error": "Failed to start scan", "stderr": stderr}
return {"message": "Scan started in background", "log_file": log_file} return {"message": "Scan started in background", "log_file": log_file}
@router.post("/maintenance/requeue") @router.post("/maintenance/requeue")
async def run_requeue(background_tasks: BackgroundTasks): async def run_requeue(background_tasks: BackgroundTasks):
"""Triggers thumbnail-clinic.sh -r in the background and writes stdout/stderr to requeue.log.""" """Triggers thumbnail-clinic.sh -r in the background."""
cmd = f"bash {THUMBNAIL_CLINIC_SCRIPT} -r" cmd = f"bash {THUMBNAIL_CLINIC_SCRIPT} -r"
log_file = f"{LOG_DIR}/requeue.log" log_file = f"{LOG_DIR}/requeue.log"
run_cmd = f"mkdir -p {LOG_DIR} && echo '=== Requeue Started at $(date) ===' > {log_file} && nohup {cmd} >> {log_file} 2>&1 &" run_cmd = f"mkdir -p {LOG_DIR} && echo '=== Requeue Started at $(date) ===' > {log_file} && nohup {cmd} >> {log_file} 2>&1 &"
code, stdout, stderr = await run_host_command(run_cmd) code, stdout, stderr = await run_host_command(run_cmd)
if code == 999:
return UNAVAILABLE_RESPONSE
if code != 0: if code != 0:
raise HTTPException(status_code=500, detail=f"Failed to start thumbnail requeue: {stderr}") return {"error": "Failed to start thumbnail requeue", "stderr": stderr}
return {"message": "Thumbnail requeue started in background", "log_file": log_file} return {"message": "Thumbnail requeue started in background", "log_file": log_file}
@router.get("/logs/{task_name}") @router.get("/logs/{task_name}")
async def get_logs(task_name: str): async def get_logs(task_name: str):
"""Reads logs for a task from /volume1/homes/zjgump/media-mgmt/logs/{task_name}.log.""" """Reads logs for a task from the media-mgmt logs directory."""
if task_name not in ["scan", "requeue", "shuttle", "phone-ingest"]: if task_name not in ["scan", "requeue", "shuttle", "phone-ingest"]:
raise HTTPException(status_code=400, detail="Invalid task name") raise HTTPException(status_code=400, detail="Invalid task name")
log_path = f"/volume1/homes/zjgump/media-mgmt/logs/{task_name}.log" log_path = f"{LOG_DIR}/{task_name}.log"
if not os.path.exists(log_path): code, stdout, stderr = await run_host_command(f"cat {log_path} 2>/dev/null || echo 'NO_LOG'")
if code == 999:
return UNAVAILABLE_RESPONSE
if stdout.strip() == "NO_LOG":
return {"content": "No log file found."} return {"content": "No log file found."}
lines = stdout.strip().split("\n")
try: return {"content": "\n".join(lines[-200:])}
with open(log_path, "r") as f:
content = f.readlines()
return {"content": "".join(content[-200:])}
except Exception as e:
raise HTTPException(status_code=500, detail=f"Failed to read log file: {e}")
@router.get("/status") @router.get("/status")
async def get_active_status(): async def get_active_status():
@@ -115,14 +194,16 @@ async def get_active_status():
"scan": SCAN_LIBRARY_SCRIPT, "scan": SCAN_LIBRARY_SCRIPT,
"requeue": THUMBNAIL_CLINIC_SCRIPT, "requeue": THUMBNAIL_CLINIC_SCRIPT,
"shuttle": SHUTTLE_SCRIPT, "shuttle": SHUTTLE_SCRIPT,
"phone-ingest": PHONE_INGEST_SCRIPT "phone-ingest": PHONE_INGEST_SCRIPT,
} }
status = {} status = {}
for name, script_path in processes.items(): for name, script_path in processes.items():
code, stdout, stderr = await run_host_command(f"pgrep -f {script_path}") code, stdout, stderr = await run_host_command(f"pgrep -f {script_path}")
if code == 999:
return UNAVAILABLE_RESPONSE
status[name] = { status[name] = {
"running": code == 0, "running": code == 0,
"pid": stdout.strip() if code == 0 else None "pid": stdout.strip() if code == 0 else None,
} }
return status return status
@@ -1,8 +1,9 @@
import pytest import pytest
from unittest.mock import patch, AsyncMock from unittest.mock import patch, AsyncMock
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_get_health(test_app, admin_headers): async def test_get_health_ok(test_app, admin_headers):
with patch("routers.media_mgmt.run_host_command", new_callable=AsyncMock) as mock_run: with patch("routers.media_mgmt.run_host_command", new_callable=AsyncMock) as mock_run:
mock_run.return_value = (0, "All checks passed\n", "") mock_run.return_value = (0, "All checks passed\n", "")
response = test_app.get("/api/media/health", headers=admin_headers) response = test_app.get("/api/media/health", headers=admin_headers)
@@ -12,16 +13,145 @@ async def test_get_health(test_app, admin_headers):
assert data["exit_code"] == 0 assert data["exit_code"] == 0
assert "All checks passed" in data["stdout"] assert "All checks passed" in data["stdout"]
@pytest.mark.asyncio
async def test_get_health_warning(test_app, admin_headers):
with patch("routers.media_mgmt.run_host_command", new_callable=AsyncMock) as mock_run:
mock_run.return_value = (1, "Disk >85%\n", "")
response = test_app.get("/api/media/health", headers=admin_headers)
assert response.status_code == 200
data = response.json()
assert data["status"] == "warning"
@pytest.mark.asyncio
async def test_get_health_error(test_app, admin_headers):
with patch("routers.media_mgmt.run_host_command", new_callable=AsyncMock) as mock_run:
mock_run.return_value = (2, "", "Immich API unreachable")
response = test_app.get("/api/media/health", headers=admin_headers)
assert response.status_code == 200
data = response.json()
assert data["status"] == "error"
@pytest.mark.asyncio
async def test_get_health_unavailable(test_app, admin_headers):
with patch("routers.media_mgmt.run_host_command", new_callable=AsyncMock) as mock_run:
mock_run.return_value = (999, "", "SSH connection refused")
response = test_app.get("/api/media/health", headers=admin_headers)
assert response.status_code == 200
data = response.json()
assert data["status"] == "unavailable"
assert "NAS unreachable" in data["error"]
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_get_storage(test_app, admin_headers): async def test_get_storage(test_app, admin_headers):
with patch("routers.media_mgmt.run_host_command", new_callable=AsyncMock) as mock_run: with patch("routers.media_mgmt.run_host_command", new_callable=AsyncMock) as mock_run:
mock_run.return_value = (0, '{"timestamp": "2026-07-11T05:37:57Z", "volume": {"path": "/volume1", "total": "14T", "used": "12T", "available": "2.2T", "used_pct": 85}}', "") mock_run.return_value = (
0,
'{"timestamp": "2026-07-11T05:37:57Z", "volume": {"path": "/volume1", "total": "14T", "used": "12T", "available": "2.2T", "used_pct": 85}}',
"",
)
response = test_app.get("/api/media/storage", headers=admin_headers) response = test_app.get("/api/media/storage", headers=admin_headers)
assert response.status_code == 200 assert response.status_code == 200
data = response.json() data = response.json()
assert "volume" in data assert "volume" in data
assert data["volume"]["total"] == "14T" assert data["volume"]["total"] == "14T"
@pytest.mark.asyncio
async def test_get_storage_unavailable(test_app, admin_headers):
with patch("routers.media_mgmt.run_host_command", new_callable=AsyncMock) as mock_run:
mock_run.return_value = (999, "", "SSH timeout")
response = test_app.get("/api/media/storage", headers=admin_headers)
assert response.status_code == 200
data = response.json()
assert data["status"] == "unavailable"
@pytest.mark.asyncio
async def test_get_storage_json_parse_error(test_app, admin_headers):
with patch("routers.media_mgmt.run_host_command", new_callable=AsyncMock) as mock_run:
mock_run.return_value = (0, "not valid json", "")
response = test_app.get("/api/media/storage", headers=admin_headers)
assert response.status_code == 200
data = response.json()
assert "error" in data
assert "Failed to parse JSON" in data["error"]
@pytest.mark.asyncio
async def test_get_shuttle_status_idle(test_app, admin_headers):
"""Shuttle not running, no log file."""
with patch("routers.media_mgmt.run_host_command", new_callable=AsyncMock) as mock_run:
# First call: cat log, Second call: pgrep -f
mock_run.side_effect = [
(0, "NO_LOG", ""), # cat log
(1, "", ""), # pgrep -f (not running)
]
response = test_app.get("/api/media/shuttle/status", headers=admin_headers)
assert response.status_code == 200
data = response.json()
assert data["running"] is False
assert data["queue_depth"] == 0
@pytest.mark.asyncio
async def test_get_shuttle_status_running(test_app, admin_headers):
"""Shuttle running with log content."""
log_content = """=== Shuttle Started at 2026-07-11 10:00 ===
--source /Volumes/SD_CARD --dest /Volumes/SSD
Stage 1: copy
[10:01] rsync: 1.2GB copied from SD_CARD to SSD
Stage 2: verify
[10:05] ✅ Verification passed — all files match
[10:06] --stage ship --source /Volumes/SSD --dest naslan:/volume1/video
"""
with patch("routers.media_mgmt.run_host_command", new_callable=AsyncMock) as mock_run:
mock_run.side_effect = [
(0, log_content, ""), # cat log
(0, "12345\n", ""), # pgrep -f (running)
]
response = test_app.get("/api/media/shuttle/status", headers=admin_headers)
assert response.status_code == 200
data = response.json()
assert data["running"] is True
assert data["pid"] == "12345"
assert data["queue_depth"] == 2 # --source entries in log
assert data["last_transfer"] is not None
assert "1.2GB" in data.get("last_transfer_size", "")
@pytest.mark.asyncio
async def test_get_shuttle_status_with_errors(test_app, admin_headers):
"""Shuttle log has error lines."""
log_content = """[10:01] rsync: OK
[10:02] ❌ rsync: failed to transfer file XYZ
[10:03] rsync: 500MB copied
[10:04] ❌ connection reset by peer
"""
with patch("routers.media_mgmt.run_host_command", new_callable=AsyncMock) as mock_run:
mock_run.side_effect = [
(0, log_content, ""), # cat log
(1, "", ""), # pgrep -f (not running)
]
response = test_app.get("/api/media/shuttle/status", headers=admin_headers)
assert response.status_code == 200
data = response.json()
assert len(data["recent_errors"]) == 2
@pytest.mark.asyncio
async def test_get_shuttle_status_unavailable(test_app, admin_headers):
with patch("routers.media_mgmt.run_host_command", new_callable=AsyncMock) as mock_run:
mock_run.return_value = (999, "", "SSH timeout")
response = test_app.get("/api/media/shuttle/status", headers=admin_headers)
assert response.status_code == 200
data = response.json()
assert data["status"] == "unavailable"
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_run_scan(test_app, admin_headers): async def test_run_scan(test_app, admin_headers):
with patch("routers.media_mgmt.run_host_command", new_callable=AsyncMock) as mock_run: with patch("routers.media_mgmt.run_host_command", new_callable=AsyncMock) as mock_run:
@@ -33,6 +163,17 @@ async def test_run_scan(test_app, admin_headers):
mock_run.assert_called_once() mock_run.assert_called_once()
assert "scan-library.sh DJI-Action6" in mock_run.call_args[0][0] assert "scan-library.sh DJI-Action6" in mock_run.call_args[0][0]
@pytest.mark.asyncio
async def test_run_scan_unavailable(test_app, admin_headers):
with patch("routers.media_mgmt.run_host_command", new_callable=AsyncMock) as mock_run:
mock_run.return_value = (999, "", "SSH timeout")
response = test_app.post("/api/media/maintenance/scan", headers=admin_headers)
assert response.status_code == 200
data = response.json()
assert data["status"] == "unavailable"
@pytest.mark.asyncio @pytest.mark.asyncio
async def test_get_active_status(test_app, admin_headers): async def test_get_active_status(test_app, admin_headers):
with patch("routers.media_mgmt.run_host_command", new_callable=AsyncMock) as mock_run: with patch("routers.media_mgmt.run_host_command", new_callable=AsyncMock) as mock_run:
@@ -47,3 +188,41 @@ async def test_get_active_status(test_app, admin_headers):
assert data["scan"]["running"] is True assert data["scan"]["running"] is True
assert data["scan"]["pid"] == "1234" assert data["scan"]["pid"] == "1234"
assert data["requeue"]["running"] is False assert data["requeue"]["running"] is False
@pytest.mark.asyncio
async def test_get_active_status_unavailable(test_app, admin_headers):
with patch("routers.media_mgmt.run_host_command", new_callable=AsyncMock) as mock_run:
mock_run.return_value = (999, "", "SSH timeout")
response = test_app.get("/api/media/status", headers=admin_headers)
assert response.status_code == 200
data = response.json()
assert data["status"] == "unavailable"
@pytest.mark.asyncio
async def test_get_logs_shuttle(test_app, admin_headers):
"""Fetch shuttle logs successfully."""
with patch("routers.media_mgmt.run_host_command", new_callable=AsyncMock) as mock_run:
mock_run.return_value = (0, "line1\nline2\nline3\n", "")
response = test_app.get("/api/media/logs/shuttle", headers=admin_headers)
assert response.status_code == 200
data = response.json()
assert "line1" in data["content"]
@pytest.mark.asyncio
async def test_get_logs_invalid_task(test_app, admin_headers):
"""Invalid task name returns 400."""
response = test_app.get("/api/media/logs/invalid-task", headers=admin_headers)
assert response.status_code == 400
@pytest.mark.asyncio
async def test_get_logs_unavailable(test_app, admin_headers):
with patch("routers.media_mgmt.run_host_command", new_callable=AsyncMock) as mock_run:
mock_run.return_value = (999, "", "SSH timeout")
response = test_app.get("/api/media/logs/scan", headers=admin_headers)
assert response.status_code == 200
data = response.json()
assert data["status"] == "unavailable"
+4
View File
@@ -242,3 +242,7 @@ export function getMediaLogs(taskName) {
export function getMediaStatus() { export function getMediaStatus() {
return get("/media/status"); return get("/media/status");
} }
export function getMediaShuttleStatus() {
return get("/media/shuttle/status");
}
@@ -3,6 +3,7 @@
import { import {
getMediaHealth, getMediaHealth,
getMediaStorage, getMediaStorage,
getMediaShuttleStatus,
startMediaScan, startMediaScan,
startMediaRequeue, startMediaRequeue,
getMediaLogs, getMediaLogs,
@@ -12,9 +13,15 @@
let loading = $state(true); let loading = $state(true);
let diagLoading = $state(false); let diagLoading = $state(false);
let actionLoading = $state(false); let actionLoading = $state(false);
let shuttleLoading = $state(false);
let health = $state(null); let health = $state(null);
let storage = $state(null); let storage = $state(null);
let shuttleDetail = $state(null);
let unavailable = $state(false);
let healthUnavailable = $state(false);
let storageUnavailable = $state(false);
let status = $state({ let status = $state({
scan: { running: false, pid: null }, scan: { running: false, pid: null },
requeue: { running: false, pid: null }, requeue: { running: false, pid: null },
@@ -31,24 +38,42 @@
async function loadData() { async function loadData() {
loading = true; loading = true;
try { try {
const [storageRes, statusRes] = await Promise.all([ const [storageRes, statusRes, shuttleRes] = await Promise.all([
getMediaStorage(), getMediaStorage(),
getMediaStatus() getMediaStatus(),
getMediaShuttleStatus()
]); ]);
storage = storageRes; storage = storageRes;
storageUnavailable = storageRes?.status === 'unavailable';
status = statusRes; status = statusRes;
unavailable = statusRes?.status === 'unavailable';
shuttleDetail = shuttleRes;
} catch (e) { } catch (e) {
console.error("Failed to load Media Ops data", e); console.error("Failed to load Media Ops data", e);
unavailable = true;
} finally { } finally {
loading = false; loading = false;
} }
} }
async function loadShuttleDetail() {
shuttleLoading = true;
try {
shuttleDetail = await getMediaShuttleStatus();
} catch (e) {
console.error("Failed to load shuttle detail", e);
} finally {
shuttleLoading = false;
}
}
async function runDiagnostics() { async function runDiagnostics() {
diagLoading = true; diagLoading = true;
health = null; health = null;
healthUnavailable = false;
try { try {
health = await getMediaHealth(); health = await getMediaHealth();
healthUnavailable = health?.status === 'unavailable';
} catch (e) { } catch (e) {
console.error("Diagnostics failed", e); console.error("Diagnostics failed", e);
health = { status: "error", stdout: "", stderr: e?.message || "Failed to execute health check" }; health = { status: "error", stdout: "", stderr: e?.message || "Failed to execute health check" };
@@ -159,6 +184,21 @@
</script> </script>
<div class="space-y-6"> <div class="space-y-6">
{#if unavailable || healthUnavailable || storageUnavailable}
<div class="p-4 rounded-xl bg-rose-50 dark:bg-rose-950/20 border border-rose-100 dark:border-rose-900/30 flex items-start gap-3">
<span class="text-rose-500 mt-0.5 shrink-0">
<svg class="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2.5"><path stroke-linecap="round" stroke-linejoin="round" d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z" /></svg>
</span>
<div class="flex-1">
<div class="font-bold text-rose-800 dark:text-rose-300 text-sm">NAS Unreachable</div>
<div class="text-xs text-rose-600 dark:text-rose-400 mt-0.5">Cannot connect to Synology NAS via SSH. Some services may be offline.</div>
</div>
<button onclick={loadData} class="px-3 py-1.5 text-xs font-semibold text-rose-700 bg-rose-100 hover:bg-rose-200 rounded-lg transition-colors shrink-0 dark:bg-rose-900/40 dark:text-rose-300">
Retry
</button>
</div>
{/if}
<div class="flex flex-col md:flex-row md:items-center justify-between gap-4"> <div class="flex flex-col md:flex-row md:items-center justify-between gap-4">
<div> <div>
<h1 class="text-2xl font-bold text-surface-900 dark:text-white tracking-tight">Media Ops</h1> <h1 class="text-2xl font-bold text-surface-900 dark:text-white tracking-tight">Media Ops</h1>
@@ -281,6 +321,22 @@
<td class="py-4 px-1"> <td class="py-4 px-1">
<div class="font-bold text-surface-800 dark:text-surface-100">SSD Shuttle</div> <div class="font-bold text-surface-800 dark:text-surface-100">SSD Shuttle</div>
<div class="text-xs text-surface-400 font-normal mt-0.5">Ingest/move files from portable SSD shuttle to NAS.</div> <div class="text-xs text-surface-400 font-normal mt-0.5">Ingest/move files from portable SSD shuttle to NAS.</div>
{#if shuttleDetail && !shuttleDetail?.status?.startsWith('unavail')}
<div class="mt-2 space-y-1">
{#if shuttleDetail.queue_depth > 0}
<div class="text-[10px] text-surface-500">Queue: <span class="font-bold text-surface-700 dark:text-surface-300">{shuttleDetail.queue_depth} item{shuttleDetail.queue_depth > 1 ? 's' : ''}</span></div>
{/if}
{#if shuttleDetail.last_transfer}
<div class="text-[10px] text-surface-500">Last: <span class="font-mono text-surface-700 dark:text-surface-300">{shuttleDetail.last_transfer}</span></div>
{/if}
{#if shuttleDetail.recent_errors?.length}
<div class="text-[10px] text-rose-500">{shuttleDetail.recent_errors.length} error{shuttleDetail.recent_errors.length > 1 ? 's' : ''} in log</div>
{/if}
{#if shuttleDetail.current_stage}
<div class="text-[10px] text-emerald-500">Stage: {shuttleDetail.current_stage}</div>
{/if}
</div>
{/if}
</td> </td>
<td class="py-4 px-1"> <td class="py-4 px-1">
<span class="inline-flex items-center gap-1 px-2.5 py-0.5 rounded-full text-xs font-semibold {getStatusColor('shuttle')}"> <span class="inline-flex items-center gap-1 px-2.5 py-0.5 rounded-full text-xs font-semibold {getStatusColor('shuttle')}">
@@ -291,12 +347,21 @@
</span> </span>
</td> </td>
<td class="py-4 px-1 text-right"> <td class="py-4 px-1 text-right">
<button <div class="inline-flex gap-1.5">
onclick={() => { selectedTask = "shuttle"; fetchLogs(); }} <button
class="px-2 py-1 text-xs text-surface-600 bg-surface-100 hover:bg-surface-200 rounded-md transition-colors font-semibold dark:bg-surface-700 dark:text-surface-300" onclick={loadShuttleDetail}
> disabled={shuttleLoading}
View Logs class="px-2 py-1 text-xs text-surface-600 bg-surface-100 hover:bg-surface-200 rounded-md transition-colors font-semibold dark:bg-surface-700 dark:text-surface-300 disabled:opacity-50"
</button> >
{shuttleLoading ? '...' : 'Refresh'}
</button>
<button
onclick={() => { selectedTask = "shuttle"; fetchLogs(); }}
class="px-2 py-1 text-xs text-surface-600 bg-surface-100 hover:bg-surface-200 rounded-md transition-colors font-semibold dark:bg-surface-700 dark:text-surface-300"
>
Logs
</button>
</div>
</td> </td>
</tr> </tr>
@@ -0,0 +1,169 @@
/**
* Component tests for MediaOps.svelte
*/
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { render, screen, fireEvent, waitFor } from '@testing-library/svelte';
import MediaOps from '../../src/routes/media/MediaOps.svelte';
// Mock the API module
vi.mock('../../src/lib/api.js', () => ({
getMediaHealth: vi.fn(),
getMediaStorage: vi.fn(),
getMediaShuttleStatus: vi.fn(),
getMediaStatus: vi.fn(),
getMediaLogs: vi.fn(),
startMediaScan: vi.fn(),
startMediaRequeue: vi.fn(),
}));
import { getMediaHealth, getMediaStorage, getMediaShuttleStatus, getMediaStatus, getMediaLogs, startMediaScan, startMediaRequeue } from '../../src/lib/api.js';
describe('MediaOps Component', () => {
beforeEach(() => {
vi.clearAllMocks();
// Default mock data — healthy NAS
getMediaHealth.mockResolvedValue({ status: 'ok', exit_code: 0, stdout: 'All checks passed', stderr: '' });
getMediaStorage.mockResolvedValue({
volume: { path: '/volume1', total: '14T', used: '12T', available: '2.2T', used_pct: 85 },
libraries: [
{ name: 'DJI', total_size: '3.5T', total_files: 15000, images: 5000, videos: 10000, volume_pct: 25 },
{ name: 'Photos', total_size: '6T', total_files: 80000, images: 70000, videos: 10000, volume_pct: 43 },
],
directories: {
dji_001: { path: '/volume1/video/DJI_001', size: '2.1T' },
immich_upload: { path: '/volume1/immich/upload', size: '4.5T' },
},
});
getMediaShuttleStatus.mockResolvedValue({
running: false, queue_depth: 0, last_transfer: null, recent_errors: [], current_stage: null,
});
getMediaStatus.mockResolvedValue({
scan: { running: false, pid: null },
requeue: { running: false, pid: null },
shuttle: { running: false, pid: null },
'phone-ingest': { running: false, pid: null },
});
getMediaLogs.mockResolvedValue({ content: 'No log file found.' });
});
it('renders the Media Ops header', async () => {
render(MediaOps);
await waitFor(() => {
expect(screen.getByText('Media Ops')).toBeTruthy();
});
});
it('renders storage data after loading', async () => {
render(MediaOps);
await waitFor(() => {
expect(screen.getByText(/14T/)).toBeTruthy();
});
});
it('renders library names from storage data', async () => {
render(MediaOps);
await waitFor(() => {
expect(screen.getByText('DJI')).toBeTruthy();
expect(screen.getByText('Photos')).toBeTruthy();
});
});
it('shows ingestion task table with scan, requeue, shuttle, phone-ingest', async () => {
render(MediaOps);
await waitFor(() => {
expect(screen.getByText('Library Scan')).toBeTruthy();
expect(screen.getByText('Thumbnail Clinic')).toBeTruthy();
expect(screen.getByText('SSD Shuttle')).toBeTruthy();
expect(screen.getByText('Phone Sync')).toBeTruthy();
});
});
it('shows health check button', async () => {
render(MediaOps);
await waitFor(() => {
const btn = screen.getByText('Run Health Check');
expect(btn).toBeTruthy();
});
});
it('shows Refresh Data button', async () => {
render(MediaOps);
await waitFor(() => {
const btn = screen.getByText('Refresh Data');
expect(btn).toBeTruthy();
});
});
it('shows NAS unreachable banner when status returns unavailable', async () => {
getMediaStatus.mockResolvedValue({ status: 'unavailable', error: 'NAS unreachable' });
getMediaStorage.mockResolvedValue({ status: 'unavailable', error: 'NAS unreachable' });
getMediaShuttleStatus.mockResolvedValue({ status: 'unavailable', error: 'NAS unreachable' });
render(MediaOps);
await waitFor(() => {
expect(screen.getByText('NAS Unreachable')).toBeTruthy();
});
});
it('shows Retry button in unreachable banner', async () => {
getMediaStatus.mockResolvedValue({ status: 'unavailable', error: 'NAS unreachable' });
getMediaStorage.mockResolvedValue({ status: 'unavailable', error: 'NAS unreachable' });
getMediaShuttleStatus.mockResolvedValue({ status: 'unavailable', error: 'NAS unreachable' });
render(MediaOps);
await waitFor(() => {
const retryBtn = screen.getByText('Retry');
expect(retryBtn).toBeTruthy();
});
});
it('shows shuttle queue depth when data available', async () => {
getMediaShuttleStatus.mockResolvedValue({
running: true, queue_depth: 3, last_transfer: 'rsync: 1.2GB from SD_CARD', recent_errors: [], current_stage: 'Stage 2: verify',
});
render(MediaOps);
await waitFor(() => {
expect(screen.getByText(/3 items/)).toBeTruthy();
});
});
it('shows shuttle error count when errors in log', async () => {
getMediaShuttleStatus.mockResolvedValue({
running: false, queue_depth: 0, last_transfer: null,
recent_errors: ['❌ failed to transfer', '❌ connection reset'], current_stage: null,
});
render(MediaOps);
await waitFor(() => {
expect(screen.getByText(/2 errors/)).toBeTruthy();
});
});
it('shows shuttle current stage when running', async () => {
getMediaShuttleStatus.mockResolvedValue({
running: true, queue_depth: 1, last_transfer: '1.2GB', recent_errors: [],
current_stage: 'Stage 3: ship',
});
render(MediaOps);
await waitFor(() => {
expect(screen.getByText(/Stage 3: ship/)).toBeTruthy();
});
});
it('renders task output console section', async () => {
render(MediaOps);
await waitFor(() => {
expect(screen.getByText('Task Output Console')).toBeTruthy();
});
});
it('shows Scan DJI and Scan Photos action buttons', async () => {
render(MediaOps);
await waitFor(() => {
expect(screen.getByText('Scan DJI')).toBeTruthy();
expect(screen.getByText('Scan Photos')).toBeTruthy();
});
});
});