RunTimelinePage.tsx 9.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234
  1. "use client";
  2. import { useCallback, useEffect, useMemo, useState } from "react";
  3. import Link from "next/link";
  4. import { getTimeline } from "@/lib/api/client";
  5. import type { TimelineItem, TimelineResponse } from "@/lib/api/types";
  6. import { AppShell } from "@/components/layout/AppShell";
  7. import { MetricCard } from "@/components/cards/MetricCard";
  8. import { StatusBadge } from "@/components/badges/StatusBadge";
  9. import { statusLabel } from "@/lib/status/status";
  10. // graph.py 节点注册顺序(M6 stage 名 = 节点名)。
  11. const STAGE_ORDER = [
  12. "load_source",
  13. "plan_queries",
  14. "search_platform",
  15. "build_discovered_content",
  16. "recall_pattern",
  17. "load_policy",
  18. "evaluate_rules",
  19. "execute_walk",
  20. "plan_walk",
  21. "record_run",
  22. "commit_results",
  23. "review_strategy"
  24. ];
  25. const SOURCE_FILTERS: Array<{ id: string; label: string }> = [
  26. { id: "all", label: "全部" },
  27. { id: "stage", label: "阶段事件" },
  28. { id: "judge", label: "判定事件" },
  29. { id: "walk", label: "游走动作" },
  30. { id: "path", label: "路径记录" },
  31. { id: "other", label: "其他" }
  32. ];
  33. function itemFilterKind(item: TimelineItem): string {
  34. const eventType = String(item.event_type || "");
  35. if (eventType.startsWith("stage_")) return "stage";
  36. // V3:Gemini 判定相关事件(配额截断、pattern recall);兼容老 run 的 decode_* 也归此类。
  37. if (eventType === "gemini_quota_exhausted" || eventType.startsWith("decode_") || item.source === "pattern_recall_evidence.jsonl") {
  38. return "judge";
  39. }
  40. if (item.source === "walk_actions.jsonl") return "walk";
  41. if (item.source === "source_path_records.jsonl") return "path";
  42. return "other";
  43. }
  44. function rawPayload(item: TimelineItem): Record<string, unknown> {
  45. const record = item.record || {};
  46. const payload = record.raw_payload;
  47. return payload && typeof payload === "object" ? (payload as Record<string, unknown>) : record;
  48. }
  49. function countsLine(counts: Record<string, number>): string {
  50. const entries = Object.entries(counts || {});
  51. if (!entries.length) return "0";
  52. return entries.map(([key, value]) => `${statusLabel(key)} ${value}`).join(" / ");
  53. }
  54. // Gemini 配额截断:run_events 里的 gemini_quota_exhausted 事件,显示 used/cap。
  55. function geminiQuotaLabel(items: TimelineItem[]): string {
  56. const ev = items.find((it) => it.event_type === "gemini_quota_exhausted");
  57. if (!ev) return "未触发";
  58. const payload = rawPayload(ev);
  59. const used = payload.used ?? "?";
  60. const cap = payload.cap ?? "?";
  61. return `已截断 ${used}/${cap}`;
  62. }
  63. export function RunTimelinePage({ runId }: { runId: string }) {
  64. const [data, setData] = useState<TimelineResponse | null>(null);
  65. const [error, setError] = useState<string | null>(null);
  66. const [loading, setLoading] = useState(true);
  67. const [filter, setFilter] = useState("all");
  68. const load = useCallback(async () => {
  69. setLoading(true);
  70. setError(null);
  71. try {
  72. setData(await getTimeline(runId));
  73. } catch (err) {
  74. setError(err instanceof Error ? err.message : "时间线加载失败");
  75. } finally {
  76. setLoading(false);
  77. }
  78. }, [runId]);
  79. useEffect(() => {
  80. void load();
  81. }, [load]);
  82. const summary = data?.summary;
  83. const maxStageMs = useMemo(() => {
  84. const values = Object.values(summary?.stage_duration_ms || {});
  85. return values.length ? Math.max(...values, 1) : 1;
  86. }, [summary]);
  87. const failedStages = useMemo(() => {
  88. const stages = new Map<string, TimelineItem>();
  89. for (const item of data?.items || []) {
  90. if (item.event_type === "stage_failed") {
  91. const stage = String(rawPayload(item).stage || "");
  92. if (stage) stages.set(stage, item);
  93. }
  94. }
  95. return stages;
  96. }, [data]);
  97. const visibleItems = useMemo(
  98. () => (data?.items || []).filter((item) => filter === "all" || itemFilterKind(item) === filter),
  99. [data, filter]
  100. );
  101. return (
  102. <AppShell onRefresh={load} showBack>
  103. <section className="detail-panel">
  104. <header className="detail-header">
  105. <div>
  106. <h1 className="detail-title">运行时间线 · {runId}</h1>
  107. <p className="muted">
  108. 可观测事实:阶段耗时、失败、限流与 Gemini 判定 / 配额。仅呈现事实,不做“卡住”判断。
  109. </p>
  110. </div>
  111. <Link className="muted" href={`/runs/${encodeURIComponent(runId)}`}>
  112. 返回运行详情
  113. </Link>
  114. </header>
  115. {loading ? <div className="loading-state">时间线加载中…</div> : null}
  116. {error ? <div className="error-state">{error}</div> : null}
  117. {summary ? (
  118. <>
  119. <section className="section">
  120. <h2 className="section-title">运行摘要</h2>
  121. <div className="metrics-grid">
  122. <MetricCard
  123. label="总耗时"
  124. value={summary.total_duration_ms == null ? "未知" : `${summary.total_duration_ms} ms`}
  125. />
  126. <MetricCard label="query 失败" value={summary.query_failure_count} />
  127. <MetricCard label="平台限流" value={summary.platform_rate_limited_count} />
  128. <MetricCard
  129. label="Gemini 配额"
  130. value={geminiQuotaLabel(data?.items || [])}
  131. />
  132. {summary.decode_status_counts && Object.keys(summary.decode_status_counts).length ? (
  133. <MetricCard
  134. label="decode 状态(历史)"
  135. value={countsLine(summary.decode_status_counts)}
  136. />
  137. ) : null}
  138. <MetricCard label="错误分布" value={countsLine(summary.error_counts)} />
  139. <MetricCard label="游走状态" value={countsLine(summary.walk_status_counts)} />
  140. </div>
  141. </section>
  142. <section className="section">
  143. <h2 className="section-title">阶段耗时</h2>
  144. <div className="stage-bars">
  145. {STAGE_ORDER.map((stage) => {
  146. const duration = summary.stage_duration_ms?.[stage];
  147. const failedItem = failedStages.get(stage);
  148. if (duration == null && !failedItem) return null;
  149. const width = Math.max(((duration || 0) / maxStageMs) * 100, 2);
  150. return (
  151. <div className={`stage-bar-row${failedItem ? " failed" : ""}`} key={stage}>
  152. <span className="stage-bar-label">{stage}</span>
  153. <span className="stage-bar-track">
  154. <span className="stage-bar-fill" style={{ width: `${width}%` }} />
  155. </span>
  156. <span className="stage-bar-value">
  157. {duration == null ? "—" : `${duration} ms`}
  158. {failedItem
  159. ? ` · 失败(${String(failedItem.error_code || rawPayload(failedItem).message || "未知原因")})`
  160. : ""}
  161. </span>
  162. </div>
  163. );
  164. })}
  165. </div>
  166. </section>
  167. </>
  168. ) : null}
  169. <section className="section">
  170. <h2 className="section-title">
  171. 事件流(日志线) <span className="muted">{visibleItems.length} / {data?.total ?? 0} 条</span>
  172. </h2>
  173. <div className="filter-bar">
  174. {SOURCE_FILTERS.map((option) => (
  175. <button
  176. key={option.id}
  177. type="button"
  178. className={`chip${filter === option.id ? " active" : ""}`}
  179. onClick={() => setFilter(option.id)}
  180. >
  181. {option.label}
  182. </button>
  183. ))}
  184. </div>
  185. {visibleItems.length === 0 && !loading ? (
  186. <div className="empty-state">当前过滤条件下没有事件。</div>
  187. ) : null}
  188. <ol className="event-list">
  189. {visibleItems.map((item, index) => {
  190. const payload = rawPayload(item);
  191. const eventId = String((item.record || {}).event_id || `${item.source}-${index}`);
  192. return (
  193. <li className={`event-row${item.event_type === "stage_failed" ? " failed" : ""}`} key={`${eventId}-${index}`}>
  194. <div className="event-row-head">
  195. <span className="muted">{String(item.timestamp || "").replace("T", " ").slice(0, 23)}</span>
  196. <span className="event-type">{statusLabel(String(item.event_type || item.stage || ""))}</span>
  197. <StatusBadge status={item.status} />
  198. {item.error_code ? <span className="error-state inline">{String(item.error_code)}</span> : null}
  199. <span className="muted">{eventId}</span>
  200. </div>
  201. <details>
  202. <summary className="muted">原始记录(日志明细)</summary>
  203. <pre className="event-payload">{JSON.stringify(payload, null, 2)}</pre>
  204. </details>
  205. </li>
  206. );
  207. })}
  208. </ol>
  209. <p className="muted">
  210. 兜底:完整 run_events.jsonl 可在
  211. <Link href={`/runs/${encodeURIComponent(runId)}`}> 运行详情</Link>
  212. 的「运行文件」区分页查看。
  213. </p>
  214. </section>
  215. </section>
  216. </AppShell>
  217. );
  218. }