755de34501
The entire admin surface of the SPA had been dead since auth landed (5ed8d9e/3bc9f2d). nginx.conf.template injected only `Authorization: Bearer ${MUZICK_API_KEY}` for all of /api, docker-compose passed only MUZICK_API_KEY to the frontend container, and app.ts requires token === adminKey for /api/admin/*. The two keys differ, and services/api.ts sets no headers of its own. All 10 admin call sites were affected: the Jobs page polled 403s every 3s/5s forever and rendered a blank Overview with no error state, and every Settings library action (Scan, Reindex, Reprocess artists, Re-enrich, Duplicates merge) silently failed. Three changes, each necessary: - a `location /api/admin/` block injecting the admin key - the Dockerfile envsubst list widened to include MUZICK_ADMIN_KEY, without which the new variable substitutes to empty and the header becomes a bare "Bearer" - MUZICK_ADMIN_KEY passed to the frontend service in docker-compose nginx selects the longest matching prefix regardless of block order; verified empirically in a throwaway nginx:stable-alpine running the real envsubst output against a stub that echoes $http_authorization: /api/admin/queue-stats -> Bearer ADMINKEY456 /api/admin/duplicates/merge -> Bearer ADMINKEY456 /api/tracks -> Bearer APIKEY123 /api/health -> Bearer APIKEY123 All 10 call sites use /admin/... under the axios /api baseURL and none request bare /api/admin without a trailing slash. Also gives the Jobs page an error state: a banner that names a 401/403 as a missing or wrong admin key, a Retry button, "Loading queue stats..." in place of a blank Overview, and refetchInterval returning false once the query has errored so it stops hammering a failing endpoint. Deletes frontend/nginx.conf — unreferenced by the Dockerfile (confirmed by grep) and the insecure variant of the template. Worth noting and not addressed here: the outer LAN-only proxy already forges credentials for everything reaching /api, so this key split buys no real security while having cost the whole admin surface. Collapsing to one key would be simpler. REVIEW-2026-07-30.md finding 2. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
515 lines
19 KiB
TypeScript
515 lines
19 KiB
TypeScript
import { useQuery } from '@tanstack/react-query';
|
|
import { useCallback, useEffect, useMemo, useState } from 'react';
|
|
import {
|
|
Activity,
|
|
Play,
|
|
Pause,
|
|
RotateCcw,
|
|
Terminal,
|
|
CheckCircle,
|
|
AlertCircle,
|
|
Clock,
|
|
ChevronDown,
|
|
ChevronRight,
|
|
Copy,
|
|
Filter,
|
|
X,
|
|
RefreshCw,
|
|
} from 'lucide-react';
|
|
import {
|
|
jobsService,
|
|
JobHistoryEntry,
|
|
JobStatus,
|
|
JOB_LABELS,
|
|
getJobDataSummary,
|
|
getJobDataDetails,
|
|
getJobStatus,
|
|
} from '../services/jobsService';
|
|
import { formatDistanceToNow, format } from 'date-fns';
|
|
|
|
/* ─────────────────────────────────────────── Filter state ─────────────────────────────────────────── */
|
|
|
|
interface Filters {
|
|
status: JobStatus | 'all';
|
|
type: string;
|
|
}
|
|
|
|
const INITIAL_FILTERS: Filters = { status: 'all', type: 'all' };
|
|
|
|
/* ─────────────────────────────────────────── Helpers ─────────────────────────────────────────────── */
|
|
|
|
const JOB_TYPES = Object.keys(JOB_LABELS);
|
|
|
|
function statusBgColor(status: JobStatus): string {
|
|
switch (status) {
|
|
case 'running':
|
|
return 'bg-accent/10 border-accent/25';
|
|
case 'failed':
|
|
return 'bg-red-400/10 border-red-400/25';
|
|
case 'completed':
|
|
return 'bg-green-400/10 border-green-400/25';
|
|
}
|
|
}
|
|
|
|
function formatDuration(ms: number): string {
|
|
const seconds = Math.floor(ms / 1000);
|
|
if (seconds < 60) return `${seconds}s`;
|
|
const minutes = Math.floor(seconds / 60);
|
|
const remainSec = seconds % 60;
|
|
return `${minutes}m ${remainSec}s`;
|
|
}
|
|
|
|
/* ─────────────────────────────────────────── Sub-components ──────────────────────────────────────── */
|
|
|
|
function StatCard({ icon: Icon, label, value, color }: { icon: React.ComponentType<{ className?: string; style?: React.CSSProperties }>; label: string; value: number; color: string }) {
|
|
return (
|
|
<div className="bg-surface0 border border-border rounded-lg p-4">
|
|
<div className="flex items-center justify-between">
|
|
<div>
|
|
<p className="text-xs text-muted uppercase tracking-wide">{label}</p>
|
|
<p className="text-3xl font-bold mt-1" style={{ color }}>{value.toLocaleString()}</p>
|
|
</div>
|
|
<Icon className="w-8 h-8 opacity-30" style={{ color }} />
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function StatusBadge({ status }: { status: JobStatus }) {
|
|
const dotColor = status === 'running' ? 'bg-accent' : status === 'failed' ? 'bg-red-400' : 'bg-green-400';
|
|
const label = status === 'running' ? 'Running' : status === 'failed' ? 'Failed' : 'Completed';
|
|
|
|
return (
|
|
<span className={`inline-flex items-center gap-1.5 px-2 py-0.5 rounded-full text-xs font-medium ${statusBgColor(status)}`}>
|
|
<span className={`w-1.5 h-1.5 rounded-full ${dotColor} ${status === 'running' ? 'animate-pulse' : ''}`} />
|
|
{label}
|
|
</span>
|
|
);
|
|
}
|
|
|
|
function ExpandedDetail({ label, value }: { label: string; value: string }) {
|
|
return (
|
|
<div className="flex items-start gap-2 text-xs">
|
|
<span className="text-muted shrink-0 w-28">{label}</span>
|
|
<span className="text-text font-mono break-all">{value || '—'}</span>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
/* ─────────────────────────────────────────── Job row ─────────────────────────────────────────────── */
|
|
|
|
function JobRow({
|
|
job,
|
|
isExpanded,
|
|
onToggle,
|
|
}: {
|
|
job: JobHistoryEntry;
|
|
isExpanded: boolean;
|
|
onToggle: () => void;
|
|
}) {
|
|
const status = getJobStatus(job);
|
|
const summary = getJobDataSummary(job.name, job.data);
|
|
const details = useMemo(() => getJobDataDetails(job.name, job.data), [job.name, job.data]);
|
|
const duration = job.finishedOn && job.timestamp ? job.finishedOn - job.timestamp : null;
|
|
const hasReturnValue = job.returnvalue !== undefined && job.returnvalue !== null;
|
|
|
|
return (
|
|
<div
|
|
className={`bg-surface0 border border-border rounded-lg transition-colors ${
|
|
isExpanded ? 'border-accent/40' : 'hover:border-accent/30'
|
|
}`}
|
|
>
|
|
{/* ── collapsed row ── */}
|
|
<button
|
|
onClick={onToggle}
|
|
className="w-full flex items-center gap-3 px-4 py-3 text-left"
|
|
>
|
|
{/* status icon */}
|
|
<div className="shrink-0">
|
|
{status === 'running' && <Activity className="w-4 h-4 text-accent animate-spin" />}
|
|
{status === 'failed' && <AlertCircle className="w-4 h-4 text-red-400" />}
|
|
{status === 'completed' && <CheckCircle className="w-4 h-4 text-green-400" />}
|
|
</div>
|
|
|
|
{/* job name + summary */}
|
|
<div className="flex-1 min-w-0">
|
|
<div className="flex items-center gap-2">
|
|
<span className="font-medium text-sm">{JOB_LABELS[job.name] || job.name}</span>
|
|
<StatusBadge status={status} />
|
|
</div>
|
|
{summary && (
|
|
<p className="text-xs text-muted mt-0.5 truncate max-w-md">{summary}</p>
|
|
)}
|
|
</div>
|
|
|
|
{/* timestamp + duration */}
|
|
<div className="hidden sm:flex flex-col items-end text-xs text-muted shrink-0">
|
|
<span>{formatDistanceToNow(new Date(job.timestamp), { addSuffix: true })}</span>
|
|
{duration !== null && (
|
|
<span className="opacity-60">{formatDuration(duration)}</span>
|
|
)}
|
|
</div>
|
|
|
|
{/* expand icon */}
|
|
<div className="shrink-0 text-muted">
|
|
{isExpanded ? <ChevronDown className="w-4 h-4" /> : <ChevronRight className="w-4 h-4" />}
|
|
</div>
|
|
</button>
|
|
|
|
{/* ── expanded detail ── */}
|
|
{isExpanded && (
|
|
<div className="border-t border-border px-4 py-3 space-y-3">
|
|
{/* Job metadata */}
|
|
<div className="space-y-1">
|
|
<ExpandedDetail label="Job ID" value={job.id} />
|
|
<ExpandedDetail label="Created" value={format(new Date(job.timestamp), 'PPpp')} />
|
|
{job.finishedOn && (
|
|
<ExpandedDetail label="Finished" value={format(new Date(job.finishedOn), 'PPpp')} />
|
|
)}
|
|
{duration !== null && (
|
|
<ExpandedDetail label="Duration" value={formatDuration(duration)} />
|
|
)}
|
|
</div>
|
|
|
|
{/* Job-specific payload */}
|
|
{details.length > 0 && (
|
|
<div>
|
|
<p className="text-xs font-medium text-muted mb-1 uppercase tracking-wider">Payload</p>
|
|
<div className="space-y-1">
|
|
{details.map((d) => (
|
|
<ExpandedDetail key={d.label} label={d.label} value={d.value} />
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Raw data (collapsible) */}
|
|
{Object.keys(job.data).length > 0 && (
|
|
<RawDataBlock label="Raw Payload" data={job.data} />
|
|
)}
|
|
|
|
{/* Return value */}
|
|
{hasReturnValue && <RawDataBlock label="Return Value" data={job.returnvalue} />}
|
|
|
|
{/* Failed reason */}
|
|
{job.failedReason && (
|
|
<div>
|
|
<p className="text-xs font-medium text-red-400 mb-1 uppercase tracking-wider">Error</p>
|
|
<div className="bg-red-400/5 border border-red-400/20 rounded p-2">
|
|
<pre className="text-xs text-red-300 whitespace-pre-wrap font-mono">{job.failedReason}</pre>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
/* ── Raw JSON expandable block ── */
|
|
|
|
function RawDataBlock({ label, data }: { label: string; data: unknown }) {
|
|
const [open, setOpen] = useState(false);
|
|
const text = JSON.stringify(data, null, 2);
|
|
|
|
return (
|
|
<div>
|
|
<button
|
|
onClick={() => setOpen((p) => !p)}
|
|
className="flex items-center gap-1 text-xs font-medium text-muted uppercase tracking-wider hover:text-text transition-colors"
|
|
>
|
|
{open ? <ChevronDown className="w-3 h-3" /> : <ChevronRight className="w-3 h-3" />}
|
|
{label}
|
|
</button>
|
|
{open && (
|
|
<div className="relative mt-1">
|
|
<pre className="text-xs text-muted bg-bg1/50 border border-border/70 rounded p-2 overflow-x-auto max-h-48 font-mono">
|
|
{text}
|
|
</pre>
|
|
<button
|
|
onClick={() => navigator.clipboard.writeText(text)}
|
|
className="absolute top-1 right-1 p-1 rounded text-muted hover:text-text hover:bg-surface0 transition-colors"
|
|
title="Copy to clipboard"
|
|
>
|
|
<Copy className="w-3.5 h-3.5" />
|
|
</button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
/* ─────────────────────────────────────────── Filter bar ──────────────────────────────────────────── */
|
|
|
|
function FilterBar({
|
|
filters,
|
|
onChange,
|
|
totalCount,
|
|
filteredCount,
|
|
}: {
|
|
filters: Filters;
|
|
onChange: (f: Filters) => void;
|
|
totalCount: number;
|
|
filteredCount: number;
|
|
}) {
|
|
const setStatus = (status: Filters['status']) => onChange({ ...filters, status });
|
|
const setType = (type: string) => onChange({ ...filters, type });
|
|
|
|
const hasActiveFilters = filters.status !== 'all' || filters.type !== 'all';
|
|
const statusOptions: { value: Filters['status']; label: string }[] = [
|
|
{ value: 'all', label: 'All' },
|
|
{ value: 'completed', label: 'Completed' },
|
|
{ value: 'failed', label: 'Failed' },
|
|
{ value: 'running', label: 'Running' },
|
|
];
|
|
const typeOptions = JOB_TYPES;
|
|
|
|
return (
|
|
<div className="flex items-center gap-3 flex-wrap">
|
|
{/* Status pills */}
|
|
<div className="flex items-center gap-1 bg-surface0 border border-border rounded-lg p-0.5">
|
|
{statusOptions.map((opt) => (
|
|
<button
|
|
key={opt.value}
|
|
onClick={() => setStatus(opt.value)}
|
|
className={`px-2.5 py-1 text-xs font-medium rounded-md transition-colors ${
|
|
filters.status === opt.value
|
|
? 'bg-accent/15 text-accent'
|
|
: 'text-muted hover:text-text'
|
|
}`}
|
|
>
|
|
{opt.label}
|
|
</button>
|
|
))}
|
|
</div>
|
|
|
|
{/* Type dropdown */}
|
|
<div className="relative">
|
|
<select
|
|
value={filters.type}
|
|
onChange={(e) => setType(e.target.value)}
|
|
className="appearance-none bg-surface0 border border-border rounded-lg px-3 py-1.5 pr-8 text-xs font-medium text-text cursor-pointer focus:outline-none focus:border-accent/50"
|
|
>
|
|
<option value="all">All Types</option>
|
|
{typeOptions.map((t) => (
|
|
<option key={t} value={t}>{JOB_LABELS[t] || t}</option>
|
|
))}
|
|
</select>
|
|
<Filter className="pointer-events-none absolute right-2 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-muted" />
|
|
</div>
|
|
|
|
{/* Result count */}
|
|
<span className="text-xs text-muted">
|
|
{filteredCount} / {totalCount} jobs
|
|
</span>
|
|
|
|
{/* Clear filters */}
|
|
{hasActiveFilters && (
|
|
<button
|
|
onClick={() => onChange(INITIAL_FILTERS)}
|
|
className="flex items-center gap-1 text-xs text-muted hover:text-text transition-colors"
|
|
>
|
|
<X className="w-3 h-3" />
|
|
Clear
|
|
</button>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
/** Human-readable one-liner for a failed admin request. */
|
|
function describeError(err: unknown): string {
|
|
const status = (err as { response?: { status?: number } })?.response?.status;
|
|
if (status === 401 || status === 403) {
|
|
return `Request rejected (HTTP ${status}) — the admin API key is missing or wrong.`;
|
|
}
|
|
if (status) return `Request failed with HTTP ${status}.`;
|
|
const message = err instanceof Error ? err.message : String(err);
|
|
return message || 'Unknown error.';
|
|
}
|
|
|
|
/* ─────────────────────────────────────────── Page ────────────────────────────────────────────────── */
|
|
|
|
export default function JobsPage() {
|
|
const [autoRefresh, setAutoRefresh] = useState(true);
|
|
const [selectedTab, setSelectedTab] = useState<'overview' | 'history'>('overview');
|
|
const [filters, setFilters] = useState<Filters>(INITIAL_FILTERS);
|
|
const [expandedIds, setExpandedIds] = useState<Set<string>>(new Set());
|
|
|
|
const statsQ = useQuery({
|
|
queryKey: ['queueStats'],
|
|
queryFn: () => jobsService.getQueueStats(),
|
|
// Stop polling once the endpoint is failing — otherwise a permission or
|
|
// outage error is retried silently every 3s forever.
|
|
refetchInterval: (query) => (autoRefresh && !query.state.error ? 3000 : false),
|
|
staleTime: 1000,
|
|
});
|
|
|
|
const historyQ = useQuery({
|
|
queryKey: ['jobHistory'],
|
|
queryFn: () => jobsService.getJobHistory(200),
|
|
refetchInterval: (query) => (autoRefresh && !query.state.error ? 5000 : false),
|
|
staleTime: 2000,
|
|
});
|
|
|
|
const { data: stats, refetch: refetchStats } = statsQ;
|
|
const { data: history, refetch: refetchHistory } = historyQ;
|
|
const loadError = statsQ.error ?? historyQ.error;
|
|
|
|
useEffect(() => {
|
|
if (selectedTab === 'overview') refetchStats();
|
|
else refetchHistory();
|
|
}, [selectedTab, refetchStats, refetchHistory]);
|
|
|
|
const toggleExpanded = useCallback((id: string) => {
|
|
setExpandedIds((prev) => {
|
|
const next = new Set(prev);
|
|
if (next.has(id)) next.delete(id);
|
|
else next.add(id);
|
|
return next;
|
|
});
|
|
}, []);
|
|
|
|
// Filtered & sorted history
|
|
const filteredHistory = useMemo(() => {
|
|
if (!history) return [];
|
|
return history
|
|
.filter((job) => {
|
|
if (filters.status !== 'all' && getJobStatus(job) !== filters.status) return false;
|
|
if (filters.type !== 'all' && job.name !== filters.type) return false;
|
|
return true;
|
|
});
|
|
}, [history, filters]);
|
|
|
|
return (
|
|
<div className="flex-1 flex flex-col overflow-hidden">
|
|
{/* ── Header ── */}
|
|
<div className="flex items-center justify-between p-4 border-b border-border">
|
|
<div>
|
|
<h1 className="text-xl font-semibold">Jobs</h1>
|
|
<p className="text-sm text-muted">Background task queue monitoring</p>
|
|
</div>
|
|
<div className="flex items-center gap-3">
|
|
<button
|
|
onClick={() => { refetchStats(); refetchHistory(); }}
|
|
className="flex items-center gap-1.5 text-xs text-muted hover:text-text transition-colors"
|
|
title="Refresh now"
|
|
>
|
|
<RefreshCw className="w-3.5 h-3.5" />
|
|
Refresh
|
|
</button>
|
|
<label className="flex items-center gap-2 text-sm">
|
|
<input
|
|
type="checkbox"
|
|
checked={autoRefresh}
|
|
onChange={(e) => setAutoRefresh(e.target.checked)}
|
|
className="w-4 h-4 accent-accent"
|
|
/>
|
|
Auto-refresh
|
|
</label>
|
|
</div>
|
|
</div>
|
|
|
|
{/* ── Tabs ── */}
|
|
<div className="flex border-b border-border px-4">
|
|
<button
|
|
onClick={() => setSelectedTab('overview')}
|
|
className={`px-4 py-2 text-sm font-medium border-b-2 transition-colors ${
|
|
selectedTab === 'overview' ? 'border-accent text-text' : 'border-transparent text-muted hover:text-text'
|
|
}`}
|
|
>
|
|
Overview
|
|
</button>
|
|
<button
|
|
onClick={() => setSelectedTab('history')}
|
|
className={`px-4 py-2 text-sm font-medium border-b-2 transition-colors ${
|
|
selectedTab === 'history' ? 'border-accent text-text' : 'border-transparent text-muted hover:text-text'
|
|
}`}
|
|
>
|
|
History
|
|
</button>
|
|
</div>
|
|
|
|
{/* ── Content ── */}
|
|
<div className="flex-1 overflow-y-auto p-4 space-y-4">
|
|
{/* ── Error banner ── */}
|
|
{loadError && (
|
|
<div className="flex items-start gap-2 rounded-md border border-red-500/40 bg-red-500/10 px-3 py-2 text-sm text-red-300">
|
|
<AlertCircle className="w-4 h-4 mt-0.5 shrink-0" />
|
|
<div className="space-y-1">
|
|
<p className="font-medium">Could not load job data</p>
|
|
<p className="text-xs opacity-80">{describeError(loadError)}</p>
|
|
<button
|
|
onClick={() => { refetchStats(); refetchHistory(); }}
|
|
className="text-xs underline hover:no-underline"
|
|
>
|
|
Retry
|
|
</button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* ── Overview tab ── */}
|
|
{selectedTab === 'overview' && !stats && !loadError && (
|
|
<div className="text-center py-16 text-muted text-sm">Loading queue stats…</div>
|
|
)}
|
|
{selectedTab === 'overview' && stats && (
|
|
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-6 gap-4">
|
|
<StatCard icon={Clock} label="Waiting" value={stats.waiting} color="#eab308" />
|
|
<StatCard icon={Play} label="Active" value={stats.active} color="#a78bfa" />
|
|
<StatCard icon={CheckCircle} label="Completed" value={stats.completed} color="#22c55e" />
|
|
<StatCard icon={AlertCircle} label="Failed" value={stats.failed} color="#f87171" />
|
|
<StatCard icon={RotateCcw} label="Delayed" value={stats.delayed} color="#60a5fa" />
|
|
<StatCard icon={Pause} label="Paused" value={stats.paused} color="#6b7280" />
|
|
</div>
|
|
)}
|
|
|
|
{/* ── History tab ── */}
|
|
{selectedTab === 'history' && (
|
|
<>
|
|
{/* Filter bar */}
|
|
{history && history.length > 0 && (
|
|
<FilterBar
|
|
filters={filters}
|
|
onChange={setFilters}
|
|
totalCount={history.length}
|
|
filteredCount={filteredHistory.length}
|
|
/>
|
|
)}
|
|
|
|
{/* Job list */}
|
|
{filteredHistory.length === 0 ? (
|
|
<div className="text-center py-16 text-muted">
|
|
<Terminal className="w-12 h-12 mx-auto mb-4 opacity-30" />
|
|
<p className="text-sm font-medium">
|
|
{history && history.length > 0
|
|
? 'No jobs match the current filters'
|
|
: 'No job history yet'}
|
|
</p>
|
|
{(history && history.length > 0) && (
|
|
<button
|
|
onClick={() => setFilters(INITIAL_FILTERS)}
|
|
className="mt-2 text-xs text-accent hover:underline"
|
|
>
|
|
Clear filters
|
|
</button>
|
|
)}
|
|
</div>
|
|
) : (
|
|
<div className="space-y-2">
|
|
{filteredHistory.map((job) => (
|
|
<JobRow
|
|
key={job.id}
|
|
job={job}
|
|
isExpanded={expandedIds.has(job.id)}
|
|
onToggle={() => toggleExpanded(job.id)}
|
|
/>
|
|
))}
|
|
</div>
|
|
)}
|
|
</>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
} |