Merge branch 'main' into dev

This commit is contained in:
Gan, Jimmy
2026-07-11 15:08:53 +08:00
17 changed files with 882 additions and 4 deletions
+1
View File
@@ -71,6 +71,7 @@ EXTERNAL_SERVICES = {
"stirling_pdf": os.environ.get("STIRLING_PDF_URL", "https://pdf.jimmygan.com"), "stirling_pdf": os.environ.get("STIRLING_PDF_URL", "https://pdf.jimmygan.com"),
"vaultwarden": os.environ.get("VAULTWARDEN_URL", "https://vault.jimmygan.com"), "vaultwarden": os.environ.get("VAULTWARDEN_URL", "https://vault.jimmygan.com"),
"n8n": os.environ.get("N8N_URL", "https://n8n.jimmygan.com"), "n8n": os.environ.get("N8N_URL", "https://n8n.jimmygan.com"),
"reasonix": os.environ.get("REASONIX_URL", "https://code.jimmygan.com"),
"speedtest": os.environ.get("SPEEDTEST_URL", "https://speed.jimmygan.com"), "speedtest": os.environ.get("SPEEDTEST_URL", "https://speed.jimmygan.com"),
"openconnector": os.environ.get("OPENCONNECTOR_URL", "https://connector.jimmygan.com"), "openconnector": os.environ.get("OPENCONNECTOR_URL", "https://connector.jimmygan.com"),
} }
+1
View File
@@ -17,6 +17,7 @@ ROUTER_CONFIGS = [
{"module": "opc_tasks", "prefix": "/api/opc", "deps": ["_inject_user", "require_page(\"opc\")"]}, {"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_agents", "prefix": "/api/opc", "deps": ["_inject_user", "require_page(\"opc\")"]},
{"module": "opc_projects", "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 = [ WS_ROUTES = [
{"module": "terminal", "path": "/ws/terminal", "handler": "ws_endpoint"}, {"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
+5 -1
View File
@@ -13,6 +13,7 @@
import OPC from "./routes/tools/OPC.svelte"; import OPC from "./routes/tools/OPC.svelte";
import Transmission from "./routes/media/Transmission.svelte"; import Transmission from "./routes/media/Transmission.svelte";
import Login from "./routes/Login.svelte"; import Login from "./routes/Login.svelte";
import MediaOps from "./routes/media/MediaOps.svelte";
import { onMount } from "svelte"; import { onMount } from "svelte";
import { getToken, checkAuth, setToken, currentUser, setCurrentUser, getPreferences, savePreferences, tryRefreshSession, logout as logoutSession } from "./lib/api.js"; import { getToken, checkAuth, setToken, currentUser, setCurrentUser, getPreferences, savePreferences, tryRefreshSession, logout as logoutSession } from "./lib/api.js";
@@ -28,6 +29,7 @@
"litellm", "litellm",
"opc", "opc",
"transmission", "transmission",
"media-ops",
]); ]);
const navItems = [ const navItems = [
@@ -40,12 +42,12 @@
{ id: "security", label: "Security", section: "main", description: "WebAuthn passkeys and TOTP" }, { id: "security", label: "Security", section: "main", description: "WebAuthn passkeys and TOTP" },
// Media // Media
{ id: "transmission", label: "Transmission", section: "media", description: "BitTorrent client" }, { id: "transmission", label: "Transmission", section: "media", description: "BitTorrent client" },
{ id: "media-ops", label: "Media Ops", section: "media", description: "Media library health, storage, and maintenance control panel" },
{ label: "Navidrome", section: "media", remoteHref: "https://music.jimmygan.com", description: "Music streaming" }, { label: "Navidrome", section: "media", remoteHref: "https://music.jimmygan.com", description: "Music streaming" },
{ label: "Jellyfin", section: "media", remoteHref: "https://media.jimmygan.com", description: "Media server" }, { label: "Jellyfin", section: "media", remoteHref: "https://media.jimmygan.com", description: "Media server" },
{ label: "Audiobookshelf", section: "media", remoteHref: "https://books.jimmygan.com", description: "Audiobooks & podcasts" }, { label: "Audiobookshelf", section: "media", remoteHref: "https://books.jimmygan.com", description: "Audiobooks & podcasts" },
{ label: "Immich", section: "media", remoteHref: "https://photos.jimmygan.com", description: "Photo and video backup" }, { label: "Immich", section: "media", remoteHref: "https://photos.jimmygan.com", description: "Photo and video backup" },
{ label: "t-youtube", section: "media", remoteHref: "https://yt.jimmygan.com", description: "YouTube subscription reader" }, { label: "t-youtube", section: "media", remoteHref: "https://yt.jimmygan.com", description: "YouTube subscription reader" },
{ label: "Media Management", section: "media", remoteHref: "https://media.jimmygan.com", description: "Media library management" },
// Tools // Tools
{ id: "chat-digest", label: "Chat Digest", section: "tools", description: "Telegram group summaries" }, { id: "chat-digest", label: "Chat Digest", section: "tools", description: "Telegram group summaries" },
{ id: "conversations", label: "Code Sessions", section: "tools", description: "Claude Code session browser" }, { id: "conversations", label: "Code Sessions", section: "tools", description: "Claude Code session browser" },
@@ -236,6 +238,8 @@
<OPC /> <OPC />
{:else if page === "transmission" && hasPageAccess("transmission")} {:else if page === "transmission" && hasPageAccess("transmission")}
<Transmission /> <Transmission />
{:else if page === "media-ops" && hasPageAccess("media-ops")}
<MediaOps />
{:else if page !== "terminal"} {:else if page !== "terminal"}
<Dashboard /> <Dashboard />
{/if} {/if}
@@ -49,7 +49,7 @@
{ label: "Audiobookshelf", remoteHref: "https://books.jimmygan.com", icon: "headphones" }, { label: "Audiobookshelf", remoteHref: "https://books.jimmygan.com", icon: "headphones" },
{ label: "Immich", remoteHref: "https://photos.jimmygan.com", icon: "image" }, { label: "Immich", remoteHref: "https://photos.jimmygan.com", icon: "image" },
{ label: "t-youtube", remoteHref: "https://yt.jimmygan.com", icon: "youtube" }, { label: "t-youtube", remoteHref: "https://yt.jimmygan.com", icon: "youtube" },
{ label: "Media Management", remoteHref: "https://media.jimmygan.com", icon: "wrench" }, { id: "media-ops", label: "Media Ops", icon: "wrench" },
{ id: "transmission", label: "Transmission", icon: "download" }, { id: "transmission", label: "Transmission", icon: "download" },
]; ];
@@ -62,6 +62,7 @@
{ label: "Stirling PDF", remoteHref: "https://pdf.jimmygan.com", icon: "pdf", external: true }, { label: "Stirling PDF", remoteHref: "https://pdf.jimmygan.com", icon: "pdf", external: true },
{ label: "Vaultwarden", remoteHref: "https://vault.jimmygan.com", icon: "lock", external: true }, { label: "Vaultwarden", remoteHref: "https://vault.jimmygan.com", icon: "lock", external: true },
{ label: "n8n", remoteHref: "https://n8n.jimmygan.com", icon: "n8n", external: true }, { label: "n8n", remoteHref: "https://n8n.jimmygan.com", icon: "n8n", external: true },
{ label: "Reasonix", remoteHref: "https://code.jimmygan.com", icon: "code", external: true },
{ label: "Speedtest", remoteHref: "https://speed.jimmygan.com", icon: "speedtest", external: true }, { label: "Speedtest", remoteHref: "https://speed.jimmygan.com", icon: "speedtest", external: true },
{ label: "OpenConnector", remoteHref: "https://connector.jimmygan.com", port: 3002, icon: "link", external: true }, { label: "OpenConnector", remoteHref: "https://connector.jimmygan.com", port: 3002, icon: "link", external: true },
]; ];
+25
View File
@@ -217,3 +217,28 @@ export async function download(path, filename) {
throw e; throw e;
} }
} }
export function getMediaHealth() {
return get("/media/health");
}
export function getMediaStorage() {
return get("/media/storage");
}
export function startMediaScan(library = null) {
const path = library ? `/media/maintenance/scan?library=${encodeURIComponent(library)}` : "/media/maintenance/scan";
return post(path, {});
}
export function startMediaRequeue() {
return post("/media/maintenance/requeue", {});
}
export function getMediaLogs(taskName) {
return get(`/media/logs/${taskName}`);
}
export function getMediaStatus() {
return get("/media/status");
}
@@ -0,0 +1,483 @@
<script>
import { onMount, onDestroy } from "svelte";
import {
getMediaHealth,
getMediaStorage,
startMediaScan,
startMediaRequeue,
getMediaLogs,
getMediaStatus
} from "../../lib/api.js";
let loading = $state(true);
let diagLoading = $state(false);
let actionLoading = $state(false);
let health = $state(null);
let storage = $state(null);
let status = $state({
scan: { running: false, pid: null },
requeue: { running: false, pid: null },
shuttle: { running: false, pid: null },
"phone-ingest": { running: false, pid: null }
});
let selectedTask = $state("scan");
let logs = $state("Select a task or start an action to view logs.");
let logPolling = $state(true);
let pollIntervals = [];
async function loadData() {
loading = true;
try {
const [storageRes, statusRes] = await Promise.all([
getMediaStorage(),
getMediaStatus()
]);
storage = storageRes;
status = statusRes;
} catch (e) {
console.error("Failed to load Media Ops data", e);
} finally {
loading = false;
}
}
async function runDiagnostics() {
diagLoading = true;
health = null;
try {
health = await getMediaHealth();
} catch (e) {
console.error("Diagnostics failed", e);
health = { status: "error", stdout: "", stderr: e?.message || "Failed to execute health check" };
} finally {
diagLoading = false;
}
}
async function triggerScan(library = null) {
actionLoading = true;
selectedTask = "scan";
logs = "Triggering scan...";
try {
await startMediaScan(library);
// Wait a moment for process to launch
setTimeout(updateStatusAndLogs, 1000);
} catch (e) {
logs = "Error triggering scan: " + (e?.message || e);
} finally {
actionLoading = false;
}
}
async function triggerRequeue() {
actionLoading = true;
selectedTask = "requeue";
logs = "Triggering thumbnail requeue...";
try {
await startMediaRequeue();
// Wait a moment for process to launch
setTimeout(updateStatusAndLogs, 1000);
} catch (e) {
logs = "Error triggering requeue: " + (e?.message || e);
} finally {
actionLoading = false;
}
}
async function fetchLogs() {
try {
const res = await getMediaLogs(selectedTask);
logs = res.content || "No logs available.";
} catch (e) {
logs = "Error fetching logs: " + (e?.message || e);
}
}
async function updateStatusAndLogs() {
try {
status = await getMediaStatus();
await fetchLogs();
} catch (e) {
console.error("Failed to update status and logs", e);
}
}
// Effect to fetch logs when selectedTask changes
$effect(() => {
if (selectedTask) {
fetchLogs();
}
});
onMount(() => {
loadData();
runDiagnostics();
// Status polling (every 5 seconds)
const statusPoll = setInterval(async () => {
try {
status = await getMediaStatus();
} catch (e) {
console.error("Status polling failed", e);
}
}, 5000);
// Logs polling (every 3 seconds if active)
const logsPoll = setInterval(() => {
if (logPolling && selectedTask) {
fetchLogs();
}
}, 3000);
pollIntervals.push(statusPoll, logsPoll);
});
onDestroy(() => {
pollIntervals.forEach(clearInterval);
});
function getStatusColor(taskName) {
if (status[taskName]?.running) return "bg-emerald-500 text-emerald-900 dark:bg-emerald-900/40 dark:text-emerald-300";
return "bg-surface-100 text-surface-600 dark:bg-surface-700 dark:text-surface-400";
}
function getStatusLabel(taskName) {
if (status[taskName]?.running) return "Running (PID " + status[taskName].pid + ")";
return "Idle";
}
function bytesToHuman(bytes) {
if (!bytes) return "0 B";
const k = 1024;
const sizes = ["B", "KB", "MB", "GB", "TB"];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + " " + sizes[i];
}
</script>
<div class="space-y-6">
<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>
<p class="text-sm text-surface-400 mt-1">Synology Immich ingestion pipelines, health, storage reports, and maintenance.</p>
</div>
<div class="flex items-center gap-2">
<button
onclick={loadData}
disabled={loading}
class="px-3.5 py-2 text-xs font-semibold text-surface-700 bg-white hover:bg-surface-50 border border-surface-200 dark:border-surface-700 dark:bg-surface-800 dark:text-surface-200 dark:hover:bg-surface-700 rounded-lg transition-colors flex items-center gap-1.5 shadow-sm disabled:opacity-50"
>
<svg class="w-3.5 h-3.5 {loading ? 'animate-spin' : ''}" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2.2"><path stroke-linecap="round" stroke-linejoin="round" d="M4 4v5h.582m15.356 2A8.001 8.001 0 1121.21 7.89M9 11l3-3m0 0l3 3m-3-3v8" /></svg>
Refresh Data
</button>
<button
onclick={runDiagnostics}
disabled={diagLoading}
class="px-3.5 py-2 text-xs font-semibold text-white bg-primary-600 hover:bg-primary-700 rounded-lg shadow-sm transition-colors flex items-center gap-1.5 disabled:opacity-50"
>
<svg class="w-3.5 h-3.5 {diagLoading ? 'animate-spin' : ''}" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2.2"><path stroke-linecap="round" stroke-linejoin="round" d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z" /></svg>
Run Health Check
</button>
</div>
</div>
<div class="grid grid-cols-1 lg:grid-cols-3 gap-6">
<!-- Ingestion & Actions (Left 2 Columns) -->
<div class="lg:col-span-2 space-y-6">
<!-- Ingestion Progress Tracker -->
<div class="bg-white dark:bg-surface-800 rounded-xl border border-surface-200 dark:border-surface-700 shadow-sm p-6">
<h2 class="text-md font-bold text-surface-900 dark:text-white mb-4">Ingestion & Maintenance Tasks</h2>
<div class="overflow-x-auto">
<table class="min-w-full divide-y divide-surface-200 dark:divide-surface-700 text-left">
<thead>
<tr class="text-xs font-semibold text-surface-400 uppercase">
<th class="py-3 px-1">Task Name</th>
<th class="py-3 px-1">Status</th>
<th class="py-3 px-1 text-right">Actions</th>
</tr>
</thead>
<tbody class="divide-y divide-surface-150 dark:divide-surface-700 text-sm font-medium">
<!-- Scan Library -->
<tr>
<td class="py-4 px-1">
<div class="font-bold text-surface-800 dark:text-surface-100">Library Scan</div>
<div class="text-xs text-surface-400 font-normal mt-0.5">Trigger Immich scanning API for ingest paths.</div>
</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('scan')}">
{#if status.scan?.running}
<span class="w-1.5 h-1.5 rounded-full bg-emerald-500 animate-ping"></span>
{/if}
{getStatusLabel('scan')}
</span>
</td>
<td class="py-4 px-1 text-right">
<div class="inline-flex gap-1.5">
<button
onclick={() => triggerScan('DJI-Action6')}
disabled={actionLoading}
class="px-2 py-1 text-xs text-primary-600 bg-primary-50 hover:bg-primary-100 rounded-md transition-colors font-semibold disabled:opacity-50 dark:bg-primary-950/40 dark:text-primary-300"
>
Scan DJI
</button>
<button
onclick={() => triggerScan('Photos')}
disabled={actionLoading}
class="px-2 py-1 text-xs text-primary-600 bg-primary-50 hover:bg-primary-100 rounded-md transition-colors font-semibold disabled:opacity-50 dark:bg-primary-950/40 dark:text-primary-300"
>
Scan Photos
</button>
<button
onclick={() => { selectedTask = "scan"; 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>
<!-- Thumbnail Clinic -->
<tr>
<td class="py-4 px-1">
<div class="font-bold text-surface-800 dark:text-surface-100">Thumbnail Clinic</div>
<div class="text-xs text-surface-400 font-normal mt-0.5">Detect missing thumbnails and requeue Bull jobs.</div>
</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('requeue')}">
{#if status.requeue?.running}
<span class="w-1.5 h-1.5 rounded-full bg-emerald-500 animate-ping"></span>
{/if}
{getStatusLabel('requeue')}
</span>
</td>
<td class="py-4 px-1 text-right">
<div class="inline-flex gap-1.5">
<button
onclick={triggerRequeue}
disabled={actionLoading}
class="px-2 py-1 text-xs text-primary-600 bg-primary-50 hover:bg-primary-100 rounded-md transition-colors font-semibold disabled:opacity-50 dark:bg-primary-950/40 dark:text-primary-300"
>
Run Clinic
</button>
<button
onclick={() => { selectedTask = "requeue"; 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>
<!-- SSD Shuttle -->
<tr>
<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>
</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')}">
{#if status.shuttle?.running}
<span class="w-1.5 h-1.5 rounded-full bg-emerald-500 animate-ping"></span>
{/if}
{getStatusLabel('shuttle')}
</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>
</td>
</tr>
<!-- Phone Sync -->
<tr>
<td class="py-4 px-1">
<div class="font-bold text-surface-800 dark:text-surface-100">Phone Sync</div>
<div class="text-xs text-surface-400 font-normal mt-0.5">Upload/sync phone media folders to target storage.</div>
</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('phone-ingest')}">
{#if status['phone-ingest']?.running}
<span class="w-1.5 h-1.5 rounded-full bg-emerald-500 animate-ping"></span>
{/if}
{getStatusLabel('phone-ingest')}
</span>
</td>
<td class="py-4 px-1 text-right">
<button
onclick={() => { selectedTask = "phone-ingest"; 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>
</td>
</tr>
</tbody>
</table>
</div>
</div>
<!-- Real-Time Log Viewer -->
<div class="bg-white dark:bg-surface-800 rounded-xl border border-surface-200 dark:border-surface-700 shadow-sm p-6">
<div class="flex items-center justify-between mb-4">
<div>
<h2 class="text-md font-bold text-surface-900 dark:text-white">Task Output Console</h2>
<p class="text-xs text-surface-400 mt-0.5">Showing last 200 lines for: <span class="font-bold text-primary-500 uppercase">{selectedTask}</span></p>
</div>
<div class="flex items-center gap-3">
<label class="flex items-center gap-1.5 text-xs text-surface-500 cursor-pointer select-none">
<input type="checkbox" bind:checked={logPolling} class="rounded border-surface-300 text-primary-600 focus:ring-primary-500 h-3.5 w-3.5" />
Auto-refresh (3s)
</label>
<button
onclick={fetchLogs}
class="p-1 text-surface-500 hover:text-surface-700 hover:bg-surface-100 rounded dark:hover:bg-surface-750"
title="Refresh logs"
>
<svg class="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2"><path stroke-linecap="round" stroke-linejoin="round" d="M4 4v5h.582m15.356 2A8.001 8.001 0 1121.21 7.89M9 11l3-3m0 0l3 3m-3-3v8" /></svg>
</button>
</div>
</div>
<pre class="bg-surface-900 text-surface-200 p-4 rounded-lg text-[11px] font-mono leading-relaxed overflow-x-auto max-h-[350px] overflow-y-auto whitespace-pre-wrap">{logs}</pre>
</div>
</div>
<!-- Right Column (Diagnostics & Storage) -->
<div class="space-y-6">
<!-- Live Diagnostics Widget -->
<div class="bg-white dark:bg-surface-800 rounded-xl border border-surface-200 dark:border-surface-700 shadow-sm p-6">
<h2 class="text-md font-bold text-surface-900 dark:text-white mb-4">Live Diagnostics Status</h2>
{#if health}
<div class="p-4 rounded-lg mb-4 flex items-start gap-3
{health.status === 'ok' ? 'bg-emerald-50 dark:bg-emerald-950/20 border border-emerald-100 dark:border-emerald-900/30' :
health.status === 'warning' ? 'bg-amber-50 dark:bg-amber-950/20 border border-amber-100 dark:border-amber-900/30' :
'bg-rose-50 dark:bg-rose-950/20 border border-rose-100 dark:border-rose-900/30'}">
{#if health.status === 'ok'}
<span class="text-emerald-500 mt-0.5">
<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="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" /></svg>
</span>
<div>
<div class="font-bold text-emerald-800 dark:text-emerald-300 text-sm">All Checks Green</div>
<div class="text-xs text-emerald-600 dark:text-emerald-400 mt-0.5">Immich API and Docker containers running properly.</div>
</div>
{:else if health.status === 'warning'}
<span class="text-amber-500 mt-0.5">
<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="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" /></svg>
</span>
<div>
<div class="font-bold text-amber-800 dark:text-amber-300 text-sm">Passed with Warnings</div>
<div class="text-xs text-amber-600 dark:text-amber-400 mt-0.5">Checks passed but disk warning threshold was met.</div>
</div>
{:else}
<span class="text-rose-500 mt-0.5">
<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>
<div class="font-bold text-rose-800 dark:text-rose-300 text-sm">Services Degraded</div>
<div class="text-xs text-rose-600 dark:text-rose-400 mt-0.5">One or more components are offline or failing pings.</div>
</div>
{/if}
</div>
<details class="group rounded-lg border border-surface-200 dark:border-surface-700 bg-surface-50 dark:bg-surface-900/50">
<summary class="px-4 py-2.5 text-xs font-bold text-surface-600 dark:text-surface-400 cursor-pointer list-none flex items-center justify-between">
Diagnostics Details
<svg class="w-4 h-4 transition-transform group-open:rotate-180" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" /></svg>
</summary>
<pre class="px-4 pb-4 pt-1 font-mono text-[10px] text-surface-700 dark:text-surface-300 whitespace-pre-wrap border-t border-surface-200 dark:border-surface-700">{health.stdout || health.stderr}</pre>
</details>
{:else}
<div class="py-8 text-center text-xs text-surface-400">
{diagLoading ? 'Running check...' : 'No diagnostics run yet.'}
</div>
{/if}
</div>
<!-- Storage Breakdown -->
<div class="bg-white dark:bg-surface-800 rounded-xl border border-surface-200 dark:border-surface-700 shadow-sm p-6">
<h2 class="text-md font-bold text-surface-900 dark:text-white mb-4">Storage Breakdown</h2>
{#if storage}
<div class="space-y-5">
<!-- Disk Usage Summary -->
<div class="p-4 rounded-lg bg-surface-50 dark:bg-surface-900/40 border border-surface-150 dark:border-surface-750">
<div class="flex justify-between text-xs font-bold text-surface-500">
<span>Volume Usage ({storage.volume.path})</span>
<span class="text-surface-700 dark:text-white">{storage.volume.used} / {storage.volume.total}</span>
</div>
<div class="w-full bg-surface-200 dark:bg-surface-700 h-2.5 rounded-full mt-2.5 overflow-hidden">
<div class="bg-primary-500 h-full rounded-full" style="width: {storage.volume.used_pct}%"></div>
</div>
<div class="flex justify-between text-[10px] text-surface-400 mt-2 font-medium">
<span>{storage.volume.available} free</span>
<span>{storage.volume.used_pct}% used</span>
</div>
</div>
<!-- Library Counts -->
<div class="space-y-3">
<h3 class="text-xs font-bold text-surface-400 uppercase tracking-wider">Libraries</h3>
{#each storage.libraries as lib}
<div class="p-3.5 rounded-lg border border-surface-150 dark:border-surface-750 flex flex-col gap-2">
<div class="flex items-center justify-between">
<span class="text-sm font-bold text-surface-800 dark:text-surface-100">{lib.name}</span>
<span class="text-xs font-semibold text-primary-500">{lib.total_size}</span>
</div>
<div class="flex justify-between text-xs text-surface-400 font-medium">
<span>Files: <strong class="text-surface-600 dark:text-surface-300">{lib.total_files}</strong></span>
<span>({lib.images} imgs, {lib.videos} vids)</span>
</div>
<div class="w-full bg-surface-100 dark:bg-surface-750 h-1.5 rounded-full overflow-hidden">
<div class="bg-primary-400 h-full rounded-full" style="width: {lib.volume_pct * 2.5}%"></div>
</div>
<div class="text-[9px] text-surface-400 text-right font-medium">
{lib.volume_pct}% of total storage
</div>
</div>
{/each}
</div>
<!-- Ingestion directory sizes -->
<div class="space-y-2">
<h3 class="text-xs font-bold text-surface-400 uppercase tracking-wider">Ingest Folders</h3>
<div class="flex items-center justify-between text-xs font-semibold p-2.5 rounded bg-surface-50 dark:bg-surface-900/30 text-surface-600 dark:text-surface-400">
<span class="truncate" title={storage.directories.dji_001.path}>video/DJI_001</span>
<span class="text-surface-800 dark:text-surface-200 font-mono">{storage.directories.dji_001.size}</span>
</div>
<div class="flex items-center justify-between text-xs font-semibold p-2.5 rounded bg-surface-50 dark:bg-surface-900/30 text-surface-600 dark:text-surface-400">
<span class="truncate" title={storage.directories.immich_upload.path}>immich/upload</span>
<span class="text-surface-800 dark:text-surface-200 font-mono">{storage.directories.immich_upload.size}</span>
</div>
</div>
</div>
{:else}
<div class="py-12 text-center text-xs text-surface-400">
Loading storage details...
</div>
{/if}
</div>
</div>
</div>
</div>
+6
View File
@@ -0,0 +1,6 @@
data/
.env
.env.example
.git
.gitignore
*.md
+2
View File
@@ -0,0 +1,2 @@
# DeepSeek API key for litellm proxy
DEEPSEEK_LOCAL_API_KEY=sk-your-key-here
+17
View File
@@ -0,0 +1,17 @@
FROM node:alpine AS build
RUN npm install -g reasonix@1.17.10
FROM node:alpine
COPY --from=build /usr/local/lib/node_modules /usr/local/lib/node_modules
COPY --from=build /usr/local/bin/reasonix /usr/local/bin/reasonix
RUN addgroup -S reasonix && adduser -S reasonix -G reasonix
USER reasonix
WORKDIR /home/reasonix
EXPOSE 8787
ENTRYPOINT ["reasonix", "serve", "--addr", "0.0.0.0:8787", "--auth", "none"]
+34
View File
@@ -0,0 +1,34 @@
services:
reasonix:
build:
context: .
dockerfile: Dockerfile
image: reasonix:latest
container_name: reasonix
restart: unless-stopped
labels:
- "com.centurylinklabs.watchtower.enable=false"
ports:
- "127.0.0.1:8787:8787"
environment:
- DEEPSEEK_LOCAL_API_KEY=${DEEPSEEK_LOCAL_API_KEY}
- TZ=Asia/Shanghai
volumes:
- ./reasonix.toml:/home/reasonix/.reasonix/config.toml:ro
- ./data:/home/reasonix/.reasonix
networks:
- nas-dashboard_internal
healthcheck:
test: ["CMD", "wget", "-q", "--spider", "http://localhost:8787"]
interval: 30s
timeout: 5s
retries: 3
logging:
driver: json-file
options:
max-size: "10m"
max-file: "3"
networks:
nas-dashboard_internal:
external: true
+18
View File
@@ -0,0 +1,18 @@
[[providers]]
name = "deepseek-flash"
kind = "openai"
base_url = "https://api.deepseek.com"
model = "deepseek-v4-flash"
api_key_env = "DEEPSEEK_API_KEY"
context_window = 1000000
[[providers]]
name = "deepseek-pro"
kind = "openai"
base_url = "https://api.deepseek.com"
model = "deepseek-v4-pro"
api_key_env = "DEEPSEEK_API_KEY"
context_window = 1000000
[defaults]
model = "deepseek-flash"
+92
View File
@@ -0,0 +1,92 @@
#!/bin/bash
# Cloudflare DNS upsert — create or update a DNS record.
# Usage: CLOUDFLARE_API_TOKEN=xxx ./scripts/cloudflare-dns.sh <zone> <type> <name> <content> [ttl]
#
# Examples:
# cloudflare-dns.sh jimmygan.com A code 161.33.182.109
# cloudflare-dns.sh jimmygan.com CNAME www jimmygan.com 120
set -euo pipefail
ZONE="${1:?Usage: $0 <zone> <type> <name> <content> [ttl]}"
TYPE="${2:?}"
NAME="${3:?}"
CONTENT="${4:?}"
TTL="${5:-120}"
if [ -z "${CLOUDFLARE_API_TOKEN:-}" ]; then
echo "❌ CLOUDFLARE_API_TOKEN not set" >&2
exit 1
fi
API="https://api.cloudflare.com/client/v4"
AUTH_HEADER="Authorization: Bearer $CLOUDFLARE_API_TOKEN"
CONTENT_TYPE="Content-Type: application/json"
echo "🔍 Looking up zone: $ZONE"
ZONE_RESP=$(curl -s -H "$AUTH_HEADER" -H "$CONTENT_TYPE" \
"$API/zones?name=$ZONE&status=active")
ZONE_ID=$(echo "$ZONE_RESP" | python3 -c "
import sys, json
d = json.load(sys.stdin)
if not d.get('success'):
print('API error:', d.get('errors'), file=sys.stderr)
sys.exit(1)
result = d.get('result', [])
if not result:
print('Zone not found:', '$ZONE', file=sys.stderr)
sys.exit(1)
print(result[0]['id'])
")
echo "✅ Zone ID: $ZONE_ID"
echo "🔍 Checking existing record: $NAME.$ZONE ($TYPE)"
RECORD_RESP=$(curl -s -H "$AUTH_HEADER" -H "$CONTENT_TYPE" \
"$API/zones/$ZONE_ID/dns_records?type=$TYPE&name=$NAME.$ZONE")
RECORD_ID=$(echo "$RECORD_RESP" | python3 -c "
import sys, json
d = json.load(sys.stdin)
if not d.get('success'):
sys.exit(0) # skip, will create
result = d.get('result', [])
if result:
# Prefer exact content match
for r in result:
if r['content'] == '$CONTENT':
print(r['id'])
sys.exit(0)
# Fall back to first match
print(result[0]['id'])
")
FULL_NAME="$NAME.$ZONE"
if [ -n "$RECORD_ID" ]; then
echo "🔄 Updating existing record ($RECORD_ID): $FULL_NAME$CONTENT"
RESP=$(curl -s -X PUT \
-H "$AUTH_HEADER" -H "$CONTENT_TYPE" \
-d "{\"type\":\"$TYPE\",\"name\":\"$FULL_NAME\",\"content\":\"$CONTENT\",\"ttl\":$TTL,\"proxied\":false}" \
"$API/zones/$ZONE_ID/dns_records/$RECORD_ID")
SUCCESS=$(echo "$RESP" | python3 -c "import sys,json; d=json.load(sys.stdin); print('true' if d.get('success') else 'false')")
if [ "$SUCCESS" = "true" ]; then
echo "✅ Updated: $FULL_NAME$CONTENT"
else
echo "❌ Failed to update:" >&2
echo "$RESP" | python3 -c "import sys,json; d=json.load(sys.stdin); print(d.get('errors'))" >&2
exit 1
fi
else
echo "🆕 Creating new record: $FULL_NAME$CONTENT"
RESP=$(curl -s -X POST \
-H "$AUTH_HEADER" -H "$CONTENT_TYPE" \
-d "{\"type\":\"$TYPE\",\"name\":\"$FULL_NAME\",\"content\":\"$CONTENT\",\"ttl\":$TTL,\"proxied\":false}" \
"$API/zones/$ZONE_ID/dns_records")
SUCCESS=$(echo "$RESP" | python3 -c "import sys,json; d=json.load(sys.stdin); print('true' if d.get('success') else 'false')")
if [ "$SUCCESS" = "true" ]; then
echo "✅ Created: $FULL_NAME$CONTENT"
else
echo "❌ Failed to create:" >&2
echo "$RESP" | python3 -c "import sys,json; d=json.load(sys.stdin); print(d.get('errors'))" >&2
exit 1
fi
fi
+2 -2
View File
@@ -106,6 +106,6 @@
- [x] CI workflows are DRY (or documented why they can't be) (or documented why they can't be) - [x] CI workflows are DRY (or documented why they can't be) (or documented why they can't be)
- [x] Login.svelte is SSR-safe - [x] Login.svelte is SSR-safe
- [x] No legacy localStorage refresh token logic (or clearly documented) (or clearly documented) - [x] No legacy localStorage refresh token logic (or clearly documented) (or clearly documented)
- [ ] All backend tests pass - [x] All backend tests pass
- [ ] All frontend tests pass - [x] All frontend tests pass
- [x] `npm run build` succeeds - [x] `npm run build` succeeds
+11
View File
@@ -141,6 +141,17 @@ vault.jimmygan.com {
reverse_proxy 127.0.0.1:8222 reverse_proxy 127.0.0.1:8222
} }
code.jimmygan.com {
tls {
dns cloudflare {env.CLOUDFLARE_API_TOKEN}
}
forward_auth 127.0.0.1:9092 {
uri /api/verify?rd=https://auth.jimmygan.com:8443
copy_headers Remote-User Remote-Groups Remote-Name Remote-Email
}
reverse_proxy 127.0.0.1:8787
}
speed.jimmygan.com { speed.jimmygan.com {
tls { tls {
dns cloudflare {env.CLOUDFLARE_API_TOKEN} dns cloudflare {env.CLOUDFLARE_API_TOKEN}
+6
View File
@@ -66,3 +66,9 @@ photos-app.jimmygan.com {
} }
} }
} }
code.jimmygan.com {
import security_headers
import authelia
reverse_proxy 100.78.131.124:8787
}