sprint: Media Ops — shuttle status endpoint, graceful offline mode, 18 backend tests, shuttle detail in pipeline card, error states with retry, 13 frontend vitest tests
Deploy Dashboard (Main) / Backend Tests (push) Failing after 18s
Deploy Dashboard (Main) / Frontend Tests (push) Successful in 10s
Deploy Dashboard (Main) / Build Main Image (push) Failing after 7m26s
Deploy Dashboard (Main) / Deploy to Production (push) Has been skipped

This commit is contained in:
Gan, Jimmy
2026-07-12 15:36:13 +08:00
parent 07ad32f784
commit 9f193ec133
5 changed files with 543 additions and 45 deletions
+4
View File
@@ -242,3 +242,7 @@ export function getMediaLogs(taskName) {
export function getMediaStatus() {
return get("/media/status");
}
export function getMediaShuttleStatus() {
return get("/media/shuttle/status");
}
@@ -3,6 +3,7 @@
import {
getMediaHealth,
getMediaStorage,
getMediaShuttleStatus,
startMediaScan,
startMediaRequeue,
getMediaLogs,
@@ -12,9 +13,15 @@
let loading = $state(true);
let diagLoading = $state(false);
let actionLoading = $state(false);
let shuttleLoading = $state(false);
let health = $state(null);
let storage = $state(null);
let shuttleDetail = $state(null);
let unavailable = $state(false);
let healthUnavailable = $state(false);
let storageUnavailable = $state(false);
let status = $state({
scan: { running: false, pid: null },
requeue: { running: false, pid: null },
@@ -31,24 +38,42 @@
async function loadData() {
loading = true;
try {
const [storageRes, statusRes] = await Promise.all([
const [storageRes, statusRes, shuttleRes] = await Promise.all([
getMediaStorage(),
getMediaStatus()
getMediaStatus(),
getMediaShuttleStatus()
]);
storage = storageRes;
storageUnavailable = storageRes?.status === 'unavailable';
status = statusRes;
unavailable = statusRes?.status === 'unavailable';
shuttleDetail = shuttleRes;
} catch (e) {
console.error("Failed to load Media Ops data", e);
unavailable = true;
} finally {
loading = false;
}
}
async function loadShuttleDetail() {
shuttleLoading = true;
try {
shuttleDetail = await getMediaShuttleStatus();
} catch (e) {
console.error("Failed to load shuttle detail", e);
} finally {
shuttleLoading = false;
}
}
async function runDiagnostics() {
diagLoading = true;
health = null;
healthUnavailable = false;
try {
health = await getMediaHealth();
healthUnavailable = health?.status === 'unavailable';
} catch (e) {
console.error("Diagnostics failed", e);
health = { status: "error", stdout: "", stderr: e?.message || "Failed to execute health check" };
@@ -159,6 +184,21 @@
</script>
<div class="space-y-6">
{#if unavailable || healthUnavailable || storageUnavailable}
<div class="p-4 rounded-xl bg-rose-50 dark:bg-rose-950/20 border border-rose-100 dark:border-rose-900/30 flex items-start gap-3">
<span class="text-rose-500 mt-0.5 shrink-0">
<svg class="w-5 h-5" fill="none" viewBox="0 0 24 24" stroke="currentColor" stroke-width="2.5"><path stroke-linecap="round" stroke-linejoin="round" d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z" /></svg>
</span>
<div class="flex-1">
<div class="font-bold text-rose-800 dark:text-rose-300 text-sm">NAS Unreachable</div>
<div class="text-xs text-rose-600 dark:text-rose-400 mt-0.5">Cannot connect to Synology NAS via SSH. Some services may be offline.</div>
</div>
<button onclick={loadData} class="px-3 py-1.5 text-xs font-semibold text-rose-700 bg-rose-100 hover:bg-rose-200 rounded-lg transition-colors shrink-0 dark:bg-rose-900/40 dark:text-rose-300">
Retry
</button>
</div>
{/if}
<div class="flex flex-col md:flex-row md:items-center justify-between gap-4">
<div>
<h1 class="text-2xl font-bold text-surface-900 dark:text-white tracking-tight">Media Ops</h1>
@@ -281,6 +321,22 @@
<td class="py-4 px-1">
<div class="font-bold text-surface-800 dark:text-surface-100">SSD Shuttle</div>
<div class="text-xs text-surface-400 font-normal mt-0.5">Ingest/move files from portable SSD shuttle to NAS.</div>
{#if shuttleDetail && !shuttleDetail?.status?.startsWith('unavail')}
<div class="mt-2 space-y-1">
{#if shuttleDetail.queue_depth > 0}
<div class="text-[10px] text-surface-500">Queue: <span class="font-bold text-surface-700 dark:text-surface-300">{shuttleDetail.queue_depth} item{shuttleDetail.queue_depth > 1 ? 's' : ''}</span></div>
{/if}
{#if shuttleDetail.last_transfer}
<div class="text-[10px] text-surface-500">Last: <span class="font-mono text-surface-700 dark:text-surface-300">{shuttleDetail.last_transfer}</span></div>
{/if}
{#if shuttleDetail.recent_errors?.length}
<div class="text-[10px] text-rose-500">{shuttleDetail.recent_errors.length} error{shuttleDetail.recent_errors.length > 1 ? 's' : ''} in log</div>
{/if}
{#if shuttleDetail.current_stage}
<div class="text-[10px] text-emerald-500">Stage: {shuttleDetail.current_stage}</div>
{/if}
</div>
{/if}
</td>
<td class="py-4 px-1">
<span class="inline-flex items-center gap-1 px-2.5 py-0.5 rounded-full text-xs font-semibold {getStatusColor('shuttle')}">
@@ -291,12 +347,21 @@
</span>
</td>
<td class="py-4 px-1 text-right">
<button
onclick={() => { selectedTask = "shuttle"; fetchLogs(); }}
class="px-2 py-1 text-xs text-surface-600 bg-surface-100 hover:bg-surface-200 rounded-md transition-colors font-semibold dark:bg-surface-700 dark:text-surface-300"
>
View Logs
</button>
<div class="inline-flex gap-1.5">
<button
onclick={loadShuttleDetail}
disabled={shuttleLoading}
class="px-2 py-1 text-xs text-surface-600 bg-surface-100 hover:bg-surface-200 rounded-md transition-colors font-semibold dark:bg-surface-700 dark:text-surface-300 disabled:opacity-50"
>
{shuttleLoading ? '...' : 'Refresh'}
</button>
<button
onclick={() => { selectedTask = "shuttle"; fetchLogs(); }}
class="px-2 py-1 text-xs text-surface-600 bg-surface-100 hover:bg-surface-200 rounded-md transition-colors font-semibold dark:bg-surface-700 dark:text-surface-300"
>
Logs
</button>
</div>
</td>
</tr>
@@ -0,0 +1,169 @@
/**
* Component tests for MediaOps.svelte
*/
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { render, screen, fireEvent, waitFor } from '@testing-library/svelte';
import MediaOps from '../../src/routes/media/MediaOps.svelte';
// Mock the API module
vi.mock('../../src/lib/api.js', () => ({
getMediaHealth: vi.fn(),
getMediaStorage: vi.fn(),
getMediaShuttleStatus: vi.fn(),
getMediaStatus: vi.fn(),
getMediaLogs: vi.fn(),
startMediaScan: vi.fn(),
startMediaRequeue: vi.fn(),
}));
import { getMediaHealth, getMediaStorage, getMediaShuttleStatus, getMediaStatus, getMediaLogs, startMediaScan, startMediaRequeue } from '../../src/lib/api.js';
describe('MediaOps Component', () => {
beforeEach(() => {
vi.clearAllMocks();
// Default mock data — healthy NAS
getMediaHealth.mockResolvedValue({ status: 'ok', exit_code: 0, stdout: 'All checks passed', stderr: '' });
getMediaStorage.mockResolvedValue({
volume: { path: '/volume1', total: '14T', used: '12T', available: '2.2T', used_pct: 85 },
libraries: [
{ name: 'DJI', total_size: '3.5T', total_files: 15000, images: 5000, videos: 10000, volume_pct: 25 },
{ name: 'Photos', total_size: '6T', total_files: 80000, images: 70000, videos: 10000, volume_pct: 43 },
],
directories: {
dji_001: { path: '/volume1/video/DJI_001', size: '2.1T' },
immich_upload: { path: '/volume1/immich/upload', size: '4.5T' },
},
});
getMediaShuttleStatus.mockResolvedValue({
running: false, queue_depth: 0, last_transfer: null, recent_errors: [], current_stage: null,
});
getMediaStatus.mockResolvedValue({
scan: { running: false, pid: null },
requeue: { running: false, pid: null },
shuttle: { running: false, pid: null },
'phone-ingest': { running: false, pid: null },
});
getMediaLogs.mockResolvedValue({ content: 'No log file found.' });
});
it('renders the Media Ops header', async () => {
render(MediaOps);
await waitFor(() => {
expect(screen.getByText('Media Ops')).toBeTruthy();
});
});
it('renders storage data after loading', async () => {
render(MediaOps);
await waitFor(() => {
expect(screen.getByText(/14T/)).toBeTruthy();
});
});
it('renders library names from storage data', async () => {
render(MediaOps);
await waitFor(() => {
expect(screen.getByText('DJI')).toBeTruthy();
expect(screen.getByText('Photos')).toBeTruthy();
});
});
it('shows ingestion task table with scan, requeue, shuttle, phone-ingest', async () => {
render(MediaOps);
await waitFor(() => {
expect(screen.getByText('Library Scan')).toBeTruthy();
expect(screen.getByText('Thumbnail Clinic')).toBeTruthy();
expect(screen.getByText('SSD Shuttle')).toBeTruthy();
expect(screen.getByText('Phone Sync')).toBeTruthy();
});
});
it('shows health check button', async () => {
render(MediaOps);
await waitFor(() => {
const btn = screen.getByText('Run Health Check');
expect(btn).toBeTruthy();
});
});
it('shows Refresh Data button', async () => {
render(MediaOps);
await waitFor(() => {
const btn = screen.getByText('Refresh Data');
expect(btn).toBeTruthy();
});
});
it('shows NAS unreachable banner when status returns unavailable', async () => {
getMediaStatus.mockResolvedValue({ status: 'unavailable', error: 'NAS unreachable' });
getMediaStorage.mockResolvedValue({ status: 'unavailable', error: 'NAS unreachable' });
getMediaShuttleStatus.mockResolvedValue({ status: 'unavailable', error: 'NAS unreachable' });
render(MediaOps);
await waitFor(() => {
expect(screen.getByText('NAS Unreachable')).toBeTruthy();
});
});
it('shows Retry button in unreachable banner', async () => {
getMediaStatus.mockResolvedValue({ status: 'unavailable', error: 'NAS unreachable' });
getMediaStorage.mockResolvedValue({ status: 'unavailable', error: 'NAS unreachable' });
getMediaShuttleStatus.mockResolvedValue({ status: 'unavailable', error: 'NAS unreachable' });
render(MediaOps);
await waitFor(() => {
const retryBtn = screen.getByText('Retry');
expect(retryBtn).toBeTruthy();
});
});
it('shows shuttle queue depth when data available', async () => {
getMediaShuttleStatus.mockResolvedValue({
running: true, queue_depth: 3, last_transfer: 'rsync: 1.2GB from SD_CARD', recent_errors: [], current_stage: 'Stage 2: verify',
});
render(MediaOps);
await waitFor(() => {
expect(screen.getByText(/3 items/)).toBeTruthy();
});
});
it('shows shuttle error count when errors in log', async () => {
getMediaShuttleStatus.mockResolvedValue({
running: false, queue_depth: 0, last_transfer: null,
recent_errors: ['❌ failed to transfer', '❌ connection reset'], current_stage: null,
});
render(MediaOps);
await waitFor(() => {
expect(screen.getByText(/2 errors/)).toBeTruthy();
});
});
it('shows shuttle current stage when running', async () => {
getMediaShuttleStatus.mockResolvedValue({
running: true, queue_depth: 1, last_transfer: '1.2GB', recent_errors: [],
current_stage: 'Stage 3: ship',
});
render(MediaOps);
await waitFor(() => {
expect(screen.getByText(/Stage 3: ship/)).toBeTruthy();
});
});
it('renders task output console section', async () => {
render(MediaOps);
await waitFor(() => {
expect(screen.getByText('Task Output Console')).toBeTruthy();
});
});
it('shows Scan DJI and Scan Photos action buttons', async () => {
render(MediaOps);
await waitFor(() => {
expect(screen.getByText('Scan DJI')).toBeTruthy();
expect(screen.getByText('Scan Photos')).toBeTruthy();
});
});
});