feat: Added useYtDlpStatus/useUpdateYtDlp hooks and yt-dlp card to Syst…

- "src/frontend/src/api/hooks/useSystem.ts"
- "src/frontend/src/pages/System.tsx"

GSD-Task: S06/T02
This commit is contained in:
jlightner 2026-04-03 07:16:56 +00:00
parent c5820fe957
commit 9f35d06e88
2 changed files with 128 additions and 3 deletions

View file

@ -1,6 +1,6 @@
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { apiClient } from '../client';
import type { HealthResponse, SystemStatusResponse, ApiKeyResponse, AppSettingsResponse } from '@shared/types/api';
import type { HealthResponse, SystemStatusResponse, ApiKeyResponse, AppSettingsResponse, YtDlpStatusResponse, YtDlpUpdateResponse } from '@shared/types/api';
// ── Query Keys ──
@ -9,6 +9,7 @@ export const systemKeys = {
health: ['system', 'health'] as const,
apiKey: ['system', 'apikey'] as const,
appSettings: ['system', 'appSettings'] as const,
ytdlpStatus: ['system', 'ytdlpStatus'] as const,
};
// ── Queries ──
@ -70,3 +71,23 @@ export function useUpdateAppSettings() {
},
});
}
/** Fetch yt-dlp version and last-updated timestamp. Auto-refreshes every 60s. */
export function useYtDlpStatus() {
return useQuery({
queryKey: systemKeys.ytdlpStatus,
queryFn: () => apiClient.get<YtDlpStatusResponse>('/api/v1/system/ytdlp/status'),
refetchInterval: 60_000,
});
}
/** Trigger a yt-dlp update check. Invalidates the status query on success. */
export function useUpdateYtDlp() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: () => apiClient.post<YtDlpUpdateResponse>('/api/v1/system/ytdlp/update'),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: systemKeys.ytdlpStatus });
},
});
}

View file

@ -1,9 +1,11 @@
import { RefreshCw, Server, Activity, Cpu, HardDrive } from 'lucide-react';
import { useSystemStatus, useHealth } from '../api/hooks/useSystem';
import { RefreshCw, Server, Activity, Cpu, HardDrive, Download, CheckCircle, AlertCircle, Loader2 } from 'lucide-react';
import { useSystemStatus, useHealth, useYtDlpStatus, useUpdateYtDlp } from '../api/hooks/useSystem';
import { HealthStatus } from '../components/HealthStatus';
import { SkeletonSystem } from '../components/Skeleton';
import { formatBytes } from '../utils/format';
import { useState } from 'react';
// ── Helpers ──
function formatUptime(seconds: number): string {
@ -23,6 +25,9 @@ function formatUptime(seconds: number): string {
export function SystemPage() {
const { data: health, isLoading: healthLoading, error: healthError, refetch: refetchHealth } = useHealth();
const { data: status, isLoading: statusLoading, error: statusError, refetch: refetchStatus } = useSystemStatus();
const { data: ytdlpStatus, isLoading: ytdlpLoading, error: ytdlpError } = useYtDlpStatus();
const updateYtDlp = useUpdateYtDlp();
const [updateMessage, setUpdateMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null);
const isLoading = healthLoading || statusLoading;
@ -84,6 +89,105 @@ export function SystemPage() {
) : null}
</section>
{/* ── yt-dlp section ── */}
<section style={{ marginBottom: 'var(--space-8)' }}>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 'var(--space-4)' }}>
<h2 style={{ display: 'flex', alignItems: 'center', gap: 'var(--space-2)', fontSize: 'var(--font-size-lg)', fontWeight: 600, color: 'var(--text-primary)' }}>
<Download size={18} style={{ color: 'var(--accent)' }} />
yt-dlp
</h2>
</div>
<div
style={{
backgroundColor: 'var(--bg-card)',
borderRadius: 'var(--radius-lg)',
border: '1px solid var(--border)',
padding: 'var(--space-4)',
}}
>
{ytdlpLoading ? (
<p style={{ color: 'var(--text-muted)' }}>Loading yt-dlp status</p>
) : ytdlpError ? (
<p style={{ color: 'var(--danger)' }}>
Failed to load yt-dlp status: {ytdlpError instanceof Error ? ytdlpError.message : 'Unknown error'}
</p>
) : (
<>
<div style={{ display: 'flex', gap: 'var(--space-6)', marginBottom: 'var(--space-4)' }}>
<div>
<span style={{ fontSize: 'var(--font-size-xs)', color: 'var(--text-muted)', textTransform: 'uppercase', letterSpacing: '0.05em' }}>Version</span>
<p style={{ fontFamily: 'var(--font-mono)', fontSize: 'var(--font-size-sm)', color: 'var(--text-primary)', marginTop: 'var(--space-1)' }}>
{ytdlpStatus?.version ?? 'Unknown'}
</p>
</div>
<div>
<span style={{ fontSize: 'var(--font-size-xs)', color: 'var(--text-muted)', textTransform: 'uppercase', letterSpacing: '0.05em' }}>Last Updated</span>
<p style={{ fontFamily: 'var(--font-mono)', fontSize: 'var(--font-size-sm)', color: 'var(--text-primary)', marginTop: 'var(--space-1)' }}>
{ytdlpStatus?.lastUpdated ? new Date(ytdlpStatus.lastUpdated).toLocaleString() : 'Never'}
</p>
</div>
</div>
<div style={{ display: 'flex', alignItems: 'center', gap: 'var(--space-3)' }}>
<button
className="btn btn-secondary"
disabled={updateYtDlp.isPending}
onClick={() => {
setUpdateMessage(null);
updateYtDlp.mutate(undefined, {
onSuccess: (data) => {
setUpdateMessage({
type: 'success',
text: data.updated
? `Updated to ${data.version} (was ${data.previousVersion})`
: `Already up to date (${data.version})`,
});
},
onError: (err) => {
setUpdateMessage({
type: 'error',
text: err instanceof Error ? err.message : 'Update failed',
});
},
});
}}
>
{updateYtDlp.isPending ? (
<>
<Loader2 size={14} className="animate-spin" />
Updating
</>
) : (
<>
<RefreshCw size={14} />
Check for Updates
</>
)}
</button>
{updateMessage && (
<span style={{
display: 'inline-flex',
alignItems: 'center',
gap: 'var(--space-1)',
fontSize: 'var(--font-size-sm)',
color: updateMessage.type === 'success' ? 'var(--success)' : 'var(--danger)',
}}>
{updateMessage.type === 'success' ? <CheckCircle size={14} /> : <AlertCircle size={14} />}
{updateMessage.text}
</span>
)}
</div>
</>
)}
</div>
<p style={{ color: 'var(--text-muted)', fontSize: 'var(--font-size-xs)', marginTop: 'var(--space-2)' }}>
Auto-refreshes every 60 seconds.
</p>
</section>
{/* ── System Status section ── */}
<section>
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: 'var(--space-4)' }}>