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
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:
@@ -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"},
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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" },
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
Reference in New Issue
Block a user