refactor(admin): redesign task panel UI with Card layout and running indicator

Replace the bordered div layout in AdminTasksPanel with the Card component for
improved visual hierarchy. Add a dynamic running indicator using a custom
RunningDot component to clearly show active tasks. Update control alignment and
button states for better usability. Remove Badge-based status display in favor
of a more streamlined appearance.
This commit is contained in:
Richard R 2026-06-07 10:37:30 -06:00
parent fa4dcf5a7d
commit fdda0df301

View file

@ -3,8 +3,8 @@
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'; import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import toast from 'react-hot-toast'; import toast from 'react-hot-toast';
import { Badge, Button, Input, Section, Switch } from '@/components/ui'; import { Button, Card, Input, Section, Switch } from '@/components/ui';
import type { BadgeTone } from '@/components/ui/badge'; import { ClockIcon, RefreshIcon } from '@/components/icons/Icons';
type TaskRunStatus = 'idle' | 'running' | 'ok' | 'error'; type TaskRunStatus = 'idle' | 'running' | 'ok' | 'error';
@ -31,12 +31,14 @@ async function fetchTasks(): Promise<TaskView[]> {
return ((await res.json()) as { tasks: TaskView[] }).tasks; return ((await res.json()) as { tasks: TaskView[] }).tasks;
} }
const STATUS_TONE: Record<TaskRunStatus, BadgeTone> = { function RunningDot() {
idle: 'muted', return (
running: 'accent', <span className="relative flex size-2 shrink-0 items-center justify-center" title="Running" aria-label="Running">
ok: 'foreground', <span className="absolute inline-flex size-2 animate-ping rounded-full bg-accent opacity-60" />
error: 'danger', <span className="relative inline-flex size-2 rounded-full bg-accent" />
}; </span>
);
}
function formatRelative(ms: number | null): string { function formatRelative(ms: number | null): string {
if (ms == null) return 'never'; if (ms == null) return 'never';
@ -115,6 +117,7 @@ export function AdminTasksPanel() {
key={task.key} key={task.key}
task={task} task={task}
busy={patchTask.isPending || runTask.isPending} busy={patchTask.isPending || runTask.isPending}
runPending={runTask.isPending && runTask.variables === task.key}
onToggle={(enabled) => patchTask.mutate({ key: task.key, patch: { enabled } })} onToggle={(enabled) => patchTask.mutate({ key: task.key, patch: { enabled } })}
onSaveInterval={(intervalMs) => patchTask.mutate({ key: task.key, patch: { intervalMs } })} onSaveInterval={(intervalMs) => patchTask.mutate({ key: task.key, patch: { intervalMs } })}
onRun={() => runTask.mutate(task.key)} onRun={() => runTask.mutate(task.key)}
@ -131,12 +134,14 @@ export function AdminTasksPanel() {
function TaskRow({ function TaskRow({
task, task,
busy, busy,
runPending,
onToggle, onToggle,
onSaveInterval, onSaveInterval,
onRun, onRun,
}: { }: {
task: TaskView; task: TaskView;
busy: boolean; busy: boolean;
runPending: boolean;
onToggle: (enabled: boolean) => void; onToggle: (enabled: boolean) => void;
onSaveInterval: (intervalMs: number) => void; onSaveInterval: (intervalMs: number) => void;
onRun: () => void; onRun: () => void;
@ -152,60 +157,73 @@ function TaskRow({
const intervalDirty = const intervalDirty =
Number.isFinite(parsedMinutes) && parsedMinutes > 0 && newIntervalMs !== task.intervalMs; Number.isFinite(parsedMinutes) && parsedMinutes > 0 && newIntervalMs !== task.intervalMs;
const running = task.running || runPending;
return ( return (
<div className="rounded-lg border border-line bg-surface px-3 py-2 space-y-2"> <Card>
<div className="space-y-2">
<div className="flex items-start justify-between gap-3"> <div className="flex items-start justify-between gap-3">
<div className="min-w-0"> <div className="min-w-0">
<div className="flex items-center gap-2"> <div className="flex items-center gap-1.5">
<span className="text-sm font-medium text-foreground truncate">{task.name}</span> {running && <RunningDot />}
<Badge tone={STATUS_TONE[task.lastStatus]}>{task.lastStatus}</Badge> <span className="truncate text-sm font-medium text-foreground">{task.name}</span>
</div> </div>
{task.description && <p className="text-xs text-soft mt-0.5">{task.description}</p>} {task.description && <p className="mt-0.5 text-xs text-soft">{task.description}</p>}
</div> </div>
<div className="flex items-center gap-2 shrink-0"> <div className="flex shrink-0 items-center gap-2">
<Switch checked={task.enabled} onChange={onToggle} ariaLabel={`Enable ${task.name}`} disabled={busy} /> <Switch checked={task.enabled} onChange={onToggle} ariaLabel={`Enable ${task.name}`} disabled={busy} />
<Button variant="outline" size="sm" onClick={onRun} disabled={busy || task.running}> <Button variant="outline" size="sm" onClick={onRun} disabled={busy || running}>
{task.running ? 'Running…' : 'Run now'} {running ? 'Running…' : 'Run now'}
</Button> </Button>
</div> </div>
</div> </div>
<div className="flex flex-wrap items-center gap-x-4 gap-y-1 text-xs text-soft"> <div className="flex flex-wrap items-center justify-between gap-x-4 gap-y-1.5">
<span>Last run: {formatRelative(task.lastRunAt)}</span> <div className="flex flex-wrap items-center gap-x-3 gap-y-1 text-xs text-soft">
<span>Duration: {formatDuration(task.lastDurationMs)}</span> <span
<span>Next run: {task.enabled ? formatRelative(task.nextRunAt) : 'disabled'}</span> className="inline-flex items-center gap-1"
<span className="flex items-center gap-1"> title={`Ran in ${formatDuration(task.lastDurationMs)}`}
Every >
<ClockIcon className="size-3 text-faint" />
{formatRelative(task.lastRunAt)}
</span>
{task.enabled && task.nextRunAt != null && (
<span className="inline-flex items-center gap-1 text-faint">
<RefreshIcon className="size-3" />
next {formatRelative(task.nextRunAt)}
</span>
)}
</div>
<div className="flex items-center gap-1.5 text-xs text-faint">
<span>Every</span>
<Input <Input
type="number" type="number"
min={0.001} min={0.001}
step="any" step="any"
controlSize="sm" controlSize="sm"
className="w-16" className="w-14 text-center"
value={minutes} value={minutes}
onChange={(e) => setMinutes(e.target.value)} onChange={(e) => setMinutes(e.target.value)}
aria-label={`${task.name} interval in minutes`} aria-label={`${task.name} interval in minutes`}
/> />
min <span>min</span>
{intervalDirty && ( {intervalDirty && (
<Button <Button variant="primary" size="xs" onClick={() => onSaveInterval(newIntervalMs)} disabled={busy}>
variant="primary"
size="xs"
onClick={() => onSaveInterval(newIntervalMs)}
disabled={busy}
>
Save Save
</Button> </Button>
)} )}
</span> </div>
</div> </div>
{task.lastStatus === 'error' && task.lastError && ( {task.lastStatus === 'error' && task.lastError ? (
<p className="text-xs text-danger truncate" title={task.lastError}>{task.lastError}</p> <p className="truncate text-xs text-danger" title={task.lastError}>{task.lastError}</p>
)} ) : (
{task.lastStatus === 'ok' && task.lastResult && ( task.lastStatus === 'ok' && task.lastResult && (
<p className="text-xs text-soft truncate" title={task.lastResult}>{task.lastResult}</p> <p className="truncate text-xs text-faint" title={task.lastResult}>{task.lastResult}</p>
)
)} )}
</div> </div>
</Card>
); );
} }