refactor: fold yt-dlp update controls into existing Health card, remove standalone section
This commit is contained in:
parent
9f35d06e88
commit
f26db65121
2 changed files with 130 additions and 134 deletions
|
|
@ -1,6 +1,9 @@
|
|||
import { AlertTriangle, CheckCircle2, HardDrive, Play, Square, Terminal } from 'lucide-react';
|
||||
import { useState } from 'react';
|
||||
import { AlertTriangle, CheckCircle2, HardDrive, Loader2, Play, RefreshCw, Square, Terminal, CheckCircle, AlertCircle } from 'lucide-react';
|
||||
import type { ComponentHealth } from '@shared/types/api';
|
||||
import { formatBytes } from '../utils/format';
|
||||
import type { YtDlpStatusResponse, YtDlpUpdateResponse } from '@shared/types/api';
|
||||
import type { UseMutationResult } from '@tanstack/react-query';
|
||||
|
||||
// ── Status → color mapping ──
|
||||
|
||||
|
|
@ -27,9 +30,12 @@ const COMPONENT_LABELS: Record<string, string> = {
|
|||
interface HealthStatusProps {
|
||||
components: ComponentHealth[];
|
||||
overallStatus: 'healthy' | 'degraded' | 'unhealthy';
|
||||
ytdlpStatus?: YtDlpStatusResponse | null;
|
||||
ytdlpLoading?: boolean;
|
||||
updateYtDlp?: UseMutationResult<YtDlpUpdateResponse, Error, void>;
|
||||
}
|
||||
|
||||
export function HealthStatus({ components, overallStatus }: HealthStatusProps) {
|
||||
export function HealthStatus({ components, overallStatus, ytdlpStatus, ytdlpLoading, updateYtDlp }: HealthStatusProps) {
|
||||
const overallColors = STATUS_COLORS[overallStatus] ?? DEFAULT_COLORS;
|
||||
const overallLabel = overallStatus.charAt(0).toUpperCase() + overallStatus.slice(1);
|
||||
|
||||
|
|
@ -74,7 +80,13 @@ export function HealthStatus({ components, overallStatus }: HealthStatusProps) {
|
|||
}}
|
||||
>
|
||||
{components.map((comp) => (
|
||||
<ComponentCard key={comp.name} component={comp} />
|
||||
<ComponentCard
|
||||
key={comp.name}
|
||||
component={comp}
|
||||
ytdlpStatus={comp.name === 'ytDlp' ? ytdlpStatus : undefined}
|
||||
ytdlpLoading={comp.name === 'ytDlp' ? ytdlpLoading : undefined}
|
||||
updateYtDlp={comp.name === 'ytDlp' ? updateYtDlp : undefined}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
||||
|
|
@ -98,7 +110,12 @@ export function HealthStatus({ components, overallStatus }: HealthStatusProps) {
|
|||
|
||||
// ── Component Card ──
|
||||
|
||||
function ComponentCard({ component }: { component: ComponentHealth }) {
|
||||
function ComponentCard({ component, ytdlpStatus, ytdlpLoading, updateYtDlp }: {
|
||||
component: ComponentHealth;
|
||||
ytdlpStatus?: YtDlpStatusResponse | null;
|
||||
ytdlpLoading?: boolean;
|
||||
updateYtDlp?: UseMutationResult<YtDlpUpdateResponse, Error, void>;
|
||||
}) {
|
||||
const colors = STATUS_COLORS[component.status] ?? DEFAULT_COLORS;
|
||||
const label = COMPONENT_LABELS[component.name] ?? component.name;
|
||||
|
||||
|
|
@ -142,20 +159,24 @@ function ComponentCard({ component }: { component: ComponentHealth }) {
|
|||
</span>
|
||||
</div>
|
||||
|
||||
{/* Custom detail rendering per component type */}
|
||||
<ComponentDetail component={component} />
|
||||
<ComponentDetail component={component} ytdlpStatus={ytdlpStatus} ytdlpLoading={ytdlpLoading} updateYtDlp={updateYtDlp} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Detail Renderers ──
|
||||
|
||||
function ComponentDetail({ component }: { component: ComponentHealth }) {
|
||||
function ComponentDetail({ component, ytdlpStatus, ytdlpLoading, updateYtDlp }: {
|
||||
component: ComponentHealth;
|
||||
ytdlpStatus?: YtDlpStatusResponse | null;
|
||||
ytdlpLoading?: boolean;
|
||||
updateYtDlp?: UseMutationResult<YtDlpUpdateResponse, Error, void>;
|
||||
}) {
|
||||
switch (component.name) {
|
||||
case 'diskSpace':
|
||||
return <DiskSpaceDetail component={component} />;
|
||||
case 'ytDlp':
|
||||
return <YtDlpDetail component={component} />;
|
||||
return <YtDlpDetail component={component} ytdlpStatus={ytdlpStatus} ytdlpLoading={ytdlpLoading} updateYtDlp={updateYtDlp} />;
|
||||
case 'scheduler':
|
||||
return <SchedulerDetail component={component} />;
|
||||
case 'recentErrors':
|
||||
|
|
@ -233,30 +254,101 @@ function DiskSpaceDetail({ component }: { component: ComponentHealth }) {
|
|||
|
||||
// ── yt-dlp ──
|
||||
|
||||
function YtDlpDetail({ component }: { component: ComponentHealth }) {
|
||||
function YtDlpDetail({ component, ytdlpStatus, ytdlpLoading, updateYtDlp }: {
|
||||
component: ComponentHealth;
|
||||
ytdlpStatus?: YtDlpStatusResponse | null;
|
||||
ytdlpLoading?: boolean;
|
||||
updateYtDlp?: UseMutationResult<YtDlpUpdateResponse, Error, void>;
|
||||
}) {
|
||||
const [updateMessage, setUpdateMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null);
|
||||
const details = component.details as { version?: string } | undefined;
|
||||
|
||||
return (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 'var(--space-2)' }}>
|
||||
<Terminal size={14} style={{ color: 'var(--text-muted)' }} aria-hidden="true" />
|
||||
{details?.version ? (
|
||||
<span
|
||||
style={{
|
||||
fontSize: 'var(--font-size-sm)',
|
||||
fontFamily: 'var(--font-mono)',
|
||||
color: 'var(--text-secondary)',
|
||||
padding: '1px var(--space-2)',
|
||||
borderRadius: 'var(--radius-sm)',
|
||||
backgroundColor: 'var(--bg-hover)',
|
||||
}}
|
||||
>
|
||||
v{details.version}
|
||||
</span>
|
||||
) : (
|
||||
<span style={{ fontSize: 'var(--font-size-sm)', color: 'var(--danger)', display: 'flex', alignItems: 'center', gap: 'var(--space-1)' }}>
|
||||
<AlertTriangle size={12} aria-hidden="true" />
|
||||
Not installed
|
||||
</span>
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: 'var(--space-3)' }}>
|
||||
{/* Version */}
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 'var(--space-2)' }}>
|
||||
<Terminal size={14} style={{ color: 'var(--text-muted)' }} aria-hidden="true" />
|
||||
{details?.version ? (
|
||||
<span
|
||||
style={{
|
||||
fontSize: 'var(--font-size-sm)',
|
||||
fontFamily: 'var(--font-mono)',
|
||||
color: 'var(--text-secondary)',
|
||||
padding: '1px var(--space-2)',
|
||||
borderRadius: 'var(--radius-sm)',
|
||||
backgroundColor: 'var(--bg-hover)',
|
||||
}}
|
||||
>
|
||||
v{details.version}
|
||||
</span>
|
||||
) : (
|
||||
<span style={{ fontSize: 'var(--font-size-sm)', color: 'var(--danger)', display: 'flex', alignItems: 'center', gap: 'var(--space-1)' }}>
|
||||
<AlertTriangle size={12} aria-hidden="true" />
|
||||
Not installed
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Last Updated */}
|
||||
{!ytdlpLoading && ytdlpStatus && (
|
||||
<div style={{ fontSize: 'var(--font-size-xs)', color: 'var(--text-muted)' }}>
|
||||
Last checked: {ytdlpStatus.lastUpdated ? new Date(ytdlpStatus.lastUpdated).toLocaleString() : 'Never'}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Update button */}
|
||||
{updateYtDlp && (
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 'var(--space-2)', flexWrap: 'wrap' }}>
|
||||
<button
|
||||
className="btn btn-ghost"
|
||||
style={{ fontSize: 'var(--font-size-xs)', padding: '2px var(--space-2)' }}
|
||||
disabled={updateYtDlp.isPending}
|
||||
onClick={() => {
|
||||
setUpdateMessage(null);
|
||||
updateYtDlp.mutate(undefined, {
|
||||
onSuccess: (data) => {
|
||||
setUpdateMessage({
|
||||
type: 'success',
|
||||
text: data.updated
|
||||
? `Updated to ${data.version}`
|
||||
: `Up to date`,
|
||||
});
|
||||
},
|
||||
onError: (err) => {
|
||||
setUpdateMessage({
|
||||
type: 'error',
|
||||
text: err instanceof Error ? err.message : 'Update failed',
|
||||
});
|
||||
},
|
||||
});
|
||||
}}
|
||||
>
|
||||
{updateYtDlp.isPending ? (
|
||||
<>
|
||||
<Loader2 size={12} className="animate-spin" />
|
||||
Checking…
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<RefreshCw size={12} />
|
||||
Check for Updates
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{updateMessage && (
|
||||
<span style={{
|
||||
display: 'inline-flex',
|
||||
alignItems: 'center',
|
||||
gap: '4px',
|
||||
fontSize: 'var(--font-size-xs)',
|
||||
color: updateMessage.type === 'success' ? 'var(--success)' : 'var(--danger)',
|
||||
}}>
|
||||
{updateMessage.type === 'success' ? <CheckCircle size={12} /> : <AlertCircle size={12} />}
|
||||
{updateMessage.text}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,11 +1,9 @@
|
|||
import { RefreshCw, Server, Activity, Cpu, HardDrive, Download, CheckCircle, AlertCircle, Loader2 } from 'lucide-react';
|
||||
import { RefreshCw, Server, Activity, Cpu, HardDrive } 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 {
|
||||
|
|
@ -25,9 +23,8 @@ 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 { data: ytdlpStatus, isLoading: ytdlpLoading } = useYtDlpStatus();
|
||||
const updateYtDlp = useUpdateYtDlp();
|
||||
const [updateMessage, setUpdateMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null);
|
||||
|
||||
const isLoading = healthLoading || statusLoading;
|
||||
|
||||
|
|
@ -85,109 +82,16 @@ export function SystemPage() {
|
|||
</button>
|
||||
</div>
|
||||
) : health ? (
|
||||
<HealthStatus components={health.components} overallStatus={health.status} />
|
||||
<HealthStatus
|
||||
components={health.components}
|
||||
overallStatus={health.status}
|
||||
ytdlpStatus={ytdlpStatus ?? null}
|
||||
ytdlpLoading={ytdlpLoading}
|
||||
updateYtDlp={updateYtDlp}
|
||||
/>
|
||||
) : 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)' }}>
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue