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:
@@ -13,6 +13,7 @@
|
||||
import OPC from "./routes/tools/OPC.svelte";
|
||||
import Transmission from "./routes/media/Transmission.svelte";
|
||||
import Login from "./routes/Login.svelte";
|
||||
import MediaOps from "./routes/media/MediaOps.svelte";
|
||||
import { onMount } from "svelte";
|
||||
import { getToken, checkAuth, setToken, currentUser, setCurrentUser, getPreferences, savePreferences, tryRefreshSession, logout as logoutSession } from "./lib/api.js";
|
||||
|
||||
@@ -28,6 +29,7 @@
|
||||
"litellm",
|
||||
"opc",
|
||||
"transmission",
|
||||
"media-ops",
|
||||
]);
|
||||
|
||||
const navItems = [
|
||||
@@ -40,12 +42,12 @@
|
||||
{ id: "security", label: "Security", section: "main", description: "WebAuthn passkeys and TOTP" },
|
||||
// Media
|
||||
{ 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: "Jellyfin", section: "media", remoteHref: "https://media.jimmygan.com", description: "Media server" },
|
||||
{ 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: "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
|
||||
{ id: "chat-digest", label: "Chat Digest", section: "tools", description: "Telegram group summaries" },
|
||||
{ id: "conversations", label: "Code Sessions", section: "tools", description: "Claude Code session browser" },
|
||||
@@ -236,6 +238,8 @@
|
||||
<OPC />
|
||||
{:else if page === "transmission" && hasPageAccess("transmission")}
|
||||
<Transmission />
|
||||
{:else if page === "media-ops" && hasPageAccess("media-ops")}
|
||||
<MediaOps />
|
||||
{:else if page !== "terminal"}
|
||||
<Dashboard />
|
||||
{/if}
|
||||
|
||||
@@ -49,7 +49,7 @@
|
||||
{ label: "Audiobookshelf", remoteHref: "https://books.jimmygan.com", icon: "headphones" },
|
||||
{ label: "Immich", remoteHref: "https://photos.jimmygan.com", icon: "image" },
|
||||
{ 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" },
|
||||
];
|
||||
|
||||
|
||||
@@ -217,3 +217,28 @@ export async function download(path, filename) {
|
||||
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