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:
parent
fa4dcf5a7d
commit
fdda0df301
1 changed files with 77 additions and 59 deletions
|
|
@ -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="flex items-start justify-between gap-3">
|
<div className="space-y-2">
|
||||||
<div className="min-w-0">
|
<div className="flex items-start justify-between gap-3">
|
||||||
<div className="flex items-center gap-2">
|
<div className="min-w-0">
|
||||||
<span className="text-sm font-medium text-foreground truncate">{task.name}</span>
|
<div className="flex items-center gap-1.5">
|
||||||
<Badge tone={STATUS_TONE[task.lastStatus]}>{task.lastStatus}</Badge>
|
{running && <RunningDot />}
|
||||||
|
<span className="truncate text-sm font-medium text-foreground">{task.name}</span>
|
||||||
|
</div>
|
||||||
|
{task.description && <p className="mt-0.5 text-xs text-soft">{task.description}</p>}
|
||||||
</div>
|
</div>
|
||||||
{task.description && <p className="text-xs text-soft mt-0.5">{task.description}</p>}
|
<div className="flex shrink-0 items-center gap-2">
|
||||||
</div>
|
<Switch checked={task.enabled} onChange={onToggle} ariaLabel={`Enable ${task.name}`} disabled={busy} />
|
||||||
<div className="flex items-center gap-2 shrink-0">
|
<Button variant="outline" size="sm" onClick={onRun} disabled={busy || running}>
|
||||||
<Switch checked={task.enabled} onChange={onToggle} ariaLabel={`Enable ${task.name}`} disabled={busy} />
|
{running ? 'Running…' : 'Run now'}
|
||||||
<Button variant="outline" size="sm" onClick={onRun} disabled={busy || task.running}>
|
|
||||||
{task.running ? 'Running…' : 'Run now'}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex flex-wrap items-center gap-x-4 gap-y-1 text-xs text-soft">
|
|
||||||
<span>Last run: {formatRelative(task.lastRunAt)}</span>
|
|
||||||
<span>Duration: {formatDuration(task.lastDurationMs)}</span>
|
|
||||||
<span>Next run: {task.enabled ? formatRelative(task.nextRunAt) : 'disabled'}</span>
|
|
||||||
<span className="flex items-center gap-1">
|
|
||||||
Every
|
|
||||||
<Input
|
|
||||||
type="number"
|
|
||||||
min={0.001}
|
|
||||||
step="any"
|
|
||||||
controlSize="sm"
|
|
||||||
className="w-16"
|
|
||||||
value={minutes}
|
|
||||||
onChange={(e) => setMinutes(e.target.value)}
|
|
||||||
aria-label={`${task.name} interval in minutes`}
|
|
||||||
/>
|
|
||||||
min
|
|
||||||
{intervalDirty && (
|
|
||||||
<Button
|
|
||||||
variant="primary"
|
|
||||||
size="xs"
|
|
||||||
onClick={() => onSaveInterval(newIntervalMs)}
|
|
||||||
disabled={busy}
|
|
||||||
>
|
|
||||||
Save
|
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
</div>
|
||||||
</span>
|
</div>
|
||||||
</div>
|
|
||||||
|
|
||||||
{task.lastStatus === 'error' && task.lastError && (
|
<div className="flex flex-wrap items-center justify-between gap-x-4 gap-y-1.5">
|
||||||
<p className="text-xs text-danger truncate" title={task.lastError}>{task.lastError}</p>
|
<div className="flex flex-wrap items-center gap-x-3 gap-y-1 text-xs text-soft">
|
||||||
)}
|
<span
|
||||||
{task.lastStatus === 'ok' && task.lastResult && (
|
className="inline-flex items-center gap-1"
|
||||||
<p className="text-xs text-soft truncate" title={task.lastResult}>{task.lastResult}</p>
|
title={`Ran in ${formatDuration(task.lastDurationMs)}`}
|
||||||
)}
|
>
|
||||||
</div>
|
<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
|
||||||
|
type="number"
|
||||||
|
min={0.001}
|
||||||
|
step="any"
|
||||||
|
controlSize="sm"
|
||||||
|
className="w-14 text-center"
|
||||||
|
value={minutes}
|
||||||
|
onChange={(e) => setMinutes(e.target.value)}
|
||||||
|
aria-label={`${task.name} interval in minutes`}
|
||||||
|
/>
|
||||||
|
<span>min</span>
|
||||||
|
{intervalDirty && (
|
||||||
|
<Button variant="primary" size="xs" onClick={() => onSaveInterval(newIntervalMs)} disabled={busy}>
|
||||||
|
Save
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{task.lastStatus === 'error' && task.lastError ? (
|
||||||
|
<p className="truncate text-xs text-danger" title={task.lastError}>{task.lastError}</p>
|
||||||
|
) : (
|
||||||
|
task.lastStatus === 'ok' && task.lastResult && (
|
||||||
|
<p className="truncate text-xs text-faint" title={task.lastResult}>{task.lastResult}</p>
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue