fix(awooop): localize run detail timeline
All checks were successful
Code Review / ai-code-review (push) Successful in 11s
CD Pipeline / tests (push) Successful in 1m2s
CD Pipeline / build-and-deploy (push) Successful in 3m36s
CD Pipeline / post-deploy-checks (push) Successful in 1m22s

This commit is contained in:
Your Name
2026-05-07 05:46:31 +08:00
parent 9d85ec5e96
commit f960a4a19b
3 changed files with 199 additions and 40 deletions

View File

@@ -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"
}
}
}
}

View File

@@ -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": "等待審批"
}
}
}
}

View File

@@ -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>