feat(awooop): add run detail action panel
This commit is contained in:
@@ -12,12 +12,17 @@ import {
|
||||
Activity,
|
||||
AlertCircle,
|
||||
ArrowLeft,
|
||||
ArrowRight,
|
||||
CheckCircle2,
|
||||
Clock,
|
||||
MessageSquareText,
|
||||
RefreshCw,
|
||||
Route,
|
||||
SearchCheck,
|
||||
Send,
|
||||
ShieldAlert,
|
||||
ShieldCheck,
|
||||
TriangleAlert,
|
||||
Wrench,
|
||||
} from "lucide-react";
|
||||
|
||||
@@ -102,6 +107,8 @@ const STATUS_TRANSLATION_KEYS: Record<string, string> = {
|
||||
waiting_approval: "statuses.waitingApproval",
|
||||
};
|
||||
|
||||
const MANUAL_STATES = new Set(["blocked", "cancelled", "error", "failed", "timeout"]);
|
||||
|
||||
function formatTime(value: string | null | undefined, locale: string, emptyLabel: string) {
|
||||
if (!value) return emptyLabel;
|
||||
return new Date(value).toLocaleString(locale === "zh-TW" ? "zh-TW" : "en-US", {
|
||||
@@ -125,6 +132,112 @@ function itemIcon(kind: string) {
|
||||
return Route;
|
||||
}
|
||||
|
||||
function RunActionPanel({
|
||||
run,
|
||||
counts,
|
||||
emptyLabel,
|
||||
}: {
|
||||
run?: RunDetail;
|
||||
counts?: RunDetailResponse["counts"];
|
||||
emptyLabel: string;
|
||||
}) {
|
||||
const t = useTranslations("awooop.runDetail.action");
|
||||
|
||||
if (!run) {
|
||||
return (
|
||||
<section className="border border-[#e0ddd4] bg-white p-4">
|
||||
<div className="h-5 w-40 animate-pulse bg-[#f2efe6]" />
|
||||
<div className="mt-3 h-4 w-full max-w-xl animate-pulse bg-[#f2efe6]" />
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
const isApproval = run.state === "waiting_approval";
|
||||
const isManual = MANUAL_STATES.has(run.state);
|
||||
const isCompleted = run.state === "completed" || run.state === "success";
|
||||
const actionKey = isApproval
|
||||
? "approval"
|
||||
: isManual
|
||||
? "manual"
|
||||
: isCompleted
|
||||
? "completed"
|
||||
: run.state === "running"
|
||||
? "running"
|
||||
: "observe";
|
||||
const Icon = isApproval
|
||||
? ShieldCheck
|
||||
: isManual
|
||||
? TriangleAlert
|
||||
: isCompleted
|
||||
? CheckCircle2
|
||||
: run.state === "running"
|
||||
? Activity
|
||||
: SearchCheck;
|
||||
const toneClass = isApproval
|
||||
? "border-[#d9b36f] bg-[#fff7e8] text-[#8a5a08]"
|
||||
: isManual
|
||||
? "border-[#e2a29b] bg-[#fff0ef] text-[#9f2f25]"
|
||||
: isCompleted
|
||||
? "border-[#9bc7a4] bg-[#f0faf2] text-[#17602a]"
|
||||
: "border-[#9bb6d9] bg-[#eef5ff] text-[#1f5b9b]";
|
||||
const projectParam = encodeURIComponent(run.project_id);
|
||||
const primaryHref = isApproval
|
||||
? (`/awooop/approvals/${run.run_id}?project_id=${projectParam}` as never)
|
||||
: (`/awooop/runs?project_id=${projectParam}` as never);
|
||||
|
||||
const evidence = [
|
||||
{ label: t("evidence.inbound"), value: counts?.inbound_events ?? 0 },
|
||||
{ label: t("evidence.outbound"), value: counts?.outbound_messages ?? 0 },
|
||||
{ label: t("evidence.mcp"), value: counts?.mcp_calls ?? 0 },
|
||||
{ label: t("evidence.steps"), value: counts?.steps ?? 0 },
|
||||
];
|
||||
|
||||
return (
|
||||
<section className="grid gap-px border border-[#e0ddd4] bg-[#e0ddd4] lg:grid-cols-[1.1fr_0.9fr]">
|
||||
<div className="bg-white p-4">
|
||||
<div className="flex flex-wrap items-start justify-between gap-4">
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={cn("flex h-8 w-8 items-center justify-center border", toneClass)}>
|
||||
<Icon className="h-4 w-4" aria-hidden="true" />
|
||||
</span>
|
||||
<div>
|
||||
<p className="text-xs font-semibold text-[#77736a]">{t("eyebrow")}</p>
|
||||
<h3 className="text-base font-semibold text-[#141413]">
|
||||
{t(`${actionKey}.title` as never)}
|
||||
</h3>
|
||||
</div>
|
||||
</div>
|
||||
<p className="mt-3 max-w-3xl text-sm leading-6 text-[#5f5b52]">
|
||||
{t(`${actionKey}.detail` as never)}
|
||||
</p>
|
||||
</div>
|
||||
<Link
|
||||
href={primaryHref}
|
||||
className={cn(
|
||||
"inline-flex shrink-0 items-center gap-2 border px-3 py-2 text-sm font-semibold",
|
||||
toneClass
|
||||
)}
|
||||
>
|
||||
{t(`${actionKey}.primary` as never)}
|
||||
<ArrowRight className="h-4 w-4" aria-hidden="true" />
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-px bg-[#e0ddd4] md:grid-cols-4 lg:grid-cols-2 xl:grid-cols-4">
|
||||
{evidence.map((item) => (
|
||||
<div key={item.label} className="bg-white px-4 py-3">
|
||||
<p className="text-xs font-semibold text-[#77736a]">{item.label}</p>
|
||||
<p className="mt-2 font-mono text-2xl font-semibold text-[#141413]">
|
||||
{item.value ?? emptyLabel}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function DetailField({
|
||||
label,
|
||||
value,
|
||||
@@ -322,6 +435,8 @@ export default function RunDetailPage({
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<RunActionPanel run={run} counts={detail?.counts} emptyLabel={t("empty")} />
|
||||
|
||||
<section className="grid gap-4 xl:grid-cols-[360px_1fr]">
|
||||
<aside className="border border-[#e0ddd4] bg-white">
|
||||
<div className="border-b border-[#e0ddd4] bg-[#faf9f3] px-4 py-3">
|
||||
|
||||
Reference in New Issue
Block a user