| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234 |
- "use client";
- import { useCallback, useEffect, useMemo, useState } from "react";
- import Link from "next/link";
- import { getTimeline } from "@/lib/api/client";
- import type { TimelineItem, TimelineResponse } from "@/lib/api/types";
- import { AppShell } from "@/components/layout/AppShell";
- import { MetricCard } from "@/components/cards/MetricCard";
- import { StatusBadge } from "@/components/badges/StatusBadge";
- import { statusLabel } from "@/lib/status/status";
- // graph.py 节点注册顺序(M6 stage 名 = 节点名)。
- const STAGE_ORDER = [
- "load_source",
- "plan_queries",
- "search_platform",
- "build_discovered_content",
- "recall_pattern",
- "load_policy",
- "evaluate_rules",
- "execute_walk",
- "plan_walk",
- "record_run",
- "commit_results",
- "review_strategy"
- ];
- const SOURCE_FILTERS: Array<{ id: string; label: string }> = [
- { id: "all", label: "全部" },
- { id: "stage", label: "阶段事件" },
- { id: "judge", label: "判定事件" },
- { id: "walk", label: "游走动作" },
- { id: "path", label: "路径记录" },
- { id: "other", label: "其他" }
- ];
- function itemFilterKind(item: TimelineItem): string {
- const eventType = String(item.event_type || "");
- if (eventType.startsWith("stage_")) return "stage";
- // V3:Gemini 判定相关事件(配额截断、pattern recall);兼容老 run 的 decode_* 也归此类。
- if (eventType === "gemini_quota_exhausted" || eventType.startsWith("decode_") || item.source === "pattern_recall_evidence.jsonl") {
- return "judge";
- }
- if (item.source === "walk_actions.jsonl") return "walk";
- if (item.source === "source_path_records.jsonl") return "path";
- return "other";
- }
- function rawPayload(item: TimelineItem): Record<string, unknown> {
- const record = item.record || {};
- const payload = record.raw_payload;
- return payload && typeof payload === "object" ? (payload as Record<string, unknown>) : record;
- }
- function countsLine(counts: Record<string, number>): string {
- const entries = Object.entries(counts || {});
- if (!entries.length) return "0";
- return entries.map(([key, value]) => `${statusLabel(key)} ${value}`).join(" / ");
- }
- // Gemini 配额截断:run_events 里的 gemini_quota_exhausted 事件,显示 used/cap。
- function geminiQuotaLabel(items: TimelineItem[]): string {
- const ev = items.find((it) => it.event_type === "gemini_quota_exhausted");
- if (!ev) return "未触发";
- const payload = rawPayload(ev);
- const used = payload.used ?? "?";
- const cap = payload.cap ?? "?";
- return `已截断 ${used}/${cap}`;
- }
- export function RunTimelinePage({ runId }: { runId: string }) {
- const [data, setData] = useState<TimelineResponse | null>(null);
- const [error, setError] = useState<string | null>(null);
- const [loading, setLoading] = useState(true);
- const [filter, setFilter] = useState("all");
- const load = useCallback(async () => {
- setLoading(true);
- setError(null);
- try {
- setData(await getTimeline(runId));
- } catch (err) {
- setError(err instanceof Error ? err.message : "时间线加载失败");
- } finally {
- setLoading(false);
- }
- }, [runId]);
- useEffect(() => {
- void load();
- }, [load]);
- const summary = data?.summary;
- const maxStageMs = useMemo(() => {
- const values = Object.values(summary?.stage_duration_ms || {});
- return values.length ? Math.max(...values, 1) : 1;
- }, [summary]);
- const failedStages = useMemo(() => {
- const stages = new Map<string, TimelineItem>();
- for (const item of data?.items || []) {
- if (item.event_type === "stage_failed") {
- const stage = String(rawPayload(item).stage || "");
- if (stage) stages.set(stage, item);
- }
- }
- return stages;
- }, [data]);
- const visibleItems = useMemo(
- () => (data?.items || []).filter((item) => filter === "all" || itemFilterKind(item) === filter),
- [data, filter]
- );
- return (
- <AppShell onRefresh={load} showBack>
- <section className="detail-panel">
- <header className="detail-header">
- <div>
- <h1 className="detail-title">运行时间线 · {runId}</h1>
- <p className="muted">
- 可观测事实:阶段耗时、失败、限流与 Gemini 判定 / 配额。仅呈现事实,不做“卡住”判断。
- </p>
- </div>
- <Link className="muted" href={`/runs/${encodeURIComponent(runId)}`}>
- 返回运行详情
- </Link>
- </header>
- {loading ? <div className="loading-state">时间线加载中…</div> : null}
- {error ? <div className="error-state">{error}</div> : null}
- {summary ? (
- <>
- <section className="section">
- <h2 className="section-title">运行摘要</h2>
- <div className="metrics-grid">
- <MetricCard
- label="总耗时"
- value={summary.total_duration_ms == null ? "未知" : `${summary.total_duration_ms} ms`}
- />
- <MetricCard label="query 失败" value={summary.query_failure_count} />
- <MetricCard label="平台限流" value={summary.platform_rate_limited_count} />
- <MetricCard
- label="Gemini 配额"
- value={geminiQuotaLabel(data?.items || [])}
- />
- {summary.decode_status_counts && Object.keys(summary.decode_status_counts).length ? (
- <MetricCard
- label="decode 状态(历史)"
- value={countsLine(summary.decode_status_counts)}
- />
- ) : null}
- <MetricCard label="错误分布" value={countsLine(summary.error_counts)} />
- <MetricCard label="游走状态" value={countsLine(summary.walk_status_counts)} />
- </div>
- </section>
- <section className="section">
- <h2 className="section-title">阶段耗时</h2>
- <div className="stage-bars">
- {STAGE_ORDER.map((stage) => {
- const duration = summary.stage_duration_ms?.[stage];
- const failedItem = failedStages.get(stage);
- if (duration == null && !failedItem) return null;
- const width = Math.max(((duration || 0) / maxStageMs) * 100, 2);
- return (
- <div className={`stage-bar-row${failedItem ? " failed" : ""}`} key={stage}>
- <span className="stage-bar-label">{stage}</span>
- <span className="stage-bar-track">
- <span className="stage-bar-fill" style={{ width: `${width}%` }} />
- </span>
- <span className="stage-bar-value">
- {duration == null ? "—" : `${duration} ms`}
- {failedItem
- ? ` · 失败(${String(failedItem.error_code || rawPayload(failedItem).message || "未知原因")})`
- : ""}
- </span>
- </div>
- );
- })}
- </div>
- </section>
- </>
- ) : null}
- <section className="section">
- <h2 className="section-title">
- 事件流(日志线) <span className="muted">{visibleItems.length} / {data?.total ?? 0} 条</span>
- </h2>
- <div className="filter-bar">
- {SOURCE_FILTERS.map((option) => (
- <button
- key={option.id}
- type="button"
- className={`chip${filter === option.id ? " active" : ""}`}
- onClick={() => setFilter(option.id)}
- >
- {option.label}
- </button>
- ))}
- </div>
- {visibleItems.length === 0 && !loading ? (
- <div className="empty-state">当前过滤条件下没有事件。</div>
- ) : null}
- <ol className="event-list">
- {visibleItems.map((item, index) => {
- const payload = rawPayload(item);
- const eventId = String((item.record || {}).event_id || `${item.source}-${index}`);
- return (
- <li className={`event-row${item.event_type === "stage_failed" ? " failed" : ""}`} key={`${eventId}-${index}`}>
- <div className="event-row-head">
- <span className="muted">{String(item.timestamp || "").replace("T", " ").slice(0, 23)}</span>
- <span className="event-type">{statusLabel(String(item.event_type || item.stage || ""))}</span>
- <StatusBadge status={item.status} />
- {item.error_code ? <span className="error-state inline">{String(item.error_code)}</span> : null}
- <span className="muted">{eventId}</span>
- </div>
- <details>
- <summary className="muted">原始记录(日志明细)</summary>
- <pre className="event-payload">{JSON.stringify(payload, null, 2)}</pre>
- </details>
- </li>
- );
- })}
- </ol>
- <p className="muted">
- 兜底:完整 run_events.jsonl 可在
- <Link href={`/runs/${encodeURIComponent(runId)}`}> 运行详情</Link>
- 的「运行文件」区分页查看。
- </p>
- </section>
- </section>
- </AppShell>
- );
- }
|