feat(awooop): add run detail timeline
All checks were successful
Code Review / ai-code-review (push) Successful in 12s
CD Pipeline / tests (push) Successful in 1m18s
CD Pipeline / build-and-deploy (push) Successful in 3m58s
CD Pipeline / post-deploy-checks (push) Successful in 1m25s

This commit is contained in:
Your Name
2026-05-07 03:55:01 +08:00
parent f10ab71c52
commit 66e22e26cb
5 changed files with 635 additions and 2 deletions

View File

@@ -0,0 +1,328 @@
// =============================================================================
// WOOO AIOps - AwoooP Run Detail / Timeline
// =============================================================================
// 將 Run FSM、Channel Event、MCP Audit、出站訊息收斂成同一條處置脈絡。
"use client";
import { useCallback, useEffect, useMemo, useState } from "react";
import { useSearchParams } from "next/navigation";
import {
Activity,
AlertCircle,
ArrowLeft,
Clock,
MessageSquareText,
RefreshCw,
Route,
Send,
ShieldAlert,
Wrench,
} from "lucide-react";
import { Link } from "@/i18n/routing";
import { cn } from "@/lib/utils";
interface RunDetail {
run_id: string;
project_id: string;
agent_id: string;
state: string;
is_shadow: boolean;
trace_id?: string | null;
trigger_type?: string | null;
trigger_ref?: string | null;
cost_usd: number | string;
step_count: number;
attempt_count: number;
max_attempts: number;
error_code?: string | null;
error_detail?: string | null;
created_at: string;
started_at?: string | null;
completed_at?: string | null;
timeout_at?: string | null;
heartbeat_at?: string | null;
}
interface TimelineItem {
ts: string | null;
kind: "run" | "inbound" | "outbound" | "step" | "mcp" | string;
title: string;
status: string;
summary?: string | null;
metadata?: Record<string, unknown>;
}
interface RunDetailResponse {
run: RunDetail;
timeline: TimelineItem[];
counts: {
steps: number;
inbound_events: number;
outbound_messages: number;
mcp_calls: number;
timeline: number;
};
}
const API_BASE = process.env.NEXT_PUBLIC_API_URL ?? "";
const AUTO_REFRESH_INTERVAL = 30_000;
const STATUS_STYLE: Record<string, string> = {
completed: "border-[#9bc7a4] bg-[#f0faf2] text-[#17602a]",
success: "border-[#9bc7a4] bg-[#f0faf2] text-[#17602a]",
sent: "border-[#9bc7a4] bg-[#f0faf2] text-[#17602a]",
running: "border-[#9bb6d9] bg-[#eef5ff] text-[#1f5b9b]",
received: "border-[#9bb6d9] bg-[#eef5ff] text-[#1f5b9b]",
waiting_approval: "border-[#d9b36f] bg-[#fff7e8] text-[#8a5a08]",
pending: "border-[#d9b36f] bg-[#fff7e8] text-[#8a5a08]",
shadow: "border-[#d8d3c7] bg-[#faf9f3] text-[#5f5b52]",
failed: "border-[#e2a29b] bg-[#fff0ef] text-[#9f2f25]",
error: "border-[#e2a29b] bg-[#fff0ef] text-[#9f2f25]",
blocked: "border-[#e2a29b] bg-[#fff0ef] text-[#9f2f25]",
cancelled: "border-[#e2a29b] bg-[#fff0ef] text-[#9f2f25]",
timeout: "border-[#e2a29b] bg-[#fff0ef] text-[#9f2f25]",
};
function formatTime(value?: string | null) {
if (!value) return "--";
return new Date(value).toLocaleString("zh-TW", {
month: "2-digit",
day: "2-digit",
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
});
}
function statusClass(status: string) {
return STATUS_STYLE[status] ?? "border-[#d8d3c7] bg-white text-[#5f5b52]";
}
function itemIcon(kind: string) {
if (kind === "inbound") return MessageSquareText;
if (kind === "outbound") return Send;
if (kind === "step") return Wrench;
if (kind === "mcp") return ShieldAlert;
return Route;
}
function DetailField({ label, value }: { label: string; value?: string | number | null }) {
return (
<div className="border-b border-[#eee9dd] py-3 last:border-0">
<div className="text-xs font-semibold uppercase text-[#77736a]">{label}</div>
<div className="mt-1 break-words font-mono text-sm text-[#141413]">{value ?? "--"}</div>
</div>
);
}
function TimelineRow({ item }: { item: TimelineItem }) {
const Icon = itemIcon(item.kind);
return (
<article className="grid gap-3 border-b border-[#eee9dd] bg-white px-4 py-4 last:border-0 md:grid-cols-[132px_1fr]">
<div className="font-mono text-xs text-[#77736a]">{formatTime(item.ts)}</div>
<div className="min-w-0">
<div className="flex flex-wrap items-center gap-2">
<span className="flex h-7 w-7 items-center justify-center border border-[#d8d3c7] bg-[#faf9f3] text-[#5f5b52]">
<Icon className="h-3.5 w-3.5" aria-hidden="true" />
</span>
<h3 className="text-sm font-semibold text-[#141413]">{item.title}</h3>
<span className={cn("border px-2 py-0.5 text-xs font-semibold", statusClass(item.status))}>
{item.status}
</span>
</div>
{item.summary && (
<p className="mt-2 whitespace-pre-line break-words text-sm leading-6 text-[#5f5b52]">
{item.summary}
</p>
)}
{item.metadata && Object.keys(item.metadata).length > 0 && (
<div className="mt-3 grid gap-2 md:grid-cols-2">
{Object.entries(item.metadata).slice(0, 4).map(([key, value]) => (
<div key={key} className="border border-[#eee9dd] bg-[#faf9f3] px-3 py-2">
<div className="text-xs font-semibold text-[#77736a]">{key}</div>
<div className="mt-1 truncate font-mono text-xs text-[#141413]">
{value === null || value === undefined ? "--" : String(value)}
</div>
</div>
))}
</div>
)}
</div>
</article>
);
}
export default function RunDetailPage({
params,
}: {
params: { run_id: string };
}) {
const { run_id } = params;
const searchParams = useSearchParams();
const projectId = searchParams.get("project_id") ?? "";
const [detail, setDetail] = useState<RunDetailResponse | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [lastRefresh, setLastRefresh] = useState<Date>(new Date());
const fetchDetail = useCallback(async () => {
try {
setError(null);
const query = new URLSearchParams();
if (projectId) query.set("project_id", projectId);
const suffix = query.toString() ? `?${query.toString()}` : "";
const res = await fetch(`${API_BASE}/api/v1/platform/runs/${run_id}/detail${suffix}`);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const data: RunDetailResponse = await res.json();
setDetail(data);
setLastRefresh(new Date());
} catch (err) {
setError(err instanceof Error ? err.message : "載入失敗");
} finally {
setLoading(false);
}
}, [projectId, run_id]);
useEffect(() => {
setLoading(true);
fetchDetail();
}, [fetchDetail]);
useEffect(() => {
const timer = setInterval(fetchDetail, AUTO_REFRESH_INTERVAL);
return () => clearInterval(timer);
}, [fetchDetail]);
const run = detail?.run;
const durationText = useMemo(() => {
if (!run?.created_at) return "--";
const end = run.completed_at || run.heartbeat_at || new Date().toISOString();
const ms = Math.max(0, new Date(end).getTime() - new Date(run.created_at).getTime());
return `${Math.round(ms / 1000)}s`;
}, [run]);
return (
<div className="space-y-6">
<Link
href="/awooop/runs"
className="inline-flex items-center gap-2 text-sm text-[#77736a] hover:text-[#141413]"
>
<ArrowLeft className="h-4 w-4" aria-hidden="true" />
Run
</Link>
<div className="flex flex-wrap items-center justify-between gap-3">
<div className="flex items-center gap-3">
<Activity className="h-5 w-5 text-brand-accent" aria-hidden="true" />
<div>
<h2 className="text-lg font-semibold text-[#141413]">Run </h2>
<p className="font-mono text-xs text-[#77736a]">{run_id}</p>
</div>
</div>
<button
onClick={() => {
setLoading(true);
fetchDetail();
}}
disabled={loading}
className="inline-flex items-center gap-2 border border-[#d8d3c7] bg-white px-3 py-2 text-sm text-[#5f5b52] hover:border-[#d97757] disabled:opacity-50"
>
<RefreshCw className={cn("h-4 w-4", loading && "animate-spin")} aria-hidden="true" />
</button>
</div>
{error && (
<div className="flex items-start gap-3 border border-[#e2a29b] bg-[#fff0ef] p-4 text-[#9f2f25]">
<AlertCircle className="mt-0.5 h-5 w-5" aria-hidden="true" />
<div>
<p className="text-sm font-semibold"> Run </p>
<p className="mt-1 text-xs">{error}</p>
</div>
</div>
)}
<section className="grid gap-px border border-[#e0ddd4] bg-[#e0ddd4] md:grid-cols-4">
<div className="bg-white p-4">
<div className="text-xs font-semibold text-[#77736a]"></div>
<div className={cn("mt-3 inline-flex border px-2 py-1 text-sm font-semibold", statusClass(run?.state ?? "pending"))}>
{run?.state ?? "--"}
</div>
</div>
<div className="bg-white p-4">
<div className="text-xs font-semibold text-[#77736a]">Timeline</div>
<div className="mt-2 font-mono text-2xl font-semibold text-[#141413]">
{detail?.counts.timeline ?? 0}
</div>
</div>
<div className="bg-white p-4">
<div className="text-xs font-semibold text-[#77736a]">MCP / Steps</div>
<div className="mt-2 font-mono text-2xl font-semibold text-[#141413]">
{(detail?.counts.mcp_calls ?? 0) + (detail?.counts.steps ?? 0)}
</div>
</div>
<div className="bg-white p-4">
<div className="flex items-center gap-2 text-xs font-semibold text-[#77736a]">
<Clock className="h-3.5 w-3.5" aria-hidden="true" />
</div>
<div className="mt-2 font-mono text-2xl font-semibold text-[#141413]">{durationText}</div>
</div>
</section>
<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">
<h3 className="text-sm font-semibold text-[#141413]">Run </h3>
</div>
<div className="px-4">
<DetailField label="Project" value={run?.project_id} />
<DetailField label="Agent" value={run?.agent_id} />
<DetailField label="Trace ID" value={run?.trace_id} />
<DetailField label="Trigger" value={run?.trigger_type} />
<DetailField label="Trigger Ref" value={run?.trigger_ref} />
<DetailField label="Cost" value={run ? `$${Number(run.cost_usd ?? 0).toFixed(4)}` : "--"} />
<DetailField label="Attempts" value={run ? `${run.attempt_count}/${run.max_attempts}` : "--"} />
<DetailField label="Created" value={formatTime(run?.created_at)} />
<DetailField label="Completed" value={formatTime(run?.completed_at)} />
<DetailField label="Error" value={run?.error_detail || run?.error_code} />
</div>
</aside>
<section className="border border-[#e0ddd4] bg-white">
<div className="flex flex-wrap items-center justify-between gap-3 border-b border-[#e0ddd4] bg-[#faf9f3] px-4 py-3">
<div>
<h3 className="text-sm font-semibold text-[#141413]"></h3>
<p className="mt-1 text-xs text-[#77736a]">
{lastRefresh.toLocaleTimeString("zh-TW")}
</p>
</div>
<span className="border border-[#d8d3c7] bg-white px-2 py-0.5 text-xs font-semibold text-[#5f5b52]">
{detail?.counts.timeline ?? 0}
</span>
</div>
{loading && !detail ? (
<div className="space-y-3 p-4">
{Array.from({ length: 5 }).map((_, index) => (
<div key={index} className="h-16 animate-pulse bg-[#f2efe6]" />
))}
</div>
) : detail && detail.timeline.length > 0 ? (
<div>
{detail.timeline.map((item, index) => (
<TimelineRow key={`${item.kind}-${item.ts}-${index}`} item={item} />
))}
</div>
) : (
<div className="px-4 py-12 text-center text-sm text-[#77736a]">
</div>
)}
</section>
</section>
</div>
);
}

View File

@@ -6,6 +6,7 @@
"use client";
import { useState, useEffect, useCallback, useMemo, useRef } from "react";
import { Link } from "@/i18n/routing";
import {
Activity,
BellOff,
@@ -264,9 +265,12 @@ function RunRow({ run }: { run: Run }) {
return (
<tr className="border-b border-border hover:bg-accent/30 transition-colors">
<td className="px-4 py-3">
<span className="font-mono text-xs text-brand-accent bg-brand-accent/10 px-2 py-1 rounded border border-brand-accent/20">
<Link
href={`/awooop/runs/${run.run_id}?project_id=${encodeURIComponent(run.project_id)}` as never}
className="font-mono text-xs text-brand-accent bg-brand-accent/10 px-2 py-1 rounded border border-brand-accent/20 hover:bg-brand-accent/15"
>
{run.run_id.slice(0, 8)}
</span>
</Link>
</td>
<td className="px-4 py-3">
<span className="font-mono text-sm text-muted-foreground">