184 lines
7.2 KiB
TypeScript
184 lines
7.2 KiB
TypeScript
|
|
// app/tasks/TaskDetail.tsx
|
|||
|
|
// Expanded panel for a single task. Shows log, result, error. Has
|
|||
|
|
// retry, cancel, and delete buttons.
|
|||
|
|
|
|||
|
|
"use client";
|
|||
|
|
|
|||
|
|
import { useState } from "react";
|
|||
|
|
import { TASK_TYPE_META, STATUS_LABEL, PRIORITY_LABEL } from "./columns";
|
|||
|
|
import { type Task, retryTask, cancelTask, deleteTask } from "./api";
|
|||
|
|
|
|||
|
|
interface TaskDetailProps {
|
|||
|
|
task: Task;
|
|||
|
|
onClose: () => void;
|
|||
|
|
onMutated: () => void;
|
|||
|
|
onToast: (kind: "info" | "success" | "error" | "warn", title: string, body?: string) => void;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
export default function TaskDetail({ task, onClose, onMutated, onToast }: TaskDetailProps) {
|
|||
|
|
const [busy, setBusy] = useState<string | null>(null);
|
|||
|
|
const meta = TASK_TYPE_META[task.task_type];
|
|||
|
|
|
|||
|
|
async function act(label: string, fn: () => Promise<any>) {
|
|||
|
|
setBusy(label);
|
|||
|
|
try {
|
|||
|
|
await fn();
|
|||
|
|
onMutated();
|
|||
|
|
onToast("success", `${label} done`);
|
|||
|
|
} catch (err: any) {
|
|||
|
|
onToast("error", `${label} failed`, err.message);
|
|||
|
|
} finally {
|
|||
|
|
setBusy(null);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
return (
|
|||
|
|
<div
|
|||
|
|
className="fixed inset-0 z-40 bg-black/40 flex items-start justify-center pt-12 px-4 overflow-y-auto"
|
|||
|
|
onClick={onClose}
|
|||
|
|
role="dialog"
|
|||
|
|
aria-modal="true"
|
|||
|
|
>
|
|||
|
|
<div
|
|||
|
|
className="bg-surface rounded-lg border border-border shadow-xl w-full max-w-2xl"
|
|||
|
|
onClick={(e) => e.stopPropagation()}
|
|||
|
|
>
|
|||
|
|
<div className="p-5 space-y-4">
|
|||
|
|
<div className="flex items-start justify-between gap-3">
|
|||
|
|
<div className="flex-1 min-w-0">
|
|||
|
|
<div className="flex flex-wrap items-center gap-2 mb-2">
|
|||
|
|
<span className={`text-[10px] font-semibold uppercase tracking-wider px-1.5 py-0.5 rounded border ${meta.tone}`}>
|
|||
|
|
{meta.icon} {meta.label}
|
|||
|
|
</span>
|
|||
|
|
<span className="text-[10px] font-semibold uppercase tracking-wider px-1.5 py-0.5 rounded border bg-blue-50 text-blue-700 border-blue-200">
|
|||
|
|
{STATUS_LABEL[task.status]}
|
|||
|
|
</span>
|
|||
|
|
<span className="text-[10px] font-semibold uppercase tracking-wider px-1.5 py-0.5 rounded border bg-amber-50 text-amber-800 border-amber-200">
|
|||
|
|
{PRIORITY_LABEL[task.priority]}
|
|||
|
|
</span>
|
|||
|
|
{task.retry_count > 0 ? (
|
|||
|
|
<span className="text-[10px] font-semibold uppercase tracking-wider px-1.5 py-0.5 rounded border bg-orange-50 text-orange-800 border-orange-300">
|
|||
|
|
retry ×{task.retry_count}
|
|||
|
|
</span>
|
|||
|
|
) : null}
|
|||
|
|
</div>
|
|||
|
|
<h2 className="text-xl font-bold text-fg leading-tight">{task.title}</h2>
|
|||
|
|
{task.description ? (
|
|||
|
|
<p className="text-sm text-muted mt-2 whitespace-pre-wrap">{task.description}</p>
|
|||
|
|
) : null}
|
|||
|
|
</div>
|
|||
|
|
<button
|
|||
|
|
type="button"
|
|||
|
|
onClick={onClose}
|
|||
|
|
className="text-muted hover:text-fg text-sm"
|
|||
|
|
aria-label="Close"
|
|||
|
|
>
|
|||
|
|
✕
|
|||
|
|
</button>
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
<div className="grid grid-cols-2 gap-3 text-xs">
|
|||
|
|
<Meta label="Assignee" value={task.assignee ?? "—"} />
|
|||
|
|
<Meta label="Claimed by" value={task.claimed_by ?? "—"} />
|
|||
|
|
<Meta label="Created" value={formatTime(task.created)} />
|
|||
|
|
<Meta label="Claimed at" value={formatTime(task.claimed_at)} />
|
|||
|
|
<Meta label="Started" value={formatTime(task.started_at)} />
|
|||
|
|
<Meta label="Completed" value={formatTime(task.completed_at)} />
|
|||
|
|
<Meta label="Duration" value={task.duration_ms != null ? `${(task.duration_ms / 1000).toFixed(2)}s` : "—"} />
|
|||
|
|
<Meta label="Created by" value={task.created_by ?? "—"} />
|
|||
|
|
</div>
|
|||
|
|
|
|||
|
|
{task.payload && Object.keys(task.payload as object).length > 0 ? (
|
|||
|
|
<Section title="Payload">
|
|||
|
|
<pre className="text-xs font-mono bg-bg p-3 rounded border border-border overflow-x-auto">
|
|||
|
|
{JSON.stringify(task.payload, null, 2)}
|
|||
|
|
</pre>
|
|||
|
|
</Section>
|
|||
|
|
) : null}
|
|||
|
|
|
|||
|
|
{task.error ? (
|
|||
|
|
<Section title="Error">
|
|||
|
|
<p className="text-xs text-red-700 bg-red-50 border border-red-300 p-3 rounded">
|
|||
|
|
{task.error}
|
|||
|
|
</p>
|
|||
|
|
</Section>
|
|||
|
|
) : null}
|
|||
|
|
|
|||
|
|
{task.log && task.log.length > 0 ? (
|
|||
|
|
<Section title={`Log (${task.log.length} entries)`}>
|
|||
|
|
<pre className="text-[11px] font-mono bg-bg p-3 rounded border border-border overflow-x-auto whitespace-pre-wrap max-h-60 overflow-y-auto">
|
|||
|
|
{task.log.join("\n")}
|
|||
|
|
</pre>
|
|||
|
|
</Section>
|
|||
|
|
) : null}
|
|||
|
|
|
|||
|
|
{task.result ? (
|
|||
|
|
<Section title="Result">
|
|||
|
|
<pre className="text-xs font-mono bg-bg p-3 rounded border border-border overflow-x-auto max-h-60 overflow-y-auto">
|
|||
|
|
{JSON.stringify(task.result, null, 2)}
|
|||
|
|
</pre>
|
|||
|
|
</Section>
|
|||
|
|
) : null}
|
|||
|
|
|
|||
|
|
<div className="flex flex-wrap gap-2 pt-2 border-t border-border">
|
|||
|
|
{(task.status === "failed" || task.status === "done") && (
|
|||
|
|
<button
|
|||
|
|
onClick={() => act("Retry", () => retryTask(task.id))}
|
|||
|
|
disabled={busy !== null}
|
|||
|
|
className="px-3 py-1.5 rounded-md bg-blue-600 text-white text-xs font-semibold hover:bg-blue-700 disabled:opacity-50"
|
|||
|
|
>
|
|||
|
|
{busy === "Retry" ? "Retrying…" : "↻ Retry"}
|
|||
|
|
</button>
|
|||
|
|
)}
|
|||
|
|
{task.status === "in_progress" && (
|
|||
|
|
<button
|
|||
|
|
onClick={() => act("Cancel", () => cancelTask(task.id))}
|
|||
|
|
disabled={busy !== null}
|
|||
|
|
className="px-3 py-1.5 rounded-md bg-amber-500 text-white text-xs font-semibold hover:bg-amber-600 disabled:opacity-50"
|
|||
|
|
>
|
|||
|
|
{busy === "Cancel" ? "Cancelling…" : "✕ Cancel"}
|
|||
|
|
</button>
|
|||
|
|
)}
|
|||
|
|
<button
|
|||
|
|
onClick={() => {
|
|||
|
|
if (confirm(`Delete "${task.title}"? This cannot be undone.`)) {
|
|||
|
|
act("Delete", () => deleteTask(task.id).then(onClose));
|
|||
|
|
}
|
|||
|
|
}}
|
|||
|
|
disabled={busy !== null}
|
|||
|
|
className="px-3 py-1.5 rounded-md border border-red-300 text-red-700 text-xs font-semibold hover:bg-red-50 disabled:opacity-50"
|
|||
|
|
>
|
|||
|
|
{busy === "Delete" ? "Deleting…" : "🗑 Delete"}
|
|||
|
|
</button>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
</div>
|
|||
|
|
);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function Meta({ label, value }: { label: string; value: string }) {
|
|||
|
|
return (
|
|||
|
|
<div>
|
|||
|
|
<p className="text-[10px] font-semibold text-muted uppercase tracking-wider mb-0.5">{label}</p>
|
|||
|
|
<p className="text-fg">{value}</p>
|
|||
|
|
</div>
|
|||
|
|
);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function Section({ title, children }: { title: string; children: React.ReactNode }) {
|
|||
|
|
return (
|
|||
|
|
<div>
|
|||
|
|
<h3 className="text-xs font-semibold text-muted uppercase tracking-wider mb-1.5">{title}</h3>
|
|||
|
|
{children}
|
|||
|
|
</div>
|
|||
|
|
);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function formatTime(iso: string | null): string {
|
|||
|
|
if (!iso) return "—";
|
|||
|
|
const t = new Date(iso.includes("T") ? iso : iso.replace(" ", "T") + "Z");
|
|||
|
|
if (isNaN(t.getTime())) return "—";
|
|||
|
|
return t.toLocaleString();
|
|||
|
|
}
|