feat(awooop): add run detail timeline
This commit is contained in:
@@ -24,7 +24,14 @@ from src.core.awooop_operator_auth import (
|
||||
)
|
||||
from src.services.platform_operator_service import (
|
||||
decide_approval as decide_approval_svc,
|
||||
)
|
||||
from src.services.platform_operator_service import (
|
||||
get_run_detail as get_run_detail_svc,
|
||||
)
|
||||
from src.services.platform_operator_service import (
|
||||
list_approvals as list_approvals_svc,
|
||||
)
|
||||
from src.services.platform_operator_service import (
|
||||
list_runs as list_runs_svc,
|
||||
)
|
||||
|
||||
@@ -104,6 +111,21 @@ async def list_runs(
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/runs/{run_id}/detail",
|
||||
summary="查詢 Run 詳細時間線",
|
||||
description=(
|
||||
"返回單一 Run 的主狀態、Step Journal、MCP Gateway audit、"
|
||||
"入站 Channel Event 與出站訊息,供 Operator Console 顯示完整處置脈絡。"
|
||||
),
|
||||
)
|
||||
async def get_run_detail(
|
||||
run_id: str,
|
||||
project_id: str | None = Query(None, description="租戶 ID(可選)"),
|
||||
) -> dict[str, Any]:
|
||||
return await get_run_detail_svc(run_id=run_id, project_id=project_id)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/approvals",
|
||||
response_model=ListApprovalsResponse,
|
||||
|
||||
@@ -15,12 +15,16 @@ from uuid import UUID
|
||||
import structlog
|
||||
from fastapi import HTTPException, status
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy import or_ as sa_or
|
||||
|
||||
from src.db.awooop_models import (
|
||||
AwoooPContractRevision,
|
||||
AwoooPConversationEvent,
|
||||
AwoooPMcpGatewayAudit,
|
||||
AwoooPOutboundMessage,
|
||||
AwoooPProject,
|
||||
AwoooPRunState,
|
||||
AwoooPRunStepJournal,
|
||||
)
|
||||
from src.db.base import get_db_context
|
||||
from src.services.audit_sink import write_audit
|
||||
@@ -33,6 +37,7 @@ _MAX_CONTRACTS = 200
|
||||
_DEFAULT_PER_PAGE = 50
|
||||
_MAX_PER_PAGE = 200
|
||||
_MAX_EVENTS = 100
|
||||
_MAX_TIMELINE_ITEMS = 100
|
||||
|
||||
# =============================================================================
|
||||
# Tenants
|
||||
@@ -149,6 +154,278 @@ async def list_runs(
|
||||
return {"runs": runs, "total": total, "page": page, "per_page": per_page}
|
||||
|
||||
|
||||
def _timeline_item(
|
||||
*,
|
||||
ts: Any,
|
||||
kind: str,
|
||||
title: str,
|
||||
status: str,
|
||||
summary: str | None = None,
|
||||
metadata: dict[str, Any] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Build one Operator Console timeline item."""
|
||||
return {
|
||||
"ts": ts,
|
||||
"kind": kind,
|
||||
"title": title,
|
||||
"status": status,
|
||||
"summary": summary,
|
||||
"metadata": metadata or {},
|
||||
}
|
||||
|
||||
|
||||
async def get_run_detail(
|
||||
run_id: str,
|
||||
project_id: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""取得單一 Run 的處置脈絡,供 AwoooP Run detail / Timeline 顯示。"""
|
||||
try:
|
||||
run_uuid = uuid.UUID(run_id)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
||||
detail=f"run_id 格式錯誤: {exc}",
|
||||
) from exc
|
||||
|
||||
async with get_db_context(project_id or "awoooi") as db:
|
||||
run_stmt = select(AwoooPRunState).where(AwoooPRunState.run_id == run_uuid)
|
||||
if project_id is not None:
|
||||
run_stmt = run_stmt.where(AwoooPRunState.project_id == project_id)
|
||||
run_result = await db.execute(run_stmt)
|
||||
run = run_result.scalar_one_or_none()
|
||||
|
||||
if run is None:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_404_NOT_FOUND,
|
||||
detail=f"run {run_id!r} 不存在",
|
||||
)
|
||||
|
||||
steps_result = await db.execute(
|
||||
select(AwoooPRunStepJournal)
|
||||
.where(AwoooPRunStepJournal.run_id == run_uuid)
|
||||
.order_by(AwoooPRunStepJournal.step_seq.asc())
|
||||
.limit(_MAX_TIMELINE_ITEMS)
|
||||
)
|
||||
steps = list(steps_result.scalars().all())
|
||||
|
||||
inbound_where = [AwoooPConversationEvent.run_id == run_uuid]
|
||||
if run.trigger_ref:
|
||||
try:
|
||||
trigger_event_uuid = uuid.UUID(run.trigger_ref)
|
||||
inbound_where.append(AwoooPConversationEvent.event_id == trigger_event_uuid)
|
||||
except ValueError:
|
||||
inbound_where.append(
|
||||
AwoooPConversationEvent.provider_event_id == run.trigger_ref
|
||||
)
|
||||
inbound_result = await db.execute(
|
||||
select(AwoooPConversationEvent)
|
||||
.where(sa_or_(*inbound_where))
|
||||
.order_by(AwoooPConversationEvent.received_at.asc())
|
||||
.limit(_MAX_TIMELINE_ITEMS)
|
||||
)
|
||||
inbound_events = list(inbound_result.scalars().all())
|
||||
|
||||
outbound_result = await db.execute(
|
||||
select(AwoooPOutboundMessage)
|
||||
.where(AwoooPOutboundMessage.run_id == run_uuid)
|
||||
.order_by(AwoooPOutboundMessage.queued_at.asc())
|
||||
.limit(_MAX_TIMELINE_ITEMS)
|
||||
)
|
||||
outbound_messages = list(outbound_result.scalars().all())
|
||||
|
||||
mcp_result = await db.execute(
|
||||
select(AwoooPMcpGatewayAudit)
|
||||
.where(AwoooPMcpGatewayAudit.run_id == run_uuid)
|
||||
.order_by(AwoooPMcpGatewayAudit.created_at.asc())
|
||||
.limit(_MAX_TIMELINE_ITEMS)
|
||||
)
|
||||
mcp_calls = list(mcp_result.scalars().all())
|
||||
|
||||
run_payload = {
|
||||
"run_id": run.run_id,
|
||||
"project_id": run.project_id,
|
||||
"agent_id": run.agent_id,
|
||||
"state": run.state,
|
||||
"is_shadow": run.is_shadow,
|
||||
"trace_id": run.trace_id,
|
||||
"trigger_type": run.trigger_type,
|
||||
"trigger_ref": run.trigger_ref,
|
||||
"cost_usd": run.cost_usd,
|
||||
"step_count": run.step_count,
|
||||
"attempt_count": run.attempt_count,
|
||||
"max_attempts": run.max_attempts,
|
||||
"error_code": run.error_code,
|
||||
"error_detail": run.error_detail,
|
||||
"created_at": run.created_at,
|
||||
"started_at": run.started_at,
|
||||
"completed_at": run.completed_at,
|
||||
"timeout_at": run.timeout_at,
|
||||
"heartbeat_at": run.heartbeat_at,
|
||||
}
|
||||
|
||||
step_items = [
|
||||
{
|
||||
"step_id": row.step_id,
|
||||
"step_seq": row.step_seq,
|
||||
"tool_name": row.tool_name,
|
||||
"result_status": row.result_status,
|
||||
"was_blocked": row.was_blocked,
|
||||
"block_reason": row.block_reason,
|
||||
"error_code": row.error_code,
|
||||
"latency_ms": row.latency_ms,
|
||||
"created_at": row.created_at,
|
||||
"completed_at": row.completed_at,
|
||||
}
|
||||
for row in steps
|
||||
]
|
||||
|
||||
inbound_items = [
|
||||
{
|
||||
"event_id": row.event_id,
|
||||
"channel_type": row.channel_type,
|
||||
"provider_event_id": row.provider_event_id,
|
||||
"content_preview": row.content_preview,
|
||||
"is_duplicate": row.is_duplicate,
|
||||
"received_at": row.received_at,
|
||||
}
|
||||
for row in inbound_events
|
||||
]
|
||||
|
||||
outbound_items = [
|
||||
{
|
||||
"message_id": row.message_id,
|
||||
"channel_type": row.channel_type,
|
||||
"message_type": row.message_type,
|
||||
"content_preview": row.content_preview,
|
||||
"send_status": row.send_status,
|
||||
"send_error": row.send_error,
|
||||
"provider_message_id": row.provider_message_id,
|
||||
"queued_at": row.queued_at,
|
||||
"sent_at": row.sent_at,
|
||||
"triggered_by_state": row.triggered_by_state,
|
||||
}
|
||||
for row in outbound_messages
|
||||
]
|
||||
|
||||
mcp_items = [
|
||||
{
|
||||
"call_id": row.call_id,
|
||||
"tool_name": row.tool_name,
|
||||
"result_status": row.result_status,
|
||||
"block_gate": row.block_gate,
|
||||
"block_reason": row.block_reason,
|
||||
"latency_ms": row.latency_ms,
|
||||
"created_at": row.created_at,
|
||||
}
|
||||
for row in mcp_calls
|
||||
]
|
||||
|
||||
timeline: list[dict[str, Any]] = [
|
||||
_timeline_item(
|
||||
ts=run.created_at,
|
||||
kind="run",
|
||||
title="Run 建立",
|
||||
status=run.state,
|
||||
summary=f"{run.trigger_type or 'unknown'} → {run.agent_id}",
|
||||
metadata={"trace_id": run.trace_id, "trigger_ref": run.trigger_ref},
|
||||
)
|
||||
]
|
||||
if run.started_at:
|
||||
timeline.append(
|
||||
_timeline_item(
|
||||
ts=run.started_at,
|
||||
kind="run",
|
||||
title="Run 開始執行",
|
||||
status="running",
|
||||
summary=run.worker_id,
|
||||
)
|
||||
)
|
||||
for row in inbound_events:
|
||||
timeline.append(
|
||||
_timeline_item(
|
||||
ts=row.received_at,
|
||||
kind="inbound",
|
||||
title=f"{row.channel_type} 入站事件",
|
||||
status="duplicate" if row.is_duplicate else "received",
|
||||
summary=row.content_preview,
|
||||
metadata={"provider_event_id": row.provider_event_id},
|
||||
)
|
||||
)
|
||||
for row in steps:
|
||||
timeline.append(
|
||||
_timeline_item(
|
||||
ts=row.completed_at or row.created_at,
|
||||
kind="step",
|
||||
title=f"Step {row.step_seq}: {row.tool_name}",
|
||||
status=row.result_status,
|
||||
summary=row.block_reason or row.error_code,
|
||||
metadata={
|
||||
"was_blocked": row.was_blocked,
|
||||
"latency_ms": row.latency_ms,
|
||||
},
|
||||
)
|
||||
)
|
||||
for row in mcp_calls:
|
||||
timeline.append(
|
||||
_timeline_item(
|
||||
ts=row.created_at,
|
||||
kind="mcp",
|
||||
title=f"MCP: {row.tool_name}",
|
||||
status=row.result_status,
|
||||
summary=row.block_reason,
|
||||
metadata={
|
||||
"block_gate": row.block_gate,
|
||||
"latency_ms": row.latency_ms,
|
||||
},
|
||||
)
|
||||
)
|
||||
for row in outbound_messages:
|
||||
timeline.append(
|
||||
_timeline_item(
|
||||
ts=row.sent_at or row.queued_at,
|
||||
kind="outbound",
|
||||
title=f"{row.channel_type} 出站:{row.message_type}",
|
||||
status=row.send_status,
|
||||
summary=row.content_preview or row.send_error,
|
||||
metadata={
|
||||
"provider_message_id": row.provider_message_id,
|
||||
"triggered_by_state": row.triggered_by_state,
|
||||
},
|
||||
)
|
||||
)
|
||||
if run.completed_at:
|
||||
timeline.append(
|
||||
_timeline_item(
|
||||
ts=run.completed_at,
|
||||
kind="run",
|
||||
title="Run 結束",
|
||||
status=run.state,
|
||||
summary=run.error_detail or run.error_code,
|
||||
)
|
||||
)
|
||||
|
||||
timeline = sorted(
|
||||
timeline,
|
||||
key=lambda item: item["ts"] or run.created_at,
|
||||
)[:_MAX_TIMELINE_ITEMS]
|
||||
|
||||
return {
|
||||
"run": run_payload,
|
||||
"steps": step_items,
|
||||
"inbound_events": inbound_items,
|
||||
"outbound_messages": outbound_items,
|
||||
"mcp_calls": mcp_items,
|
||||
"timeline": timeline,
|
||||
"counts": {
|
||||
"steps": len(step_items),
|
||||
"inbound_events": len(inbound_items),
|
||||
"outbound_messages": len(outbound_items),
|
||||
"mcp_calls": len(mcp_items),
|
||||
"timeline": len(timeline),
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# Channel Events
|
||||
# =============================================================================
|
||||
|
||||
@@ -11,8 +11,10 @@ def test_runs_list_route_is_registered_before_dynamic_run_id() -> None:
|
||||
]
|
||||
|
||||
assert "/runs/list" in paths
|
||||
assert "/runs/{run_id}/detail" in paths
|
||||
assert "/runs/{run_id}" in paths
|
||||
assert paths.index("/runs/list") < paths.index("/runs/{run_id}")
|
||||
assert paths.index("/runs/{run_id}/detail") < paths.index("/runs/{run_id}")
|
||||
|
||||
|
||||
def test_recent_events_route_is_registered() -> None:
|
||||
|
||||
328
apps/web/src/app/[locale]/awooop/runs/[run_id]/page.tsx
Normal file
328
apps/web/src/app/[locale]/awooop/runs/[run_id]/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user