@tea-agent/loop-agent 0.8.0 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,7 @@
1
1
  import { layoutDag } from "./dag-layout.js";
2
2
 
3
- const POLL_MS = 2000;
3
+ const ACTIVE_DETAIL_POLL_MS = 2000;
4
+ const HIDDEN_POLL_MS = 30000;
4
5
  const DASHBOARD_POLL_MS = 5000;
5
6
 
6
7
  const UI_TEXT = {
@@ -132,6 +133,172 @@ let selectedDagNodeId = null;
132
133
  let sessionEventOffset = 0;
133
134
  let sessionEvents = [];
134
135
  let lastSnapshot = null;
136
+ let dagInspectorOpen = false;
137
+ let dagInspectorTab = "output";
138
+ let dagGraphViewportState = null;
139
+ const dagGraphViewportStates = new Map();
140
+ let dagTimelineViewportState = null;
141
+ let pollingGeneration = 0;
142
+
143
+ function isPageVisible() {
144
+ return typeof document === "undefined" || document.visibilityState !== "hidden";
145
+ }
146
+
147
+ export function detailPollDelay(status, isVisible) {
148
+ const normalizedStatus = (status ?? "").toLowerCase().replace(/-/g, "_");
149
+ if (TERMINAL_RUN_STATUSES.has(normalizedStatus)) return null;
150
+ return isVisible ? ACTIVE_DETAIL_POLL_MS : HIDDEN_POLL_MS;
151
+ }
152
+
153
+ function dashboardPollDelay() {
154
+ return isPageVisible() ? DASHBOARD_POLL_MS : HIDDEN_POLL_MS;
155
+ }
156
+
157
+ export function parseMarkdownBlocks(markdown) {
158
+ const lines = String(markdown ?? "").replace(/\r\n?/g, "\n").split("\n");
159
+ const blocks = [];
160
+ let paragraph = [];
161
+ let list = null;
162
+ let code = null;
163
+
164
+ const flushParagraph = () => {
165
+ const text = paragraph.join(" ").trim();
166
+ if (text) blocks.push({ type: "paragraph", text });
167
+ paragraph = [];
168
+ };
169
+ const flushList = () => {
170
+ if (list?.items.length) blocks.push(list);
171
+ list = null;
172
+ };
173
+
174
+ for (let index = 0; index < lines.length; index += 1) {
175
+ const line = lines[index] ?? "";
176
+ if (code) {
177
+ if (/^```\s*$/.test(line)) {
178
+ blocks.push({ type: "code", language: code.language, text: code.lines.join("\n") });
179
+ code = null;
180
+ } else {
181
+ code.lines.push(line);
182
+ }
183
+ continue;
184
+ }
185
+
186
+ const fence = line.match(/^```([^\s]*)\s*$/);
187
+ if (fence) {
188
+ flushParagraph();
189
+ flushList();
190
+ code = { language: fence[1] ?? "", lines: [] };
191
+ continue;
192
+ }
193
+ if (!line.trim()) {
194
+ flushParagraph();
195
+ flushList();
196
+ continue;
197
+ }
198
+ const heading = line.match(/^(#{1,4})\s+(.+)$/);
199
+ if (heading) {
200
+ flushParagraph();
201
+ flushList();
202
+ blocks.push({ type: "heading", level: heading[1].length, text: heading[2].trim() });
203
+ continue;
204
+ }
205
+ if (/^\s{0,3}([-*_])(?:\s*\1){2,}\s*$/.test(line)) {
206
+ flushParagraph();
207
+ flushList();
208
+ blocks.push({ type: "rule" });
209
+ continue;
210
+ }
211
+ if (line.startsWith(">")) {
212
+ flushParagraph();
213
+ flushList();
214
+ blocks.push({ type: "quote", text: line.replace(/^>\s?/, "") });
215
+ continue;
216
+ }
217
+ const unordered = line.match(/^[-*+]\s+(.+)$/);
218
+ const ordered = line.match(/^\d+[.)]\s+(.+)$/);
219
+ if (unordered || ordered) {
220
+ flushParagraph();
221
+ const orderedList = Boolean(ordered);
222
+ if (!list || list.ordered !== orderedList) {
223
+ flushList();
224
+ list = { type: "list", ordered: orderedList, items: [] };
225
+ }
226
+ list.items.push((ordered?.[1] ?? unordered?.[1] ?? "").trim());
227
+ continue;
228
+ }
229
+ if (line.includes("|") && /^\s*\|?\s*:?-{3,}:?\s*(?:\|\s*:?-{3,}:?\s*)+\|?\s*$/.test(lines[index + 1] ?? "")) {
230
+ flushParagraph();
231
+ flushList();
232
+ const parseCells = (row) => row.trim().replace(/^\||\|$/g, "").split("|").map((cell) => cell.trim());
233
+ const headers = parseCells(line);
234
+ const rows = [];
235
+ index += 2;
236
+ while (index < lines.length && lines[index].includes("|")) {
237
+ rows.push(parseCells(lines[index]));
238
+ index += 1;
239
+ }
240
+ index -= 1;
241
+ blocks.push({ type: "table", headers, rows });
242
+ continue;
243
+ }
244
+ flushList();
245
+ paragraph.push(line.trim());
246
+ }
247
+ if (code) blocks.push({ type: "code", language: code.language, text: code.lines.join("\n") });
248
+ flushParagraph();
249
+ flushList();
250
+ return blocks;
251
+ }
252
+
253
+ export function markdownLinkHref(value) {
254
+ const href = typeof value === "string" ? value.trim() : "";
255
+ if (!href) return null;
256
+ if (href.startsWith("#") || href.startsWith("/")) return href;
257
+ try {
258
+ const parsed = new URL(
259
+ href,
260
+ typeof location === "undefined" ? "http://localhost" : location.href,
261
+ );
262
+ return ["http:", "https:", "mailto:"].includes(parsed.protocol) ? href : null;
263
+ } catch {
264
+ return null;
265
+ }
266
+ }
267
+
268
+ export function updateDagGraphViewportState(_previous, dagRunId, scrollLeft, scrollTop) {
269
+ return {
270
+ dagRunId,
271
+ scrollLeft: Math.max(0, Number(scrollLeft) || 0),
272
+ scrollTop: Math.max(0, Number(scrollTop) || 0),
273
+ };
274
+ }
275
+
276
+ export function captureDagGraphViewportState(previous, dagRunId, viewport) {
277
+ if (!viewport || viewport.dataset?.dagRunId !== dagRunId) return previous;
278
+ return updateDagGraphViewportState(
279
+ previous,
280
+ dagRunId,
281
+ viewport.scrollLeft,
282
+ viewport.scrollTop,
283
+ );
284
+ }
285
+
286
+ export function updateDagTimelineViewportState(
287
+ _previous,
288
+ dagRunId,
289
+ nodeId,
290
+ scrollLeft,
291
+ scrollTop,
292
+ followLatest,
293
+ ) {
294
+ return {
295
+ dagRunId,
296
+ nodeId,
297
+ scrollLeft: Math.max(0, Number(scrollLeft) || 0),
298
+ scrollTop: Math.max(0, Number(scrollTop) || 0),
299
+ followLatest: Boolean(followLatest),
300
+ };
301
+ }
135
302
 
136
303
  export function parseHashRoute(hashValue) {
137
304
  const hash = hashValue.replace(/^#/, "") || "/";
@@ -268,13 +435,20 @@ function livenessBadge(liveness) {
268
435
  }
269
436
 
270
437
  function showView(name) {
438
+ document.body.dataset.view = name;
439
+ if (name !== "dag") closeDagInspector();
271
440
  document.querySelectorAll("[data-view]").forEach((section) => {
272
441
  section.hidden = section.dataset.view !== name;
273
442
  });
443
+ const activeHref = name === "failures" ? "#/failures" : "#/";
444
+ document.querySelectorAll(".nav-link").forEach((link) => {
445
+ link.classList.toggle("nav-link-active", link.getAttribute("href") === activeHref);
446
+ });
274
447
  }
275
448
 
276
449
  function setBreadcrumb(parts) {
277
450
  const nav = document.getElementById("breadcrumb");
451
+ nav.hidden = parts.length <= 1;
278
452
  clearNode(nav);
279
453
  parts.forEach((part, i) => {
280
454
  if (i > 0) nav.appendChild(document.createTextNode(" / "));
@@ -304,16 +478,17 @@ function updateHeaderRefresh(generatedAt) {
304
478
  }
305
479
 
306
480
  function stopTimers() {
481
+ pollingGeneration += 1;
307
482
  if (dashboardTimer) {
308
- clearInterval(dashboardTimer);
483
+ clearTimeout(dashboardTimer);
309
484
  dashboardTimer = null;
310
485
  }
311
486
  if (runPollTimer) {
312
- clearInterval(runPollTimer);
487
+ clearTimeout(runPollTimer);
313
488
  runPollTimer = null;
314
489
  }
315
490
  if (dagPollTimer) {
316
- clearInterval(dagPollTimer);
491
+ clearTimeout(dagPollTimer);
317
492
  dagPollTimer = null;
318
493
  }
319
494
  currentRunId = null;
@@ -427,26 +602,6 @@ function isNodeFailed(status) {
427
602
  return s === "error" || s === "failed" || s === "partial_failed";
428
603
  }
429
604
 
430
- function pickDefaultDagNode(nodes) {
431
- if (!nodes?.length) return null;
432
- const sorted = [...nodes].sort((a, b) => {
433
- const ra = Number(a.rank);
434
- const rb = Number(b.rank);
435
- if (!Number.isNaN(ra) && !Number.isNaN(rb) && ra !== rb) return ra - rb;
436
- if (a.rank !== b.rank) return (a.rank ?? "").localeCompare(b.rank ?? "");
437
- return a.nodeId.localeCompare(b.nodeId);
438
- });
439
- const failed = sorted.find((n) => isNodeFailed(n.status));
440
- if (failed) return failed.nodeId;
441
- const active = sorted.find((n) => isNodeActive(n.status));
442
- if (active) return active.nodeId;
443
- const withOutput = sorted.find(
444
- (n) => (n.outputPreview || n.errorPreview) && isNodeFinished(n.status),
445
- );
446
- if (withOutput) return withOutput.nodeId;
447
- return sorted[0]?.nodeId ?? null;
448
- }
449
-
450
605
  function truncateText(text, maxLen) {
451
606
  if (!text) return "—";
452
607
  const normalized = String(text).replace(/\s+/g, " ").trim();
@@ -517,32 +672,50 @@ async function mergeSessionEvents(dagRunId, nodeId) {
517
672
  }
518
673
 
519
674
  function selectDagNode(dagRunId, nodeId) {
520
- if (selectedDagNodeId === nodeId) return;
675
+ const nodeChanged = selectedDagNodeId !== nodeId;
521
676
  selectedDagNodeId = nodeId;
522
- sessionEventOffset = 0;
523
- sessionEvents = [];
677
+ dagInspectorOpen = true;
678
+ if (nodeChanged) {
679
+ sessionEventOffset = 0;
680
+ sessionEvents = [];
681
+ }
524
682
  void renderDagDetail(dagRunId, false);
525
683
  }
526
684
 
527
- function renderSessionTimeline(nodeId) {
528
- const panel = document.getElementById("dag-timeline");
529
- const scrollEl = panel.querySelector(".process-timeline-scroll");
530
- const wasNearBottom = scrollEl
531
- ? scrollEl.scrollHeight - scrollEl.scrollTop - scrollEl.clientHeight < 48
532
- : true;
685
+ function closeDagInspector() {
686
+ dagInspectorOpen = false;
687
+ const panel = document.getElementById("dag-inspector");
688
+ if (panel) {
689
+ panel.classList.remove("is-open");
690
+ panel.setAttribute("aria-hidden", "true");
691
+ }
692
+ }
533
693
 
534
- clearNode(panel);
535
- panel.appendChild(el("h3", null, "执行过程"));
536
- if (nodeId) {
537
- panel.appendChild(el("p", "timeline-node-label", `节点:${nodeId}`));
694
+ function captureDagTimelineViewportState(panel) {
695
+ const scrollEl = panel.querySelector(".process-timeline-scroll");
696
+ if (scrollEl?.dataset.dagRunId && scrollEl.dataset.nodeId) {
697
+ const followLatest =
698
+ scrollEl.scrollHeight - scrollEl.scrollTop - scrollEl.clientHeight < 48;
699
+ dagTimelineViewportState = updateDagTimelineViewportState(
700
+ dagTimelineViewportState,
701
+ scrollEl.dataset.dagRunId,
702
+ scrollEl.dataset.nodeId,
703
+ scrollEl.scrollLeft,
704
+ scrollEl.scrollTop,
705
+ followLatest,
706
+ );
538
707
  }
708
+ }
539
709
 
710
+ function renderSessionTimeline(content, nodeId) {
711
+ content.dataset.dagRunId = currentDagRunId ?? "";
712
+ content.dataset.nodeId = nodeId ?? "";
713
+ content.classList.add("process-timeline-scroll");
540
714
  if (!nodeId || sessionEvents.length === 0) {
541
- panel.appendChild(el("p", "empty", UI_TEXT.noSessionEvents));
715
+ content.appendChild(el("p", "empty", UI_TEXT.noSessionEvents));
542
716
  return;
543
717
  }
544
718
 
545
- const container = el("div", "process-timeline-scroll");
546
719
  const ul = el("ul", "process-timeline");
547
720
  for (const event of sessionEvents) {
548
721
  const li = el("li", "process-timeline-item");
@@ -550,21 +723,43 @@ function renderSessionTimeline(nodeId) {
550
723
  li.appendChild(el("span", "process-timeline-label", formatSessionEventLabel(event)));
551
724
  ul.appendChild(li);
552
725
  }
553
- container.appendChild(ul);
554
- panel.appendChild(container);
555
-
556
- if (wasNearBottom) {
557
- container.scrollTop = container.scrollHeight;
726
+ content.appendChild(ul);
727
+
728
+ const timelineState =
729
+ dagTimelineViewportState?.dagRunId === currentDagRunId &&
730
+ dagTimelineViewportState.nodeId === nodeId
731
+ ? dagTimelineViewportState
732
+ : null;
733
+ content.addEventListener(
734
+ "scroll",
735
+ () => {
736
+ const followLatest =
737
+ content.scrollHeight - content.scrollTop - content.clientHeight < 48;
738
+ dagTimelineViewportState = updateDagTimelineViewportState(
739
+ dagTimelineViewportState,
740
+ currentDagRunId,
741
+ nodeId,
742
+ content.scrollLeft,
743
+ content.scrollTop,
744
+ followLatest,
745
+ );
746
+ },
747
+ { passive: true },
748
+ );
749
+ if (timelineState?.followLatest ?? true) {
750
+ content.scrollLeft = timelineState?.scrollLeft ?? 0;
751
+ content.scrollTop = content.scrollHeight;
752
+ } else {
753
+ content.scrollLeft = timelineState.scrollLeft;
754
+ content.scrollTop = timelineState.scrollTop;
558
755
  }
559
756
  }
560
757
 
561
- function isDagRunActive(dag) {
758
+ export function isDagRunActive(dag) {
562
759
  const status = (dag.status ?? "").toLowerCase();
563
- if (["running", "pending", "started"].includes(status)) return true;
564
- const hasActiveNode = (dag.nodes ?? []).some((n) => isNodeActive(n.status));
565
- if (hasActiveNode) return true;
566
760
  if (TERMINAL_RUN_STATUSES.has(status)) return false;
567
- return false;
761
+ if (["running", "pending", "started"].includes(status)) return true;
762
+ return (dag.nodes ?? []).some((n) => isNodeActive(n.status));
568
763
  }
569
764
 
570
765
  function dagProgress(dag) {
@@ -733,6 +928,7 @@ function renderDagGraph(dag, dagRunId) {
733
928
  wrap.appendChild(toolbar);
734
929
  if (edges.length === 0) wrap.appendChild(el("p", "dag-graph-fallback", "该运行没有可用依赖数据;图按并行层级展示,节点表格仍保留完整数据。"));
735
930
  const viewport = el("div", "dag-graph-viewport");
931
+ viewport.dataset.dagRunId = dagRunId;
736
932
  const svg = svgEl("svg", { class: "dag-graph", viewBox: `0 0 ${layout.width} ${layout.height}`, width: layout.width, height: layout.height, role: "img", "aria-label": `DAG 依赖图,共 ${nodes.length} 个节点` });
737
933
  const defs = svgEl("defs");
738
934
  const marker = svgEl("marker", { id: "dag-arrow", viewBox: "0 0 10 10", refX: 9, refY: 5, markerWidth: 7, markerHeight: 7, orient: "auto-start-reverse" });
@@ -760,17 +956,226 @@ function renderDagGraph(dag, dagRunId) {
760
956
  svg.appendChild(group);
761
957
  }
762
958
  viewport.appendChild(svg); wrap.appendChild(viewport);
763
- reset.addEventListener("click", () => { viewport.scrollTo({ left: 0, top: 0, behavior: "smooth" }); });
959
+ const graphViewportState =
960
+ dagGraphViewportStates.get(dagRunId) ??
961
+ (dagGraphViewportState?.dagRunId === dagRunId
962
+ ? dagGraphViewportState
963
+ : null);
964
+ viewport.addEventListener(
965
+ "scroll",
966
+ () => {
967
+ dagGraphViewportState = updateDagGraphViewportState(
968
+ dagGraphViewportState,
969
+ dagRunId,
970
+ viewport.scrollLeft,
971
+ viewport.scrollTop,
972
+ );
973
+ dagGraphViewportStates.set(dagRunId, dagGraphViewportState);
974
+ },
975
+ { passive: true },
976
+ );
977
+ if (graphViewportState) {
978
+ const restoreViewport = () => {
979
+ viewport.scrollLeft = graphViewportState.scrollLeft;
980
+ viewport.scrollTop = graphViewportState.scrollTop;
981
+ };
982
+ if (typeof requestAnimationFrame === "function") {
983
+ requestAnimationFrame(restoreViewport);
984
+ } else {
985
+ restoreViewport();
986
+ }
987
+ }
988
+ reset.addEventListener("click", () => {
989
+ dagGraphViewportState = updateDagGraphViewportState(
990
+ dagGraphViewportState,
991
+ dagRunId,
992
+ 0,
993
+ 0,
994
+ );
995
+ dagGraphViewportStates.set(dagRunId, dagGraphViewportState);
996
+ viewport.scrollTo({ left: 0, top: 0, behavior: "smooth" });
997
+ });
764
998
  return wrap;
765
999
  }
766
1000
 
767
- function expandablePreview(title, content, previewClass) {
768
- const wrap = el("details", previewClass ?? "output-preview");
769
- const summary = el("summary", null, title);
770
- wrap.appendChild(summary);
771
- const pre = el("pre", "output-block", content);
772
- wrap.appendChild(pre);
773
- return wrap;
1001
+ function appendInlineMarkdown(target, text) {
1002
+ const tokenPattern = /(`[^`]*`|\*\*[^*]+\*\*|\*[^*]+\*|\[[^\]]+\]\([^)]+\))/g;
1003
+ let cursor = 0;
1004
+ for (const match of text.matchAll(tokenPattern)) {
1005
+ const index = match.index ?? 0;
1006
+ if (index > cursor) target.appendChild(document.createTextNode(text.slice(cursor, index)));
1007
+ const token = match[0];
1008
+ if (token.startsWith("`")) {
1009
+ target.appendChild(el("code", null, token.slice(1, -1)));
1010
+ } else if (token.startsWith("**")) {
1011
+ const strong = document.createElement("strong");
1012
+ strong.textContent = token.slice(2, -2);
1013
+ target.appendChild(strong);
1014
+ } else if (token.startsWith("*")) {
1015
+ const emphasis = document.createElement("em");
1016
+ emphasis.textContent = token.slice(1, -1);
1017
+ target.appendChild(emphasis);
1018
+ } else {
1019
+ const link = token.match(/^\[([^\]]+)\]\((.+)\)$/);
1020
+ const href = markdownLinkHref(link?.[2]);
1021
+ if (link && href) {
1022
+ const anchor = document.createElement("a");
1023
+ anchor.href = href;
1024
+ anchor.target = "_blank";
1025
+ anchor.rel = "noopener noreferrer";
1026
+ anchor.textContent = link[1];
1027
+ target.appendChild(anchor);
1028
+ } else {
1029
+ target.appendChild(document.createTextNode(token));
1030
+ }
1031
+ }
1032
+ cursor = index + token.length;
1033
+ }
1034
+ if (cursor < text.length) target.appendChild(document.createTextNode(text.slice(cursor)));
1035
+ }
1036
+
1037
+ function renderMarkdown(content) {
1038
+ const article = el("article", "markdown-output");
1039
+ for (const block of parseMarkdownBlocks(content)) {
1040
+ switch (block.type) {
1041
+ case "heading": {
1042
+ const heading = document.createElement(`h${block.level}`);
1043
+ appendInlineMarkdown(heading, block.text);
1044
+ article.appendChild(heading);
1045
+ break;
1046
+ }
1047
+ case "paragraph": {
1048
+ const paragraph = document.createElement("p");
1049
+ appendInlineMarkdown(paragraph, block.text);
1050
+ article.appendChild(paragraph);
1051
+ break;
1052
+ }
1053
+ case "quote": {
1054
+ const quote = document.createElement("blockquote");
1055
+ appendInlineMarkdown(quote, block.text);
1056
+ article.appendChild(quote);
1057
+ break;
1058
+ }
1059
+ case "list": {
1060
+ const list = document.createElement(block.ordered ? "ol" : "ul");
1061
+ for (const item of block.items) {
1062
+ const listItem = document.createElement("li");
1063
+ appendInlineMarkdown(listItem, item);
1064
+ list.appendChild(listItem);
1065
+ }
1066
+ article.appendChild(list);
1067
+ break;
1068
+ }
1069
+ case "code": {
1070
+ const pre = document.createElement("pre");
1071
+ const code = document.createElement("code");
1072
+ if (block.language) code.dataset.language = block.language;
1073
+ code.textContent = block.text;
1074
+ pre.appendChild(code);
1075
+ article.appendChild(pre);
1076
+ break;
1077
+ }
1078
+ case "table": {
1079
+ const table = document.createElement("table");
1080
+ const head = document.createElement("thead");
1081
+ const headRow = document.createElement("tr");
1082
+ for (const value of block.headers) {
1083
+ const cell = document.createElement("th");
1084
+ appendInlineMarkdown(cell, value);
1085
+ headRow.appendChild(cell);
1086
+ }
1087
+ head.appendChild(headRow);
1088
+ table.appendChild(head);
1089
+ const body = document.createElement("tbody");
1090
+ for (const row of block.rows) {
1091
+ const tableRow = document.createElement("tr");
1092
+ for (const value of row) {
1093
+ const cell = document.createElement("td");
1094
+ appendInlineMarkdown(cell, value);
1095
+ tableRow.appendChild(cell);
1096
+ }
1097
+ body.appendChild(tableRow);
1098
+ }
1099
+ table.appendChild(body);
1100
+ article.appendChild(table);
1101
+ break;
1102
+ }
1103
+ case "rule":
1104
+ article.appendChild(document.createElement("hr"));
1105
+ break;
1106
+ }
1107
+ }
1108
+ return article;
1109
+ }
1110
+
1111
+ function renderDagInspector(dagRunId, node) {
1112
+ const panel = document.getElementById("dag-inspector");
1113
+ if (!panel) return;
1114
+ captureDagTimelineViewportState(panel);
1115
+ if (!dagInspectorOpen || !node) {
1116
+ panel.classList.remove("is-open");
1117
+ panel.setAttribute("aria-hidden", "true");
1118
+ return;
1119
+ }
1120
+
1121
+ clearNode(panel);
1122
+ panel.classList.add("is-open");
1123
+ panel.setAttribute("aria-hidden", "false");
1124
+ const header = el("div", "dag-inspector-header");
1125
+ const title = el("div", "dag-inspector-title");
1126
+ title.appendChild(el("h3", null, "节点检查器"));
1127
+ title.appendChild(el("div", "dag-inspector-node", node.nodeId));
1128
+ header.appendChild(title);
1129
+ const controls = el("div", "dag-inspector-controls");
1130
+ controls.appendChild(badge(node.status ?? "unknown"));
1131
+ const close = document.createElement("button");
1132
+ close.type = "button";
1133
+ close.className = "dag-inspector-close";
1134
+ close.setAttribute("aria-label", "关闭节点检查器");
1135
+ const closeIcon = el("i", "ri-close-line");
1136
+ closeIcon.setAttribute("aria-hidden", "true");
1137
+ close.appendChild(closeIcon);
1138
+ close.addEventListener("click", closeDagInspector);
1139
+ controls.appendChild(close);
1140
+ header.appendChild(controls);
1141
+
1142
+ const tabs = el("div", "dag-inspector-tabs");
1143
+ tabs.setAttribute("role", "tablist");
1144
+ for (const [tab, label, icon] of [["output", "节点输出", "ri-file-text-line"], ["timeline", "执行过程", "ri-route-line"]]) {
1145
+ const button = document.createElement("button");
1146
+ button.type = "button";
1147
+ button.className = "dag-inspector-tab";
1148
+ button.setAttribute("role", "tab");
1149
+ button.setAttribute("aria-selected", String(dagInspectorTab === tab));
1150
+ const tabIcon = el("i", icon);
1151
+ tabIcon.setAttribute("aria-hidden", "true");
1152
+ button.appendChild(tabIcon);
1153
+ button.appendChild(document.createTextNode(` ${label}`));
1154
+ button.addEventListener("click", () => {
1155
+ dagInspectorTab = tab;
1156
+ renderDagInspector(dagRunId, node);
1157
+ });
1158
+ tabs.appendChild(button);
1159
+ }
1160
+ header.appendChild(tabs);
1161
+ panel.appendChild(header);
1162
+
1163
+ const content = el("div", "dag-inspector-content");
1164
+ content.setAttribute("role", "tabpanel");
1165
+ if (dagInspectorTab === "timeline") {
1166
+ renderSessionTimeline(content, node.nodeId);
1167
+ } else if (!node.outputPreview && !node.errorPreview) {
1168
+ content.appendChild(el("p", "empty", UI_TEXT.noOutput));
1169
+ } else {
1170
+ if (node.outputPreview) content.appendChild(renderMarkdown(node.outputPreview));
1171
+ if (node.errorPreview) {
1172
+ const error = el("section", "markdown-error");
1173
+ error.appendChild(el("p", "markdown-error-title", "错误输出"));
1174
+ error.appendChild(renderMarkdown(node.errorPreview));
1175
+ content.appendChild(error);
1176
+ }
1177
+ }
1178
+ panel.appendChild(content);
774
1179
  }
775
1180
 
776
1181
  async function renderFailures() {
@@ -838,6 +1243,7 @@ async function renderDashboard(scrollTo) {
838
1243
 
839
1244
  const kpiEl = document.getElementById("dashboard-kpi");
840
1245
  const activeEl = document.getElementById("dashboard-active-dags");
1246
+ const riskEl = document.getElementById("dashboard-risk");
841
1247
  const dagsEl = document.getElementById("dashboard-dags");
842
1248
  const batchesEl = document.getElementById("dashboard-batches");
843
1249
 
@@ -855,13 +1261,19 @@ async function renderDashboard(scrollTo) {
855
1261
  .slice(0, 10);
856
1262
 
857
1263
  clearNode(kpiEl);
1264
+ const quietCount = snapshot.health?.quietCount ?? 0;
1265
+ const staleCount = snapshot.health?.staleCount ?? 0;
1266
+ const timeoutRiskCount = snapshot.health?.timeoutRiskCount ?? 0;
1267
+ const failuresCount = snapshot.health?.failuresCount ?? 0;
858
1268
  kpiEl.appendChild(kpiCard("活跃 DAG", activeDags.length, "kpi-highlight"));
859
1269
  kpiEl.appendChild(kpiCard("活跃 Task", snapshot.health?.activeTasks ?? 0));
860
- kpiEl.appendChild(kpiCard("无输出", snapshot.health?.quietCount ?? 0));
861
- kpiEl.appendChild(kpiCard("心跳失联", snapshot.health?.staleCount ?? 0));
862
- kpiEl.appendChild(kpiCard("即将超时", snapshot.health?.timeoutRiskCount ?? 0));
1270
+ kpiEl.appendChild(kpiCard("无输出", quietCount, quietCount > 0 ? "kpi-muted" : ""));
1271
+ kpiEl.appendChild(kpiCard("心跳失联", staleCount, staleCount > 0 ? "kpi-risk" : ""));
1272
+ kpiEl.appendChild(
1273
+ kpiCard("即将超时", timeoutRiskCount, timeoutRiskCount > 0 ? "kpi-risk" : ""),
1274
+ );
863
1275
  kpiEl.appendChild(
864
- kpiCard("失败", snapshot.health?.failuresCount ?? 0, "kpi-danger"),
1276
+ kpiCard("失败", failuresCount, failuresCount > 0 ? "kpi-danger" : ""),
865
1277
  );
866
1278
 
867
1279
  const nowMs = Date.now();
@@ -880,6 +1292,30 @@ async function renderDashboard(scrollTo) {
880
1292
  activeEl.appendChild(list);
881
1293
  }
882
1294
 
1295
+ const riskCount = failuresCount + staleCount + timeoutRiskCount;
1296
+ clearNode(riskEl);
1297
+ riskEl.classList.toggle("panel-risk-attention", riskCount > 0);
1298
+ riskEl.appendChild(el("h3", "section-heading", "运行风险"));
1299
+ if (riskCount === 0) {
1300
+ riskEl.appendChild(el("p", "risk-empty", "当前没有需要处理的异常。"));
1301
+ } else {
1302
+ riskEl.appendChild(el("div", "risk-total", String(riskCount)));
1303
+ riskEl.appendChild(
1304
+ el(
1305
+ "p",
1306
+ "risk-summary",
1307
+ `失败 ${failuresCount} · 心跳失联 ${staleCount} · 即将超时 ${timeoutRiskCount}`,
1308
+ ),
1309
+ );
1310
+ const link = el("a", "risk-link", "查看异常 Task →");
1311
+ link.href = "#/failures";
1312
+ link.addEventListener("click", (event) => {
1313
+ event.preventDefault();
1314
+ navigate("/failures");
1315
+ });
1316
+ riskEl.appendChild(link);
1317
+ }
1318
+
883
1319
  clearNode(dagsEl);
884
1320
  dagsEl.id = "dashboard-dags";
885
1321
  dagsEl.appendChild(el("h3", "section-heading", UI_TEXT.recentDags));
@@ -887,24 +1323,45 @@ async function renderDashboard(scrollTo) {
887
1323
  dagsEl.appendChild(el("p", "empty", UI_TEXT.noRecentDags));
888
1324
  } else if (recentDags.length === 0) {
889
1325
  dagsEl.appendChild(el("p", "empty", "暂无历史 DAG 记录"));
890
- } else {
891
- const rows = recentDags.map((dag) => {
892
- const progress = dagProgress(dag);
893
- return {
894
- onClick: () => navigate(`/dag/${encodeURIComponent(dag.dagRunId)}`),
895
- cells: [
896
- dag.title || dag.dagRunId,
897
- badge(dag.status),
898
- `${progress.finished}/${progress.total}`,
899
- formatDuration(dag.durationMs),
900
- formatTs(dag.startedAt),
901
- ],
902
- };
903
- });
904
- dagsEl.appendChild(
905
- buildTable(["标题", "状态", "进度", "耗时", "开始时间"], rows),
906
- );
907
- }
1326
+ } else {
1327
+ const timeline = el("div", "dag-run-timeline");
1328
+ for (const dag of recentDags) {
1329
+ const progress = dagProgress(dag);
1330
+ const item = el("a", "dag-run-timeline-item");
1331
+ item.href = `#/dag/${encodeURIComponent(dag.dagRunId)}`;
1332
+ item.title = dag.title || dag.dagRunId;
1333
+ item.addEventListener("click", (event) => {
1334
+ event.preventDefault();
1335
+ navigate(`/dag/${encodeURIComponent(dag.dagRunId)}`);
1336
+ });
1337
+
1338
+ const rail = el("span", "dag-run-timeline-rail");
1339
+ rail.appendChild(
1340
+ el(
1341
+ "span",
1342
+ `dag-run-timeline-dot status-${String(dag.status ?? "unknown").toLowerCase()}`,
1343
+ ),
1344
+ );
1345
+ item.appendChild(rail);
1346
+
1347
+ const content = el("span", "dag-run-timeline-content");
1348
+ content.appendChild(el("span", "dag-run-timeline-title", dag.title || dag.dagRunId));
1349
+ content.appendChild(
1350
+ el(
1351
+ "span",
1352
+ "dag-run-timeline-meta",
1353
+ `${formatTs(dag.startedAt)} · ${progress.finished}/${progress.total} · ${formatDuration(dag.durationMs)}`,
1354
+ ),
1355
+ );
1356
+ item.appendChild(content);
1357
+
1358
+ const state = el("span", "dag-run-timeline-status");
1359
+ state.appendChild(badge(dag.status));
1360
+ item.appendChild(state);
1361
+ timeline.appendChild(item);
1362
+ }
1363
+ dagsEl.appendChild(timeline);
1364
+ }
908
1365
 
909
1366
  clearNode(batchesEl);
910
1367
  batchesEl.appendChild(el("h3", "section-heading", UI_TEXT.batches));
@@ -927,15 +1384,6 @@ async function renderDashboard(scrollTo) {
927
1384
  );
928
1385
  }
929
1386
 
930
- const failuresFooter = el("p", "dashboard-footer");
931
- failuresFooter.appendChild(document.createTextNode("失败/失联合计:"));
932
- failuresFooter.appendChild(
933
- failuresInboxLink(
934
- (snapshot.health?.failuresCount ?? 0) + (snapshot.health?.staleCount ?? 0),
935
- ),
936
- );
937
- batchesEl.appendChild(failuresFooter);
938
-
939
1387
  if (scrollTo === "dags") {
940
1388
  dagsEl.scrollIntoView({ behavior: "smooth" });
941
1389
  }
@@ -1287,7 +1735,7 @@ async function renderRun(workerRunId, initial = true) {
1287
1735
  .getElementById("run-meta")
1288
1736
  .appendChild(el("p", "empty", "找不到该运行。"));
1289
1737
  }
1290
- return;
1738
+ return false;
1291
1739
  }
1292
1740
 
1293
1741
  if (snapshot) {
@@ -1298,13 +1746,22 @@ async function renderRun(workerRunId, initial = true) {
1298
1746
  currentBatchRunId = task.batchRunId ?? currentBatchRunId;
1299
1747
  await mergeRunEvents(workerRunId);
1300
1748
  renderRunPanels(task, runEvents, snapshot ?? lastSnapshot);
1301
- }
1302
-
1303
- function startRunPolling(workerRunId) {
1304
- if (runPollTimer) clearInterval(runPollTimer);
1305
- runPollTimer = setInterval(() => {
1306
- void renderRun(workerRunId, false);
1307
- }, POLL_MS);
1749
+ return !TERMINAL_RUN_STATUSES.has((task.status ?? "").toLowerCase());
1750
+ }
1751
+
1752
+ function startRunPolling(workerRunId, initial = true) {
1753
+ if (runPollTimer) clearTimeout(runPollTimer);
1754
+ const generation = ++pollingGeneration;
1755
+ const poll = async (isInitial) => {
1756
+ const active = await renderRun(workerRunId, isInitial);
1757
+ if (generation !== pollingGeneration || !active) return;
1758
+ const delay = detailPollDelay("running", isPageVisible());
1759
+ runPollTimer = setTimeout(() => {
1760
+ runPollTimer = null;
1761
+ void poll(false);
1762
+ }, delay);
1763
+ };
1764
+ void poll(initial);
1308
1765
  }
1309
1766
 
1310
1767
  async function renderDagDetail(dagRunId, initial = true) {
@@ -1319,24 +1776,36 @@ async function renderDagDetail(dagRunId, initial = true) {
1319
1776
  selectedDagNodeId = null;
1320
1777
  sessionEventOffset = 0;
1321
1778
  sessionEvents = [];
1779
+ dagInspectorOpen = false;
1780
+ dagInspectorTab = "output";
1781
+ dagGraphViewportState = null;
1782
+ dagTimelineViewportState = null;
1322
1783
  }
1323
1784
 
1324
1785
  const metaEl = document.getElementById("dag-meta");
1325
1786
  const ranksEl = document.getElementById("dag-ranks");
1326
1787
  const nodesEl = document.getElementById("dag-nodes");
1327
- const outputEl = document.getElementById("dag-output");
1788
+ dagGraphViewportState = captureDagGraphViewportState(
1789
+ dagGraphViewportState,
1790
+ dagRunId,
1791
+ ranksEl.querySelector(".dag-graph-viewport"),
1792
+ );
1793
+ if (dagGraphViewportState?.dagRunId === dagRunId) {
1794
+ dagGraphViewportStates.set(dagRunId, dagGraphViewportState);
1795
+ }
1328
1796
 
1329
1797
  const dag = await fetchJson(`/api/dag-runs/${encodeURIComponent(dagRunId)}`);
1330
1798
  if (!dag) {
1331
1799
  clearNode(metaEl);
1332
1800
  metaEl.appendChild(el("p", "empty", "找不到该 DAG 运行。"));
1333
- clearNode(document.getElementById("dag-timeline"));
1334
- return;
1801
+ closeDagInspector();
1802
+ return false;
1335
1803
  }
1336
1804
 
1337
1805
  const nodes = dag.nodes ?? [];
1338
- if (!selectedDagNodeId || !nodes.some((n) => n.nodeId === selectedDagNodeId)) {
1339
- selectedDagNodeId = pickDefaultDagNode(nodes);
1806
+ if (selectedDagNodeId && !nodes.some((n) => n.nodeId === selectedDagNodeId)) {
1807
+ selectedDagNodeId = null;
1808
+ dagInspectorOpen = false;
1340
1809
  if (initial) {
1341
1810
  sessionEventOffset = 0;
1342
1811
  sessionEvents = [];
@@ -1375,7 +1844,10 @@ async function renderDagDetail(dagRunId, initial = true) {
1375
1844
  }
1376
1845
 
1377
1846
  clearNode(nodesEl);
1378
- nodesEl.appendChild(el("h3", null, "节点"));
1847
+ const nodeHeading = el("div", "dag-section-heading");
1848
+ nodeHeading.appendChild(el("h3", null, "节点"));
1849
+ nodeHeading.appendChild(el("span", "dag-section-meta", `共 ${nodes.length} 个节点`));
1850
+ nodesEl.appendChild(nodeHeading);
1379
1851
 
1380
1852
  if (nodes.length === 0) {
1381
1853
  nodesEl.appendChild(el("p", "empty", UI_TEXT.noNodes));
@@ -1411,62 +1883,69 @@ async function renderDagDetail(dagRunId, initial = true) {
1411
1883
  ],
1412
1884
  };
1413
1885
  });
1414
- nodesEl.appendChild(
1415
- buildTable(
1416
- ["节点 ID", "层级", "执行方式", "模型", "状态", "耗时", "备注"],
1417
- rows,
1418
- ),
1886
+ const nodeTable = buildTable(
1887
+ ["节点 ID", "依赖层", "执行方式", "模型", "状态", "耗时", "备注"],
1888
+ rows,
1419
1889
  );
1420
- }
1421
-
1422
- await mergeSessionEvents(dagRunId, selectedDagNodeId);
1423
- renderSessionTimeline(selectedDagNodeId);
1424
-
1425
- clearNode(outputEl);
1426
- outputEl.appendChild(el("h3", null, "节点输出"));
1427
- const withOutput = nodes.filter((n) => n.outputPreview || n.errorPreview);
1428
- if (withOutput.length === 0) {
1429
- outputEl.appendChild(el("p", "empty", UI_TEXT.noOutput));
1430
- } else {
1431
- for (const node of withOutput) {
1432
- if (node.outputPreview) {
1433
- outputEl.appendChild(
1434
- expandablePreview(`${node.nodeId} · 输出`, node.outputPreview),
1435
- );
1436
- }
1437
- if (node.errorPreview) {
1438
- outputEl.appendChild(
1439
- expandablePreview(
1440
- `${node.nodeId} · 错误`,
1441
- node.errorPreview,
1442
- "output-preview error-preview",
1443
- ),
1444
- );
1890
+ nodeTable.classList.add("dag-node-table");
1891
+ for (const row of nodeTable.tBodies[0]?.rows ?? []) {
1892
+ for (const columnIndex of [0, 3, 6]) {
1893
+ const cell = row.cells[columnIndex];
1894
+ if (cell?.textContent) cell.title = cell.textContent;
1445
1895
  }
1446
1896
  }
1897
+ nodesEl.appendChild(nodeTable);
1447
1898
  }
1448
- }
1449
1899
 
1450
- function startDagPolling(dagRunId) {
1451
- if (dagPollTimer) clearInterval(dagPollTimer);
1452
- dagPollTimer = setInterval(() => {
1453
- void renderDagDetail(dagRunId, false);
1454
- }, POLL_MS);
1900
+ if (dagInspectorOpen && selectedDagNodeId) {
1901
+ await mergeSessionEvents(dagRunId, selectedDagNodeId);
1902
+ }
1903
+ renderDagInspector(
1904
+ dagRunId,
1905
+ nodes.find((node) => node.nodeId === selectedDagNodeId),
1906
+ );
1907
+ return isDagRunActive(dag);
1908
+ }
1909
+
1910
+ function startDagPolling(dagRunId, initial = true) {
1911
+ if (dagPollTimer) clearTimeout(dagPollTimer);
1912
+ const generation = ++pollingGeneration;
1913
+ const poll = async (isInitial) => {
1914
+ const active = await renderDagDetail(dagRunId, isInitial);
1915
+ if (generation !== pollingGeneration || !active) return;
1916
+ const delay = detailPollDelay("running", isPageVisible());
1917
+ dagPollTimer = setTimeout(() => {
1918
+ dagPollTimer = null;
1919
+ void poll(false);
1920
+ }, delay);
1921
+ };
1922
+ void poll(initial);
1923
+ }
1924
+
1925
+ function startDashboardPolling(scrollTo) {
1926
+ if (dashboardTimer) clearTimeout(dashboardTimer);
1927
+ const generation = ++pollingGeneration;
1928
+ const poll = async (initialScrollTo) => {
1929
+ await renderDashboard(initialScrollTo);
1930
+ if (generation !== pollingGeneration) return;
1931
+ dashboardTimer = setTimeout(() => {
1932
+ dashboardTimer = null;
1933
+ void poll();
1934
+ }, dashboardPollDelay());
1935
+ };
1936
+ void poll(scrollTo);
1455
1937
  }
1456
1938
 
1457
1939
  function route() {
1458
1940
  stopTimers();
1459
1941
  const r = parseRoute();
1460
1942
  if (r.view === "dashboard") {
1461
- void renderDashboard(r.scrollTo);
1462
- dashboardTimer = setInterval(() => void renderDashboard(), DASHBOARD_POLL_MS);
1943
+ startDashboardPolling(r.scrollTo);
1463
1944
  } else if (r.view === "batch") {
1464
1945
  void renderBatch(r.batchRunId);
1465
1946
  } else if (r.view === "run") {
1466
- void renderRun(r.workerRunId, true);
1467
1947
  startRunPolling(r.workerRunId);
1468
1948
  } else if (r.view === "dag") {
1469
- void renderDagDetail(r.dagRunId, true);
1470
1949
  startDagPolling(r.dagRunId);
1471
1950
  } else if (r.view === "failures") {
1472
1951
  void renderFailures();
@@ -1476,4 +1955,11 @@ function route() {
1476
1955
  if (typeof window !== "undefined") {
1477
1956
  window.addEventListener("hashchange", route);
1478
1957
  window.addEventListener("load", route);
1958
+ document.addEventListener("visibilitychange", () => {
1959
+ if (!isPageVisible()) return;
1960
+ const r = parseRoute();
1961
+ if (r.view === "dashboard") startDashboardPolling();
1962
+ if (r.view === "run") startRunPolling(r.workerRunId, false);
1963
+ if (r.view === "dag") startDagPolling(r.dagRunId, false);
1964
+ });
1479
1965
  }