fix(awooop): localize run detail timeline
This commit is contained in:
@@ -1551,6 +1551,57 @@
|
||||
"item3": "Review contract lifecycle",
|
||||
"item4": "Open the AwoooP work map"
|
||||
}
|
||||
},
|
||||
"runDetail": {
|
||||
"back": "Back to Run Monitor",
|
||||
"title": "Run Disposition Timeline",
|
||||
"refresh": "Refresh",
|
||||
"empty": "--",
|
||||
"durationSeconds": "{seconds}s",
|
||||
"errors": {
|
||||
"title": "Failed to load run details",
|
||||
"loadFailed": "Load failed"
|
||||
},
|
||||
"stats": {
|
||||
"state": "Current State",
|
||||
"timeline": "Timeline",
|
||||
"mcpSteps": "MCP / Steps",
|
||||
"duration": "Duration"
|
||||
},
|
||||
"summary": {
|
||||
"title": "Run Summary",
|
||||
"project": "Project",
|
||||
"agent": "Agent",
|
||||
"traceId": "Trace ID",
|
||||
"trigger": "Trigger",
|
||||
"triggerRef": "Trigger Ref",
|
||||
"cost": "Cost",
|
||||
"attempts": "Attempts",
|
||||
"created": "Created",
|
||||
"completed": "Completed",
|
||||
"error": "Error"
|
||||
},
|
||||
"timeline": {
|
||||
"title": "Disposition Timeline",
|
||||
"lastUpdated": "Last updated {time}",
|
||||
"count": "{count} items",
|
||||
"empty": "No timeline records yet."
|
||||
},
|
||||
"statuses": {
|
||||
"blocked": "Blocked",
|
||||
"cancelled": "Cancelled",
|
||||
"completed": "Completed",
|
||||
"error": "Error",
|
||||
"failed": "Failed",
|
||||
"pending": "Pending",
|
||||
"received": "Received",
|
||||
"running": "Running",
|
||||
"sent": "Sent",
|
||||
"shadow": "Shadow",
|
||||
"success": "Success",
|
||||
"timeout": "Timed out",
|
||||
"waitingApproval": "Waiting approval"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1552,6 +1552,57 @@
|
||||
"item3": "審查 Contract lifecycle",
|
||||
"item4": "查看 AwoooP 工作鏈路地圖"
|
||||
}
|
||||
},
|
||||
"runDetail": {
|
||||
"back": "返回 Run 監控",
|
||||
"title": "Run 處置脈絡",
|
||||
"refresh": "重新整理",
|
||||
"empty": "--",
|
||||
"durationSeconds": "{seconds}s",
|
||||
"errors": {
|
||||
"title": "無法載入 Run 詳情",
|
||||
"loadFailed": "載入失敗"
|
||||
},
|
||||
"stats": {
|
||||
"state": "目前狀態",
|
||||
"timeline": "Timeline",
|
||||
"mcpSteps": "MCP / Steps",
|
||||
"duration": "執行時間"
|
||||
},
|
||||
"summary": {
|
||||
"title": "Run 摘要",
|
||||
"project": "Project",
|
||||
"agent": "Agent",
|
||||
"traceId": "Trace ID",
|
||||
"trigger": "Trigger",
|
||||
"triggerRef": "Trigger Ref",
|
||||
"cost": "Cost",
|
||||
"attempts": "Attempts",
|
||||
"created": "Created",
|
||||
"completed": "Completed",
|
||||
"error": "Error"
|
||||
},
|
||||
"timeline": {
|
||||
"title": "處置時間線",
|
||||
"lastUpdated": "上次更新 {time}",
|
||||
"count": "{count} 筆",
|
||||
"empty": "尚無時間線資料。"
|
||||
},
|
||||
"statuses": {
|
||||
"blocked": "已阻擋",
|
||||
"cancelled": "已取消",
|
||||
"completed": "已完成",
|
||||
"error": "錯誤",
|
||||
"failed": "失敗",
|
||||
"pending": "待執行",
|
||||
"received": "已接收",
|
||||
"running": "執行中",
|
||||
"sent": "已送出",
|
||||
"shadow": "Shadow",
|
||||
"success": "成功",
|
||||
"timeout": "已超時",
|
||||
"waitingApproval": "等待審批"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useSearchParams } from "next/navigation";
|
||||
import { useLocale, useTranslations } from "next-intl";
|
||||
import {
|
||||
Activity,
|
||||
AlertCircle,
|
||||
@@ -85,9 +86,25 @@ const STATUS_STYLE: Record<string, string> = {
|
||||
timeout: "border-[#e2a29b] bg-[#fff0ef] text-[#9f2f25]",
|
||||
};
|
||||
|
||||
function formatTime(value?: string | null) {
|
||||
if (!value) return "--";
|
||||
return new Date(value).toLocaleString("zh-TW", {
|
||||
const STATUS_TRANSLATION_KEYS: Record<string, string> = {
|
||||
blocked: "statuses.blocked",
|
||||
cancelled: "statuses.cancelled",
|
||||
completed: "statuses.completed",
|
||||
error: "statuses.error",
|
||||
failed: "statuses.failed",
|
||||
pending: "statuses.pending",
|
||||
received: "statuses.received",
|
||||
running: "statuses.running",
|
||||
sent: "statuses.sent",
|
||||
shadow: "statuses.shadow",
|
||||
success: "statuses.success",
|
||||
timeout: "statuses.timeout",
|
||||
waiting_approval: "statuses.waitingApproval",
|
||||
};
|
||||
|
||||
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", {
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
hour: "2-digit",
|
||||
@@ -108,20 +125,42 @@ function itemIcon(kind: string) {
|
||||
return Route;
|
||||
}
|
||||
|
||||
function DetailField({ label, value }: { label: string; value?: string | number | null }) {
|
||||
function DetailField({
|
||||
label,
|
||||
value,
|
||||
emptyLabel,
|
||||
}: {
|
||||
label: string;
|
||||
value?: string | number | null;
|
||||
emptyLabel: string;
|
||||
}) {
|
||||
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 className="mt-1 break-words font-mono text-sm text-[#141413]">
|
||||
{value ?? emptyLabel}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function TimelineRow({ item }: { item: TimelineItem }) {
|
||||
function TimelineRow({
|
||||
item,
|
||||
locale,
|
||||
emptyLabel,
|
||||
statusLabel,
|
||||
}: {
|
||||
item: TimelineItem;
|
||||
locale: string;
|
||||
emptyLabel: string;
|
||||
statusLabel: (status: string) => string;
|
||||
}) {
|
||||
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="font-mono text-xs text-[#77736a]">
|
||||
{formatTime(item.ts, locale, emptyLabel)}
|
||||
</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]">
|
||||
@@ -129,7 +168,7 @@ function TimelineRow({ item }: { item: TimelineItem }) {
|
||||
</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}
|
||||
{statusLabel(item.status)}
|
||||
</span>
|
||||
</div>
|
||||
{item.summary && (
|
||||
@@ -143,7 +182,7 @@ function TimelineRow({ item }: { item: TimelineItem }) {
|
||||
<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)}
|
||||
{value === null || value === undefined ? emptyLabel : String(value)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
@@ -157,9 +196,11 @@ function TimelineRow({ item }: { item: TimelineItem }) {
|
||||
export default function RunDetailPage({
|
||||
params,
|
||||
}: {
|
||||
params: { run_id: string };
|
||||
params: { locale: string; run_id: string };
|
||||
}) {
|
||||
const { run_id } = params;
|
||||
const locale = useLocale();
|
||||
const t = useTranslations("awooop.runDetail");
|
||||
const searchParams = useSearchParams();
|
||||
const projectId = searchParams.get("project_id") ?? "";
|
||||
|
||||
@@ -180,11 +221,11 @@ export default function RunDetailPage({
|
||||
setDetail(data);
|
||||
setLastRefresh(new Date());
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "載入失敗");
|
||||
setError(err instanceof Error ? err.message : t("errors.loadFailed"));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [projectId, run_id]);
|
||||
}, [projectId, run_id, t]);
|
||||
|
||||
useEffect(() => {
|
||||
setLoading(true);
|
||||
@@ -198,11 +239,19 @@ export default function RunDetailPage({
|
||||
|
||||
const run = detail?.run;
|
||||
const durationText = useMemo(() => {
|
||||
if (!run?.created_at) return "--";
|
||||
if (!run?.created_at) return t("empty");
|
||||
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 t("durationSeconds", { seconds: Math.round(ms / 1000) });
|
||||
}, [run, t]);
|
||||
|
||||
const statusLabel = useCallback(
|
||||
(status: string) => {
|
||||
const key = STATUS_TRANSLATION_KEYS[status];
|
||||
return key ? t(key as never) : status;
|
||||
},
|
||||
[t]
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
@@ -211,14 +260,14 @@ export default function RunDetailPage({
|
||||
className="inline-flex items-center gap-2 text-sm text-[#77736a] hover:text-[#141413]"
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4" aria-hidden="true" />
|
||||
返回 Run 監控
|
||||
{t("back")}
|
||||
</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>
|
||||
<h2 className="text-lg font-semibold text-[#141413]">{t("title")}</h2>
|
||||
<p className="font-mono text-xs text-[#77736a]">{run_id}</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -231,7 +280,7 @@ export default function RunDetailPage({
|
||||
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" />
|
||||
重新整理
|
||||
{t("refresh")}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -239,7 +288,7 @@ export default function RunDetailPage({
|
||||
<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="text-sm font-semibold">{t("errors.title")}</p>
|
||||
<p className="mt-1 text-xs">{error}</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -247,19 +296,19 @@ export default function RunDetailPage({
|
||||
|
||||
<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="text-xs font-semibold text-[#77736a]">{t("stats.state")}</div>
|
||||
<div className={cn("mt-3 inline-flex border px-2 py-1 text-sm font-semibold", statusClass(run?.state ?? "pending"))}>
|
||||
{run?.state ?? "--"}
|
||||
{run ? statusLabel(run.state) : t("empty")}
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-white p-4">
|
||||
<div className="text-xs font-semibold text-[#77736a]">Timeline</div>
|
||||
<div className="text-xs font-semibold text-[#77736a]">{t("stats.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="text-xs font-semibold text-[#77736a]">{t("stats.mcpSteps")}</div>
|
||||
<div className="mt-2 font-mono text-2xl font-semibold text-[#141413]">
|
||||
{(detail?.counts.mcp_calls ?? 0) + (detail?.counts.steps ?? 0)}
|
||||
</div>
|
||||
@@ -267,7 +316,7 @@ export default function RunDetailPage({
|
||||
<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" />
|
||||
執行時間
|
||||
{t("stats.duration")}
|
||||
</div>
|
||||
<div className="mt-2 font-mono text-2xl font-semibold text-[#141413]">{durationText}</div>
|
||||
</div>
|
||||
@@ -276,32 +325,34 @@ export default function RunDetailPage({
|
||||
<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>
|
||||
<h3 className="text-sm font-semibold text-[#141413]">{t("summary.title")}</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} />
|
||||
<DetailField label={t("summary.project")} value={run?.project_id} emptyLabel={t("empty")} />
|
||||
<DetailField label={t("summary.agent")} value={run?.agent_id} emptyLabel={t("empty")} />
|
||||
<DetailField label={t("summary.traceId")} value={run?.trace_id} emptyLabel={t("empty")} />
|
||||
<DetailField label={t("summary.trigger")} value={run?.trigger_type} emptyLabel={t("empty")} />
|
||||
<DetailField label={t("summary.triggerRef")} value={run?.trigger_ref} emptyLabel={t("empty")} />
|
||||
<DetailField label={t("summary.cost")} value={run ? `$${Number(run.cost_usd ?? 0).toFixed(4)}` : null} emptyLabel={t("empty")} />
|
||||
<DetailField label={t("summary.attempts")} value={run ? `${run.attempt_count}/${run.max_attempts}` : null} emptyLabel={t("empty")} />
|
||||
<DetailField label={t("summary.created")} value={formatTime(run?.created_at, locale, t("empty"))} emptyLabel={t("empty")} />
|
||||
<DetailField label={t("summary.completed")} value={formatTime(run?.completed_at, locale, t("empty"))} emptyLabel={t("empty")} />
|
||||
<DetailField label={t("summary.error")} value={run?.error_detail || run?.error_code} emptyLabel={t("empty")} />
|
||||
</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>
|
||||
<h3 className="text-sm font-semibold text-[#141413]">{t("timeline.title")}</h3>
|
||||
<p className="mt-1 text-xs text-[#77736a]">
|
||||
上次更新 {lastRefresh.toLocaleTimeString("zh-TW")}
|
||||
{t("timeline.lastUpdated", {
|
||||
time: lastRefresh.toLocaleTimeString(locale === "zh-TW" ? "zh-TW" : "en-US"),
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
<span className="border border-[#d8d3c7] bg-white px-2 py-0.5 text-xs font-semibold text-[#5f5b52]">
|
||||
{detail?.counts.timeline ?? 0} 筆
|
||||
{t("timeline.count", { count: detail?.counts.timeline ?? 0 })}
|
||||
</span>
|
||||
</div>
|
||||
{loading && !detail ? (
|
||||
@@ -313,12 +364,18 @@ export default function RunDetailPage({
|
||||
) : detail && detail.timeline.length > 0 ? (
|
||||
<div>
|
||||
{detail.timeline.map((item, index) => (
|
||||
<TimelineRow key={`${item.kind}-${item.ts}-${index}`} item={item} />
|
||||
<TimelineRow
|
||||
key={`${item.kind}-${item.ts}-${index}`}
|
||||
item={item}
|
||||
locale={locale}
|
||||
emptyLabel={t("empty")}
|
||||
statusLabel={statusLabel}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="px-4 py-12 text-center text-sm text-[#77736a]">
|
||||
尚無時間線資料。
|
||||
{t("timeline.empty")}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
Reference in New Issue
Block a user