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
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:
@@ -1,6 +1,9 @@
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import re
|
||||
from datetime import datetime
|
||||
|
||||
import asyncssh
|
||||
from fastapi import APIRouter, Depends, HTTPException, BackgroundTasks, Query
|
||||
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"
|
||||
|
||||
UNAVAILABLE_RESPONSE = {"status": "unavailable", "error": "NAS unreachable"}
|
||||
|
||||
|
||||
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:
|
||||
# Prepend PATH and NAS_HOST variables for host execution
|
||||
full_cmd = f'PATH="/volume1/homes/zjgump/bin:$PATH" NAS_HOST="naslan" {cmd}'
|
||||
async with asyncssh.connect(
|
||||
config.SSH_HOST,
|
||||
username=config.SSH_USER,
|
||||
client_keys=[config.SSH_KEY_PATH],
|
||||
known_hosts=None
|
||||
known_hosts=None,
|
||||
) as conn:
|
||||
result = await conn.run(full_cmd)
|
||||
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}")
|
||||
return 999, "", str(e)
|
||||
|
||||
|
||||
@router.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}")
|
||||
if code == 999:
|
||||
return UNAVAILABLE_RESPONSE
|
||||
return {
|
||||
"status": "ok" if code == 0 else "warning" if code == 1 else "error",
|
||||
"exit_code": code,
|
||||
"stdout": stdout,
|
||||
"stderr": stderr
|
||||
"stderr": stderr,
|
||||
}
|
||||
|
||||
|
||||
@router.get("/storage")
|
||||
async def get_storage():
|
||||
"""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")
|
||||
if code == 999:
|
||||
return UNAVAILABLE_RESPONSE
|
||||
if code != 0:
|
||||
raise HTTPException(status_code=500, detail=f"Storage report failed: {stderr}")
|
||||
return {"error": "Storage report failed", "stderr": stderr}
|
||||
try:
|
||||
return json.loads(stdout)
|
||||
except json.JSONDecodeError:
|
||||
return {
|
||||
"error": "Failed to parse JSON from storage report",
|
||||
"raw_output": stdout,
|
||||
"stderr": stderr
|
||||
}
|
||||
return {"error": "Failed to parse JSON from storage report", "raw_output": stdout}
|
||||
|
||||
|
||||
@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")
|
||||
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}"
|
||||
if library:
|
||||
cmd += f" {library}"
|
||||
|
||||
|
||||
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 &"
|
||||
|
||||
|
||||
code, stdout, stderr = await run_host_command(run_cmd)
|
||||
if code == 999:
|
||||
return UNAVAILABLE_RESPONSE
|
||||
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}
|
||||
|
||||
|
||||
@router.post("/maintenance/requeue")
|
||||
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"
|
||||
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 &"
|
||||
|
||||
|
||||
code, stdout, stderr = await run_host_command(run_cmd)
|
||||
if code == 999:
|
||||
return UNAVAILABLE_RESPONSE
|
||||
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}
|
||||
|
||||
|
||||
@router.get("/logs/{task_name}")
|
||||
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"]:
|
||||
raise HTTPException(status_code=400, detail="Invalid task name")
|
||||
|
||||
log_path = f"/volume1/homes/zjgump/media-mgmt/logs/{task_name}.log"
|
||||
if not os.path.exists(log_path):
|
||||
|
||||
log_path = f"{LOG_DIR}/{task_name}.log"
|
||||
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."}
|
||||
|
||||
try:
|
||||
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}")
|
||||
lines = stdout.strip().split("\n")
|
||||
return {"content": "\n".join(lines[-200:])}
|
||||
|
||||
|
||||
@router.get("/status")
|
||||
async def get_active_status():
|
||||
@@ -115,14 +194,16 @@ async def get_active_status():
|
||||
"scan": SCAN_LIBRARY_SCRIPT,
|
||||
"requeue": THUMBNAIL_CLINIC_SCRIPT,
|
||||
"shuttle": SHUTTLE_SCRIPT,
|
||||
"phone-ingest": PHONE_INGEST_SCRIPT
|
||||
"phone-ingest": PHONE_INGEST_SCRIPT,
|
||||
}
|
||||
|
||||
|
||||
status = {}
|
||||
for name, script_path in processes.items():
|
||||
code, stdout, stderr = await run_host_command(f"pgrep -f {script_path}")
|
||||
if code == 999:
|
||||
return UNAVAILABLE_RESPONSE
|
||||
status[name] = {
|
||||
"running": code == 0,
|
||||
"pid": stdout.strip() if code == 0 else None
|
||||
"pid": stdout.strip() if code == 0 else None,
|
||||
}
|
||||
return status
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import pytest
|
||||
from unittest.mock import patch, AsyncMock
|
||||
|
||||
|
||||
@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:
|
||||
mock_run.return_value = (0, "All checks passed\n", "")
|
||||
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 "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
|
||||
async def test_get_storage(test_app, admin_headers):
|
||||
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)
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert "volume" in data
|
||||
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
|
||||
async def test_run_scan(test_app, admin_headers):
|
||||
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()
|
||||
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
|
||||
async def test_get_active_status(test_app, admin_headers):
|
||||
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"]["pid"] == "1234"
|
||||
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"
|
||||
|
||||
@@ -242,3 +242,7 @@ export function getMediaLogs(taskName) {
|
||||
export function getMediaStatus() {
|
||||
return get("/media/status");
|
||||
}
|
||||
|
||||
export function getMediaShuttleStatus() {
|
||||
return get("/media/shuttle/status");
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import {
|
||||
getMediaHealth,
|
||||
getMediaStorage,
|
||||
getMediaShuttleStatus,
|
||||
startMediaScan,
|
||||
startMediaRequeue,
|
||||
getMediaLogs,
|
||||
@@ -12,9 +13,15 @@
|
||||
let loading = $state(true);
|
||||
let diagLoading = $state(false);
|
||||
let actionLoading = $state(false);
|
||||
let shuttleLoading = $state(false);
|
||||
|
||||
let health = $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({
|
||||
scan: { running: false, pid: null },
|
||||
requeue: { running: false, pid: null },
|
||||
@@ -31,24 +38,42 @@
|
||||
async function loadData() {
|
||||
loading = true;
|
||||
try {
|
||||
const [storageRes, statusRes] = await Promise.all([
|
||||
const [storageRes, statusRes, shuttleRes] = await Promise.all([
|
||||
getMediaStorage(),
|
||||
getMediaStatus()
|
||||
getMediaStatus(),
|
||||
getMediaShuttleStatus()
|
||||
]);
|
||||
storage = storageRes;
|
||||
storageUnavailable = storageRes?.status === 'unavailable';
|
||||
status = statusRes;
|
||||
unavailable = statusRes?.status === 'unavailable';
|
||||
shuttleDetail = shuttleRes;
|
||||
} catch (e) {
|
||||
console.error("Failed to load Media Ops data", e);
|
||||
unavailable = true;
|
||||
} finally {
|
||||
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() {
|
||||
diagLoading = true;
|
||||
health = null;
|
||||
healthUnavailable = false;
|
||||
try {
|
||||
health = await getMediaHealth();
|
||||
healthUnavailable = health?.status === 'unavailable';
|
||||
} catch (e) {
|
||||
console.error("Diagnostics failed", e);
|
||||
health = { status: "error", stdout: "", stderr: e?.message || "Failed to execute health check" };
|
||||
@@ -159,6 +184,21 @@
|
||||
</script>
|
||||
|
||||
<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>
|
||||
<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">
|
||||
<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>
|
||||
{#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 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')}">
|
||||
@@ -291,12 +347,21 @@
|
||||
</span>
|
||||
</td>
|
||||
<td class="py-4 px-1 text-right">
|
||||
<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"
|
||||
>
|
||||
View Logs
|
||||
</button>
|
||||
<div class="inline-flex gap-1.5">
|
||||
<button
|
||||
onclick={loadShuttleDetail}
|
||||
disabled={shuttleLoading}
|
||||
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"
|
||||
>
|
||||
{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>
|
||||
</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();
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user