Sprint 08: Media Control backend routers, frontend dashboard view, integration tests, and SSH host command wrapper
Deploy Dashboard (Main) / Backend Tests (push) Successful in 3m26s
Deploy Dashboard (Main) / Frontend Tests (push) Successful in 7s
Deploy Dashboard (Main) / Build Main Image (push) Successful in 4m52s
Deploy Dashboard (Main) / Deploy to Production (push) Successful in 1m0s

This commit is contained in:
Gan, Jimmy
2026-07-11 13:42:46 +08:00
parent 9a4f9ecde3
commit 258f8fd2f7
7 changed files with 692 additions and 2 deletions
+1
View File
@@ -17,6 +17,7 @@ ROUTER_CONFIGS = [
{"module": "opc_tasks", "prefix": "/api/opc", "deps": ["_inject_user", "require_page(\"opc\")"]},
{"module": "opc_agents", "prefix": "/api/opc", "deps": ["_inject_user", "require_page(\"opc\")"]},
{"module": "opc_projects", "prefix": "/api/opc", "deps": ["_inject_user", "require_page(\"opc\")"]},
{"module": "media_mgmt", "prefix": "/api/media", "deps": ["_inject_user", "require_page(\"media-ops\")"]},
]
WS_ROUTES = [
{"module": "terminal", "path": "/ws/terminal", "handler": "ws_endpoint"},
+128
View File
@@ -0,0 +1,128 @@
import json
import logging
import os
import asyncssh
from fastapi import APIRouter, Depends, HTTPException, BackgroundTasks, Query
import config
router = APIRouter()
logger = logging.getLogger(__name__)
# Absolute paths to media-mgmt scripts on NAS host
HEALTH_CHECK_SCRIPT = "/volume1/homes/zjgump/media-mgmt/immich/scripts/health-check.sh"
STORAGE_REPORT_SCRIPT = "/volume1/homes/zjgump/media-mgmt/diagnostics/storage-report.sh"
SCAN_LIBRARY_SCRIPT = "/volume1/homes/zjgump/media-mgmt/immich/scripts/scan-library.sh"
THUMBNAIL_CLINIC_SCRIPT = "/volume1/homes/zjgump/media-mgmt/diagnostics/thumbnail-clinic.sh"
SHUTTLE_SCRIPT = "/volume1/homes/zjgump/media-mgmt/transfer/ssd-shuttle.sh"
PHONE_INGEST_SCRIPT = "/volume1/homes/zjgump/media-mgmt/transfer/phone-to-nas.sh"
LOG_DIR = "/volume1/homes/zjgump/media-mgmt/logs"
async def run_host_command(cmd: str) -> tuple[int, str, str]:
"""Execute a command on the NAS host via SSH."""
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
) as conn:
result = await conn.run(full_cmd)
return result.exit_status, result.stdout, result.stderr
except Exception as e:
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."""
code, stdout, stderr = await run_host_command(f"bash {HEALTH_CHECK_SCRIPT}")
return {
"status": "ok" if code == 0 else "warning" if code == 1 else "error",
"exit_code": code,
"stdout": stdout,
"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 != 0:
raise HTTPException(status_code=500, detail=f"Storage report failed: {stderr}")
try:
return json.loads(stdout)
except json.JSONDecodeError:
return {
"error": "Failed to parse JSON from storage report",
"raw_output": stdout,
"stderr": stderr
}
@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."""
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 != 0:
raise HTTPException(status_code=500, detail=f"Failed to start scan: {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."""
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 != 0:
raise HTTPException(status_code=500, detail=f"Failed to start thumbnail requeue: {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."""
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):
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}")
@router.get("/status")
async def get_active_status():
"""Checks if any ingestion/maintenance processes are currently running on the host."""
processes = {
"scan": SCAN_LIBRARY_SCRIPT,
"requeue": THUMBNAIL_CLINIC_SCRIPT,
"shuttle": SHUTTLE_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}")
status[name] = {
"running": code == 0,
"pid": stdout.strip() if code == 0 else None
}
return status
@@ -0,0 +1,49 @@
import pytest
from unittest.mock import patch, AsyncMock
@pytest.mark.asyncio
async def test_get_health(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)
assert response.status_code == 200
data = response.json()
assert data["status"] == "ok"
assert data["exit_code"] == 0
assert "All checks passed" in data["stdout"]
@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}}', "")
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_run_scan(test_app, admin_headers):
with patch("routers.media_mgmt.run_host_command", new_callable=AsyncMock) as mock_run:
mock_run.return_value = (0, "", "")
response = test_app.post("/api/media/maintenance/scan?library=DJI-Action6", headers=admin_headers)
assert response.status_code == 200
data = response.json()
assert "Scan started" in data["message"]
mock_run.assert_called_once()
assert "scan-library.sh DJI-Action6" in mock_run.call_args[0][0]
@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:
def side_effect(cmd):
if "scan-library.sh" in cmd:
return (0, "1234\n", "")
return (1, "", "")
mock_run.side_effect = side_effect
response = test_app.get("/api/media/status", headers=admin_headers)
assert response.status_code == 200
data = response.json()
assert data["scan"]["running"] is True
assert data["scan"]["pid"] == "1234"
assert data["requeue"]["running"] is False