niuma-codex 0.0.0-stage → 0.0.1-beta.10
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.
- package/LICENSE +202 -0
- package/README.md +68 -2
- package/THIRD_PARTY_NOTICES +10 -0
- package/bin/niuma.js +3 -0
- package/dist/assets/control/app.js +1858 -0
- package/dist/assets/control/index.html +309 -0
- package/dist/assets/pairing-web/PHOSPHOR-LICENSE.txt +21 -0
- package/dist/assets/pairing-web/app.js +180 -0
- package/dist/assets/pairing-web/index.html +28 -0
- package/dist/assets/pairing-web/settings.js +66 -0
- package/dist/assets/pairing-web/state.js +28 -0
- package/dist/assets/pairing-web/style.css +98 -0
- package/dist/cli.js +87530 -0
- package/package.json +38 -4
|
@@ -0,0 +1,1858 @@
|
|
|
1
|
+
(() => {
|
|
2
|
+
const overview = document.getElementById("overview");
|
|
3
|
+
const emptyState = document.getElementById("empty-state");
|
|
4
|
+
const controlPageTitle = document.getElementById("control-page-title");
|
|
5
|
+
const controlPageIntro = document.getElementById("control-page-intro");
|
|
6
|
+
const refreshButton = document.getElementById("refresh-control");
|
|
7
|
+
const refreshStatus = document.getElementById("refresh-status");
|
|
8
|
+
const turnAuditCoverage = document.getElementById("turn-audit-coverage");
|
|
9
|
+
const turnAuditSummary = document.getElementById("turn-audit-summary");
|
|
10
|
+
const turnAuditList = document.getElementById("turn-audit-list");
|
|
11
|
+
const turnAuditEmpty = document.getElementById("turn-audit-empty");
|
|
12
|
+
const turnAuditLoadMoreSessionsButton = document.getElementById("turn-audit-load-more-sessions");
|
|
13
|
+
const sessionsList = document.getElementById("sessions-list");
|
|
14
|
+
const sessionsEmpty = document.getElementById("sessions-empty");
|
|
15
|
+
const followerTracesList = document.getElementById("follower-traces-list");
|
|
16
|
+
const followerTracesEmpty = document.getElementById("follower-traces-empty");
|
|
17
|
+
const followerTracesCoverage = document.getElementById("follower-traces-coverage");
|
|
18
|
+
const clearFollowerTracesButton = document.getElementById("clear-follower-traces");
|
|
19
|
+
const logsList = document.getElementById("logs-list");
|
|
20
|
+
const logsEmpty = document.getElementById("logs-empty");
|
|
21
|
+
const errorLogTab = document.getElementById("error-log-tab");
|
|
22
|
+
const debugLogTab = document.getElementById("debug-log-tab");
|
|
23
|
+
const loadMoreLogsButton = document.getElementById("load-more-logs");
|
|
24
|
+
const daemonLifecycleDetails = document.getElementById("daemon-lifecycle-details");
|
|
25
|
+
const daemonStartButton = document.getElementById("daemon-start");
|
|
26
|
+
const daemonStopButton = document.getElementById("daemon-stop");
|
|
27
|
+
const daemonRestartButton = document.getElementById("daemon-restart");
|
|
28
|
+
const daemonActionConfirmationLabel = document.getElementById("daemon-action-confirmation-label");
|
|
29
|
+
const daemonActionConfirmationInput = document.getElementById("daemon-action-confirmation");
|
|
30
|
+
const daemonActionSubmitButton = document.getElementById("daemon-action-submit");
|
|
31
|
+
const daemonMetricsStatus = document.getElementById("daemon-metrics-status");
|
|
32
|
+
const daemonMetricsSubtitle = document.getElementById("daemon-metrics-subtitle");
|
|
33
|
+
const daemonMetricsSummary = document.getElementById("daemon-metrics-summary");
|
|
34
|
+
const daemonMetricsEmpty = document.getElementById("daemon-metrics-empty");
|
|
35
|
+
const daemonCpuChart = document.getElementById("daemon-cpu-chart");
|
|
36
|
+
const daemonMemoryChart = document.getElementById("daemon-memory-chart");
|
|
37
|
+
const daemonCpuChartTitle = document.getElementById("daemon-cpu-chart-title");
|
|
38
|
+
const daemonMemoryChartTitle = document.getElementById("daemon-memory-chart-title");
|
|
39
|
+
const daemonMetricsRangeButtons = [
|
|
40
|
+
document.getElementById("daemon-metrics-range-5m"),
|
|
41
|
+
document.getElementById("daemon-metrics-range-15m"),
|
|
42
|
+
document.getElementById("daemon-metrics-range-1h"),
|
|
43
|
+
document.getElementById("daemon-metrics-range-6h"),
|
|
44
|
+
document.getElementById("daemon-metrics-range-24h"),
|
|
45
|
+
document.getElementById("daemon-metrics-range-48h"),
|
|
46
|
+
].filter(Boolean);
|
|
47
|
+
const connectionsCurrent = document.getElementById("connections-current");
|
|
48
|
+
const connectionsStatus = document.getElementById("connections-status");
|
|
49
|
+
const connectionsEmpty = document.getElementById("connections-empty");
|
|
50
|
+
const CONTROL_PAGES = Object.freeze({
|
|
51
|
+
"/connections": { key: "connections", title: "外部连接", intro: "查看当前设备连接与额度占用,页面可见时每 3 秒刷新。", sectionId: "control-page-connections", navId: "control-nav-connections" },
|
|
52
|
+
"/": { key: "overview", title: "概览", intro: "查看当前设备上的 daemon 运行概览和生命周期。", sectionId: "control-page-overview", navId: "control-nav-overview" },
|
|
53
|
+
"/sessions": { key: "sessions", title: "会话跟踪", intro: "查看 follower 生命周期、共享订阅和租约快照。", sectionId: "control-page-sessions", navId: "control-nav-sessions" },
|
|
54
|
+
"/subscriptions": { key: "subscriptions", title: "会话订阅", intro: "查看最近 24 小时的会话订阅 Trace。", sectionId: "control-page-subscriptions", navId: "control-nav-subscriptions" },
|
|
55
|
+
"/turn-audit": { key: "turn-audit", title: "Turn 推送审计", intro: "查看最近 24 小时的安全投递证据。", sectionId: "control-page-turn-audit", navId: "control-nav-turn-audit" },
|
|
56
|
+
"/logs": { key: "logs", title: "诊断日志", intro: "查看安全投影后的错误与调试日志。", sectionId: "control-page-logs", navId: "control-nav-logs" },
|
|
57
|
+
});
|
|
58
|
+
const currentPage = CONTROL_PAGES[window.location.pathname];
|
|
59
|
+
if (!currentPage) return;
|
|
60
|
+
if (
|
|
61
|
+
!overview
|
|
62
|
+
|| !emptyState
|
|
63
|
+
|| !refreshButton
|
|
64
|
+
|| !refreshStatus
|
|
65
|
+
|| !controlPageTitle
|
|
66
|
+
|| !controlPageIntro
|
|
67
|
+
|| !sessionsList
|
|
68
|
+
|| !sessionsEmpty
|
|
69
|
+
|| !logsList
|
|
70
|
+
|| !logsEmpty
|
|
71
|
+
|| !errorLogTab
|
|
72
|
+
|| !debugLogTab
|
|
73
|
+
|| !loadMoreLogsButton
|
|
74
|
+
) return;
|
|
75
|
+
|
|
76
|
+
// 路由只显示并读取其所属模块,避免控制台同时读取无关的本机管理快照。
|
|
77
|
+
for (const page of Object.values(CONTROL_PAGES)) {
|
|
78
|
+
const section = document.getElementById(page.sectionId);
|
|
79
|
+
const navigationLink = document.getElementById(page.navId);
|
|
80
|
+
if (!section || !navigationLink) return;
|
|
81
|
+
section.hidden = page.key !== currentPage.key;
|
|
82
|
+
const isCurrentPage = page.key === currentPage.key;
|
|
83
|
+
if (isCurrentPage) navigationLink.setAttribute("aria-current", "page");
|
|
84
|
+
else navigationLink.removeAttribute("aria-current");
|
|
85
|
+
toggleBootstrapClass(navigationLink, "active", isCurrentPage);
|
|
86
|
+
}
|
|
87
|
+
controlPageTitle.textContent = currentPage.title;
|
|
88
|
+
controlPageIntro.textContent = currentPage.intro;
|
|
89
|
+
|
|
90
|
+
// 旧的最小 DOM 测试夹具不含 Trace 区域;真实静态页面始终完整启用此功能。
|
|
91
|
+
const followerTracesEnabled = Boolean(
|
|
92
|
+
followerTracesList && followerTracesEmpty && followerTracesCoverage,
|
|
93
|
+
);
|
|
94
|
+
const turnAuditAvailable = Boolean(
|
|
95
|
+
turnAuditCoverage
|
|
96
|
+
&& turnAuditSummary
|
|
97
|
+
&& turnAuditList
|
|
98
|
+
&& turnAuditEmpty,
|
|
99
|
+
);
|
|
100
|
+
const daemonMetricsEnabled = Boolean(
|
|
101
|
+
daemonMetricsStatus
|
|
102
|
+
&& daemonMetricsSubtitle
|
|
103
|
+
&& daemonMetricsSummary
|
|
104
|
+
&& daemonMetricsEmpty
|
|
105
|
+
&& daemonCpuChart
|
|
106
|
+
&& daemonMemoryChart
|
|
107
|
+
&& daemonCpuChartTitle
|
|
108
|
+
&& daemonMemoryChartTitle,
|
|
109
|
+
);
|
|
110
|
+
|
|
111
|
+
let selectedLogKind = "error";
|
|
112
|
+
let intendedLogKind = "error";
|
|
113
|
+
const logSnapshots = { error: null, debug: null };
|
|
114
|
+
const logRequestGenerations = { error: 0, debug: 0 };
|
|
115
|
+
let controlRefreshGeneration = 0;
|
|
116
|
+
let daemonLifecycleOperation = null;
|
|
117
|
+
let selectedDaemonAction = null;
|
|
118
|
+
let daemonActionBusy = false;
|
|
119
|
+
const turnAuditEventsButton = document.getElementById("turn-audit-view-events");
|
|
120
|
+
const turnAuditSessionsButton = document.getElementById("turn-audit-view-sessions");
|
|
121
|
+
const turnAuditEventCount = document.getElementById("turn-audit-event-count");
|
|
122
|
+
const turnAuditHistoryNote = document.getElementById("turn-audit-history-note");
|
|
123
|
+
let turnAuditView = turnAuditEventsButton ? "events" : "sessions";
|
|
124
|
+
const triggerFilters = document.getElementById("trigger-filters");
|
|
125
|
+
const triggerTypeSelect = document.getElementById("trigger-filter-type");
|
|
126
|
+
const triggerFilterNames = ["all", "attention", "processing", "accepted", "recovered"];
|
|
127
|
+
let triggerStatus = "all";
|
|
128
|
+
let triggerType = "all";
|
|
129
|
+
let triggerSnapshot = null;
|
|
130
|
+
let triggerGeneration = 0;
|
|
131
|
+
let triggerBusy = false;
|
|
132
|
+
let triggerError = false;
|
|
133
|
+
let triggerExhausted = false;
|
|
134
|
+
let turnAuditSnapshot = null;
|
|
135
|
+
let turnAuditListGeneration = 0;
|
|
136
|
+
let turnAuditSessionPageRequest = null;
|
|
137
|
+
let turnAuditRefreshPending = false;
|
|
138
|
+
const turnAuditDetailGenerations = new Map();
|
|
139
|
+
const turnAuditDetails = new Map();
|
|
140
|
+
const turnAuditDetailRequests = new Set();
|
|
141
|
+
const daemonActionConfirmations = { stop: "STOP", restart: "RESTART" };
|
|
142
|
+
const DAEMON_METRICS_RANGE_LABELS = Object.freeze({ "5m": "5 分钟", "15m": "15 分钟", "1h": "1 小时", "6h": "6 小时", "24h": "24 小时", "48h": "48 小时" });
|
|
143
|
+
let selectedDaemonMetricsRange = "15m";
|
|
144
|
+
let daemonMetricsRangeGeneration = 0;
|
|
145
|
+
|
|
146
|
+
function beginLogRequest(kind) {
|
|
147
|
+
logRequestGenerations[kind] += 1;
|
|
148
|
+
return logRequestGenerations[kind];
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function isCurrentLogRequest(kind, generation) {
|
|
152
|
+
return logRequestGenerations[kind] === generation;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
async function getControlJson(url) {
|
|
156
|
+
const response = await fetch(url, {
|
|
157
|
+
cache: "no-store",
|
|
158
|
+
// 同源 GET 不会由脚本设置 Origin,固定根 Referer 保持既有来源证明。
|
|
159
|
+
referrer: `${window.location.origin}/`,
|
|
160
|
+
});
|
|
161
|
+
const payload = await response.json();
|
|
162
|
+
if (payload.code !== 0 || !payload.data) {
|
|
163
|
+
throw new Error("Control snapshot unavailable");
|
|
164
|
+
}
|
|
165
|
+
return payload.data;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function textElement(tagName, value, className = "") {
|
|
169
|
+
const element = document.createElement(tagName);
|
|
170
|
+
element.textContent = String(value);
|
|
171
|
+
if (className) element.className = className;
|
|
172
|
+
return element;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// 不依赖额外 DOM 封装,保持 Bootstrap 状态类在真实浏览器和最小测试 DOM 中一致。
|
|
176
|
+
function toggleBootstrapClass(element, className, enabled) {
|
|
177
|
+
const classes = new Set(element.className.split(/\s+/u).filter(Boolean));
|
|
178
|
+
if (enabled) classes.add(className);
|
|
179
|
+
else classes.delete(className);
|
|
180
|
+
element.className = [...classes].join(" ");
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// 仅映射 Control 严格投影的可信枚举,不能从宿主机、IP 或 UA 补猜资料。
|
|
184
|
+
const BROWSER_LABELS = {
|
|
185
|
+
chrome: "Chrome",
|
|
186
|
+
chromium: "Chromium",
|
|
187
|
+
firefox: "Firefox",
|
|
188
|
+
safari: "Safari",
|
|
189
|
+
unknown: "未知浏览器",
|
|
190
|
+
};
|
|
191
|
+
const PLATFORM_LABELS = {
|
|
192
|
+
android: "Android",
|
|
193
|
+
ios: "iOS",
|
|
194
|
+
linux: "Linux",
|
|
195
|
+
macos: "macOS",
|
|
196
|
+
unknown: "未知平台",
|
|
197
|
+
windows: "Windows",
|
|
198
|
+
};
|
|
199
|
+
|
|
200
|
+
function formatDeviceInfoSubtitle(deviceInfo) {
|
|
201
|
+
if (deviceInfo.product === "native" && deviceInfo.platform === "ios") {
|
|
202
|
+
return `${deviceInfo.model} · iOS ${deviceInfo.system_version}`;
|
|
203
|
+
}
|
|
204
|
+
return `${BROWSER_LABELS[deviceInfo.browser]} · ${PLATFORM_LABELS[deviceInfo.platform]}`;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
function formatLocalTime(value, milliseconds = false) {
|
|
208
|
+
if (value === null || value === undefined) return "无";
|
|
209
|
+
let date;
|
|
210
|
+
try {
|
|
211
|
+
date = value instanceof Date ? value : new Date(value);
|
|
212
|
+
} catch {
|
|
213
|
+
return "无";
|
|
214
|
+
}
|
|
215
|
+
// Control 的缺失时间可投影为 0;不得把它误显示为 1970 年。
|
|
216
|
+
if (!Number.isFinite(date.getTime()) || date.getTime() <= 0) return "无";
|
|
217
|
+
// 由浏览器按当前设备时区格式化,避免把 daemon 时间误解为服务器本地时间。
|
|
218
|
+
return new Intl.DateTimeFormat(undefined, {
|
|
219
|
+
year: "numeric",
|
|
220
|
+
month: "2-digit",
|
|
221
|
+
day: "2-digit",
|
|
222
|
+
hour: "2-digit",
|
|
223
|
+
minute: "2-digit",
|
|
224
|
+
second: "2-digit",
|
|
225
|
+
...(milliseconds ? { fractionalSecondDigits: 3, hourCycle: "h23" } : {}),
|
|
226
|
+
timeZoneName: "short",
|
|
227
|
+
}).format(date);
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
function appendDefinition(list, label, value) {
|
|
231
|
+
const item = document.createElement("div");
|
|
232
|
+
item.className = "col";
|
|
233
|
+
item.append(
|
|
234
|
+
textElement("dt", label, "small text-body-secondary"),
|
|
235
|
+
textElement("dd", value, "font-monospace text-break mb-0"),
|
|
236
|
+
);
|
|
237
|
+
list.append(item);
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
function formatMemoryRss(bytes) {
|
|
241
|
+
if (!Number.isSafeInteger(bytes) || bytes < 0) return "unknown";
|
|
242
|
+
return `${(bytes / (1024 * 1024)).toFixed(1)} MiB`;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
function formatPercent(value) {
|
|
246
|
+
if (typeof value !== "number" || !Number.isFinite(value)) return "unknown";
|
|
247
|
+
return `${value.toFixed(1)}%`;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
function formatOptionalPercent(value) {
|
|
251
|
+
return value === null ? "采样不足" : formatPercent(value);
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
function formatOptionalMemory(value) {
|
|
255
|
+
return value === null ? "采样不足" : formatMemoryRss(value);
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
function metricsStatusClass(status) {
|
|
259
|
+
if (status === "healthy") return "badge text-bg-success";
|
|
260
|
+
if (status === "warning") return "badge text-bg-warning";
|
|
261
|
+
if (status === "critical") return "badge text-bg-danger";
|
|
262
|
+
return "badge text-bg-secondary";
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
function appendMetricSummary(label, value) {
|
|
266
|
+
const item = document.createElement("div");
|
|
267
|
+
item.className = "col";
|
|
268
|
+
item.append(
|
|
269
|
+
textElement("dt", label, "small text-body-secondary"),
|
|
270
|
+
textElement("dd", value, "font-monospace text-break mb-0"),
|
|
271
|
+
);
|
|
272
|
+
daemonMetricsSummary.append(item);
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
// 复用图表实例,刷新数据时保留响应式布局,避免重复绑定鼠标事件。
|
|
276
|
+
const metricCharts = new Map();
|
|
277
|
+
function renderMetricChart(canvas, samples, valueOf, maxValue, color) {
|
|
278
|
+
if (typeof Chart === "undefined") return;
|
|
279
|
+
const isCpu = canvas === daemonCpuChart;
|
|
280
|
+
const unit = isCpu ? "%" : "MiB";
|
|
281
|
+
const data = samples.map((sample) => ({ x: sample.sampled_at_ms, y: valueOf(sample) }));
|
|
282
|
+
// 限定真实采样范围,避免线性轴的整刻度扩展出额外日期。
|
|
283
|
+
const minTime = data.length ? data[0].x - (data.length === 1 ? 5000 : 0) : undefined;
|
|
284
|
+
const maxTime = data.length ? data[data.length - 1].x + (data.length === 1 ? 5000 : 0) : undefined;
|
|
285
|
+
const existing = metricCharts.get(canvas);
|
|
286
|
+
if (existing) {
|
|
287
|
+
existing.data.datasets[0].data = data;
|
|
288
|
+
existing.data.datasets[0].pointRadius = samples.length === 1 ? 3 : 0;
|
|
289
|
+
existing.options.scales.x.min = minTime;
|
|
290
|
+
existing.options.scales.x.max = maxTime;
|
|
291
|
+
existing.update("none");
|
|
292
|
+
return;
|
|
293
|
+
}
|
|
294
|
+
metricCharts.set(canvas, new Chart(canvas, {
|
|
295
|
+
type: "line",
|
|
296
|
+
data: { datasets: [{
|
|
297
|
+
label: isCpu ? "CPU" : "RSS",
|
|
298
|
+
data,
|
|
299
|
+
borderColor: color,
|
|
300
|
+
borderWidth: 2,
|
|
301
|
+
pointRadius: samples.length === 1 ? 3 : 0,
|
|
302
|
+
pointHoverRadius: 4,
|
|
303
|
+
tension: 0,
|
|
304
|
+
// 停机或休眠造成的采样空档保持断线,不能画成连续运行。
|
|
305
|
+
spanGaps: 15_000,
|
|
306
|
+
}] },
|
|
307
|
+
options: {
|
|
308
|
+
responsive: true,
|
|
309
|
+
maintainAspectRatio: false,
|
|
310
|
+
animation: false,
|
|
311
|
+
parsing: false,
|
|
312
|
+
interaction: { mode: "nearest", axis: "x", intersect: false },
|
|
313
|
+
plugins: {
|
|
314
|
+
legend: { display: false },
|
|
315
|
+
tooltip: {
|
|
316
|
+
displayColors: false,
|
|
317
|
+
callbacks: {
|
|
318
|
+
title: (items) => items.length ? formatLocalTime(items[0].parsed.x) : "",
|
|
319
|
+
label: (item) => `${item.dataset.label}: ${item.parsed.y.toFixed(2)} ${unit}`,
|
|
320
|
+
},
|
|
321
|
+
},
|
|
322
|
+
},
|
|
323
|
+
scales: {
|
|
324
|
+
x: {
|
|
325
|
+
type: "linear",
|
|
326
|
+
min: minTime,
|
|
327
|
+
max: maxTime,
|
|
328
|
+
grid: { display: false },
|
|
329
|
+
ticks: {
|
|
330
|
+
maxTicksLimit: 5,
|
|
331
|
+
maxRotation: 0,
|
|
332
|
+
callback: (value) => ["24h", "48h"].includes(selectedDaemonMetricsRange)
|
|
333
|
+
? [new Date(value).toLocaleDateString("zh-CN", { month: "2-digit", day: "2-digit" }),
|
|
334
|
+
new Date(value).toLocaleTimeString("zh-CN", { hour: "2-digit", minute: "2-digit", hour12: false })]
|
|
335
|
+
: new Date(value).toLocaleTimeString("zh-CN", { hour12: false }),
|
|
336
|
+
},
|
|
337
|
+
},
|
|
338
|
+
y: {
|
|
339
|
+
min: 0,
|
|
340
|
+
suggestedMax: maxValue,
|
|
341
|
+
title: { display: true, text: unit },
|
|
342
|
+
ticks: { maxTicksLimit: 5 },
|
|
343
|
+
},
|
|
344
|
+
},
|
|
345
|
+
},
|
|
346
|
+
}));
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
function updateDaemonMetricsRangeView() {
|
|
350
|
+
if (!daemonMetricsEnabled) return;
|
|
351
|
+
const label = DAEMON_METRICS_RANGE_LABELS[selectedDaemonMetricsRange] ?? "15 分钟";
|
|
352
|
+
daemonCpuChartTitle.textContent = `CPU 最近 ${label}`;
|
|
353
|
+
daemonMemoryChartTitle.textContent = `RSS 最近 ${label}`;
|
|
354
|
+
daemonCpuChart.setAttribute("aria-label", `CPU 最近 ${label}趋势`);
|
|
355
|
+
daemonMemoryChart.setAttribute("aria-label", `RSS 最近 ${label}趋势`);
|
|
356
|
+
for (const button of daemonMetricsRangeButtons) {
|
|
357
|
+
const active = button.getAttribute("data-daemon-metrics-range") === selectedDaemonMetricsRange
|
|
358
|
+
|| button.getAttribute("id") === `daemon-metrics-range-${selectedDaemonMetricsRange}`;
|
|
359
|
+
toggleBootstrapClass(button, "active", active);
|
|
360
|
+
button.setAttribute("aria-pressed", active ? "true" : "false");
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
function renderDaemonMetrics(current, history) {
|
|
365
|
+
if (!daemonMetricsEnabled) return;
|
|
366
|
+
updateDaemonMetricsRangeView();
|
|
367
|
+
daemonMetricsStatus.className = metricsStatusClass(current?.status);
|
|
368
|
+
daemonMetricsStatus.textContent = current?.status ?? "unknown";
|
|
369
|
+
daemonMetricsSummary.replaceChildren();
|
|
370
|
+
const sample = current?.current ?? null;
|
|
371
|
+
const summary = current?.summary ?? {};
|
|
372
|
+
const samples = Array.isArray(history?.list) ? history.list : [];
|
|
373
|
+
daemonMetricsEmpty.hidden = Boolean(sample || samples.length);
|
|
374
|
+
daemonMetricsSubtitle.textContent = sample
|
|
375
|
+
? `最近采样:${formatLocalTime(sample.sampled_at_ms)},采样间隔 ${Math.round((current.sample_interval_ms ?? 0) / 1000)} 秒`
|
|
376
|
+
: "daemon 正在启动或尚未形成两次采样。";
|
|
377
|
+
appendMetricSummary("CPU 当前", sample ? formatPercent(sample.cpu_percent) : "采样不足");
|
|
378
|
+
appendMetricSummary("CPU 1 分钟平均", formatOptionalPercent(summary.cpu_avg_1m ?? null));
|
|
379
|
+
appendMetricSummary("RSS 当前", sample ? formatMemoryRss(sample.memory_rss_bytes) : "采样不足");
|
|
380
|
+
appendMetricSummary("RSS 5 分钟峰值", formatOptionalMemory(summary.memory_rss_peak_5m ?? null));
|
|
381
|
+
renderMetricChart(daemonCpuChart, samples, (entry) => entry.cpu_percent, 100, "#0d6efd");
|
|
382
|
+
renderMetricChart(daemonMemoryChart, samples, (entry) => entry.memory_rss_bytes / (1024 * 1024), 1, "#198754");
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
function renderDaemonMetricsUnavailable() {
|
|
386
|
+
if (!daemonMetricsEnabled) return;
|
|
387
|
+
updateDaemonMetricsRangeView();
|
|
388
|
+
daemonMetricsStatus.className = "badge text-bg-secondary";
|
|
389
|
+
daemonMetricsStatus.textContent = "unavailable";
|
|
390
|
+
daemonMetricsSubtitle.textContent = "daemon 未运行或资源采样暂不可读取。";
|
|
391
|
+
daemonMetricsSummary.replaceChildren();
|
|
392
|
+
appendMetricSummary("CPU 当前", "不可用");
|
|
393
|
+
appendMetricSummary("CPU 1 分钟平均", "不可用");
|
|
394
|
+
appendMetricSummary("RSS 当前", "不可用");
|
|
395
|
+
appendMetricSummary("RSS 5 分钟峰值", "不可用");
|
|
396
|
+
daemonMetricsEmpty.hidden = false;
|
|
397
|
+
renderMetricChart(daemonCpuChart, [], (entry) => entry.cpu_percent, 100, "#0d6efd");
|
|
398
|
+
renderMetricChart(daemonMemoryChart, [], (entry) => entry.memory_rss_bytes / (1024 * 1024), 1, "#198754");
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
async function refreshDaemonMetricsHistoryRange(range) {
|
|
402
|
+
if (!daemonMetricsEnabled || !Object.prototype.hasOwnProperty.call(DAEMON_METRICS_RANGE_LABELS, range)) return;
|
|
403
|
+
selectedDaemonMetricsRange = range;
|
|
404
|
+
updateDaemonMetricsRangeView();
|
|
405
|
+
const generation = ++daemonMetricsRangeGeneration;
|
|
406
|
+
try {
|
|
407
|
+
const [metricsCurrent, metricsHistory] = await Promise.all([
|
|
408
|
+
getControlJson("/api/v1/control/daemon-metrics/current").catch(() => null),
|
|
409
|
+
getControlJson(`/api/v1/control/daemon-metrics/history?range=${range}`).catch(() => null),
|
|
410
|
+
]);
|
|
411
|
+
if (generation !== daemonMetricsRangeGeneration) return;
|
|
412
|
+
if (metricsCurrent && metricsHistory) renderDaemonMetrics(metricsCurrent, metricsHistory);
|
|
413
|
+
else renderDaemonMetricsUnavailable();
|
|
414
|
+
} catch {
|
|
415
|
+
if (generation === daemonMetricsRangeGeneration) renderDaemonMetricsUnavailable();
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
// 使用绝对时间快照;手动刷新之外不新增轮询或自动倒计时。
|
|
420
|
+
function executionTime(value) {
|
|
421
|
+
return Number.isSafeInteger(value) && value >= 0 ? new Date(value).toLocaleString() : "尚无记录";
|
|
422
|
+
}
|
|
423
|
+
function renderOverview(data) {
|
|
424
|
+
const service=data.execution_service;
|
|
425
|
+
const availability={ready:"可用",recovering:"正在恢复",blocked:"等待安全退出确认",stopped:"已停止",unknown:"尚未取得执行服务状态"};
|
|
426
|
+
const phases={current:"正常运行",checking:"检查安装",waiting_idle:"等待现有任务结束",draining:"等待在途请求结束",exit_requested:"等待旧执行服务退出",starting:"启动执行服务",retrying:"等待下次尝试",blocked:"等待安全退出确认",stopped:"已停止",unknown:"尚未取得执行服务状态"};
|
|
427
|
+
const catalogStates={uninitialized:"尚未读取",loading:"正在刷新",fresh:"已刷新",stale:"缓存已过期",unknown:"尚未取得目录状态"};
|
|
428
|
+
const coverage={unknown:"尚未确认",partial:"部分会话",complete:"完整目录"};
|
|
429
|
+
const values = [
|
|
430
|
+
["daemon", data.daemon?.state ?? "unknown"],
|
|
431
|
+
["PID", data.pid ?? "unknown"],
|
|
432
|
+
["daemon 内存(RSS)", formatMemoryRss(data.memory_rss_bytes)],
|
|
433
|
+
["App Server", data.app_server ?? "unknown"],
|
|
434
|
+
["执行服务入口", service?.provider === "cli" ? "Codex CLI" : service?.provider === "desktop" ? "Codex 桌面端" : "尚未取得入口"],
|
|
435
|
+
["执行服务", availability[service?.availability] ?? availability.unknown],
|
|
436
|
+
["恢复阶段", phases[service?.phase] ?? phases.unknown],
|
|
437
|
+
["下次尝试", executionTime(service?.next_retry_at_ms)],
|
|
438
|
+
["会话目录", catalogStates[service?.catalog?.state] ?? catalogStates.unknown],
|
|
439
|
+
["目录覆盖", coverage[service?.catalog?.coverage] ?? coverage.unknown],
|
|
440
|
+
["目录最后刷新", executionTime(service?.catalog?.last_success_at_ms)],
|
|
441
|
+
["Desktop IPC", data.desktop_ipc ?? "unknown"],
|
|
442
|
+
["HTTP Gateway", data.http_gateway ?? "unknown"],
|
|
443
|
+
["Direct", data.direct ?? "unknown"],
|
|
444
|
+
["Relay", data.relay ?? "unknown"],
|
|
445
|
+
];
|
|
446
|
+
overview.replaceChildren();
|
|
447
|
+
for (const [label, value] of values) appendDefinition(overview, label, value);
|
|
448
|
+
emptyState.hidden = data.daemon?.state !== "stopped";
|
|
449
|
+
renderDaemonLifecycle(data);
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
function renderDaemonLifecycle(data) {
|
|
453
|
+
if (!daemonLifecycleDetails) return;
|
|
454
|
+
daemonLifecycleOperation = data.lifecycle?.operation ?? null;
|
|
455
|
+
const values = [
|
|
456
|
+
["状态", data.daemon?.state ?? "unknown"],
|
|
457
|
+
["当前操作", daemonLifecycleOperation ?? "无"],
|
|
458
|
+
];
|
|
459
|
+
daemonLifecycleDetails.replaceChildren();
|
|
460
|
+
for (const [label, value] of values) appendDefinition(daemonLifecycleDetails, label, value);
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
function holderIdentity(holder) {
|
|
464
|
+
if (holder?.kind === "unknown") return "未知 IPC 来源";
|
|
465
|
+
return holder?.client_name ?? holder?.client_id ?? holder?.kind ?? "未知 IPC 来源";
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
function subscriptionActionLabel(action) {
|
|
469
|
+
// 只展示 Control 已验证的动作,避免页面从其它字段推断订阅生命周期。
|
|
470
|
+
return {
|
|
471
|
+
started: "已开始 daemon 订阅",
|
|
472
|
+
reused: "跳过重复订阅,复用现有订阅",
|
|
473
|
+
retained: "保留现有 daemon 订阅",
|
|
474
|
+
draining: "订阅排空中",
|
|
475
|
+
stopped: "订阅已停止",
|
|
476
|
+
}[action] ?? "Trace 暂不可用";
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
function followerPhaseLabel(phase) {
|
|
480
|
+
// 保留协议枚举供排障,同时附上稳定的中文简称方便快速理解。
|
|
481
|
+
const labels = {
|
|
482
|
+
discovering: "发现中",
|
|
483
|
+
baselining: "建立基线",
|
|
484
|
+
following: "跟踪中",
|
|
485
|
+
terminal_draining: "收尾中",
|
|
486
|
+
released: "已释放",
|
|
487
|
+
};
|
|
488
|
+
const label = labels[phase];
|
|
489
|
+
return label ? `${phase}(${label})` : phase ?? "未知";
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
function createSubscriptionTraceButton(traceId, leaseId) {
|
|
493
|
+
// 明细仅由用户点击读取;多个 lease 可以安全复用同一个 trace_id。
|
|
494
|
+
const button = textElement("button", leaseId ? "查看加载时间线" : "查看会话订阅 Trace", "btn btn-sm btn-outline-secondary mt-3");
|
|
495
|
+
button.setAttribute("type", "button");
|
|
496
|
+
const timeline = document.createElement("div");
|
|
497
|
+
timeline.className = "list-group list-group-flush mt-3";
|
|
498
|
+
button.addEventListener("click", () => {
|
|
499
|
+
void loadFollowerTraceDetail(traceId, button, timeline, leaseId);
|
|
500
|
+
});
|
|
501
|
+
return { button, timeline };
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
function renderSubscription(subscription) {
|
|
505
|
+
const details = document.createElement("details");
|
|
506
|
+
details.className = "subscription border rounded p-3 mt-3";
|
|
507
|
+
details.append(textElement("summary", "daemon 订阅", "fw-semibold"));
|
|
508
|
+
if (!subscription) {
|
|
509
|
+
details.append(textElement("p", "Trace 暂不可用", "text-body-secondary small mt-3 mb-0"));
|
|
510
|
+
return details;
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
const definitions = document.createElement("dl");
|
|
514
|
+
definitions.className = "row row-cols-1 row-cols-sm-2 g-3 mt-3 mb-0";
|
|
515
|
+
appendDefinition(definitions, "阶段", followerPhaseLabel(subscription.phase));
|
|
516
|
+
appendDefinition(
|
|
517
|
+
definitions,
|
|
518
|
+
"维持租约",
|
|
519
|
+
subscription.lease_kinds.map((kind) => followerTraceLeaseKindLabel(kind)).join("、") || "无",
|
|
520
|
+
);
|
|
521
|
+
appendDefinition(definitions, "开始时间", formatLocalTime(subscription.started_at_ms));
|
|
522
|
+
appendDefinition(definitions, "宽限截止", formatLocalTime(subscription.release_at_ms));
|
|
523
|
+
const trace = createSubscriptionTraceButton(subscription.trace_id);
|
|
524
|
+
details.append(definitions, trace.button, trace.timeline);
|
|
525
|
+
return details;
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
function renderLease(lease, subscription) {
|
|
529
|
+
const details = document.createElement("details");
|
|
530
|
+
details.className = "lease border rounded p-3";
|
|
531
|
+
details.append(textElement("summary", `租约 ${lease.lease_id} · ${lease.kind}`, "fw-semibold"));
|
|
532
|
+
|
|
533
|
+
const definitions = document.createElement("dl");
|
|
534
|
+
definitions.className = "row row-cols-1 row-cols-sm-2 g-3 mt-3 mb-0";
|
|
535
|
+
appendDefinition(definitions, "身份", holderIdentity(lease.holder));
|
|
536
|
+
appendDefinition(definitions, "身份类型", lease.holder?.kind ?? "unknown");
|
|
537
|
+
appendDefinition(definitions, "证据来源", lease.holder?.evidence ?? "unverified");
|
|
538
|
+
if (lease.holder?.client_id) appendDefinition(definitions, "Client ID", lease.holder.client_id);
|
|
539
|
+
if (lease.holder?.binding_id) appendDefinition(definitions, "Binding ID", lease.holder.binding_id);
|
|
540
|
+
appendDefinition(definitions, "获得时间", formatLocalTime(lease.acquired_at_ms));
|
|
541
|
+
appendDefinition(definitions, "最近活动", formatLocalTime(lease.last_seen_at_ms));
|
|
542
|
+
appendDefinition(definitions, "订阅动作", subscriptionActionLabel(lease.subscription_action));
|
|
543
|
+
if (subscription && typeof lease.trace_id === "string" && lease.trace_id.length > 0) {
|
|
544
|
+
// 操作集中在标题下方,避免加载结果把另一个按钮推到长详情底部。
|
|
545
|
+
const actions = document.createElement("div");
|
|
546
|
+
actions.className = "lease-actions d-flex flex-wrap align-items-center gap-2 mt-3";
|
|
547
|
+
actions.setAttribute("role", "group");
|
|
548
|
+
actions.setAttribute("aria-label", "租约诊断操作");
|
|
549
|
+
const trace = createSubscriptionTraceButton(lease.trace_id);
|
|
550
|
+
toggleBootstrapClass(trace.button, "mt-3", false);
|
|
551
|
+
actions.append(trace.button);
|
|
552
|
+
details.append(actions, definitions, trace.timeline);
|
|
553
|
+
if (lease.kind === "open") {
|
|
554
|
+
const loading = createSubscriptionTraceButton(lease.trace_id, lease.lease_id);
|
|
555
|
+
toggleBootstrapClass(loading.button, "mt-3", false);
|
|
556
|
+
actions.append(loading.button);
|
|
557
|
+
details.append(loading.timeline);
|
|
558
|
+
}
|
|
559
|
+
} else {
|
|
560
|
+
details.append(definitions, textElement("p", "Trace 暂不可用", "text-body-secondary small mt-3 mb-0"));
|
|
561
|
+
}
|
|
562
|
+
return details;
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
function renderFollowers(data) {
|
|
566
|
+
sessionsList.replaceChildren();
|
|
567
|
+
sessionsEmpty.hidden = data.list.length !== 0;
|
|
568
|
+
for (const follower of data.list) {
|
|
569
|
+
const article = document.createElement("article");
|
|
570
|
+
article.className = "session card card-body shadow-sm";
|
|
571
|
+
article.append(textElement("h3", follower.title || "未命名会话", "h5"));
|
|
572
|
+
article.append(textElement("p", follower.thread_id, "small font-monospace text-break text-body-secondary"));
|
|
573
|
+
|
|
574
|
+
const metadata = document.createElement("dl");
|
|
575
|
+
metadata.className = "row row-cols-1 row-cols-sm-2 row-cols-xl-3 g-3 mb-0";
|
|
576
|
+
appendDefinition(metadata, "阶段", followerPhaseLabel(follower.phase));
|
|
577
|
+
appendDefinition(metadata, "登记代际", follower.registered_generation ?? "未登记");
|
|
578
|
+
appendDefinition(metadata, "快照", follower.snapshot_observed ? "已观察" : "未观察");
|
|
579
|
+
appendDefinition(metadata, "宽限截止", formatLocalTime(follower.release_at_ms));
|
|
580
|
+
appendDefinition(metadata, "活跃租约", follower.leases.length);
|
|
581
|
+
article.append(metadata);
|
|
582
|
+
article.append(renderSubscription(follower.subscription));
|
|
583
|
+
|
|
584
|
+
const leases = document.createElement("div");
|
|
585
|
+
leases.className = "leases vstack gap-2 mt-3";
|
|
586
|
+
for (const lease of follower.leases) leases.append(renderLease(lease, follower.subscription));
|
|
587
|
+
article.append(leases);
|
|
588
|
+
sessionsList.append(article);
|
|
589
|
+
}
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
function followerTraceTransportLabel(transport) {
|
|
593
|
+
return {
|
|
594
|
+
local: "本机打开",
|
|
595
|
+
direct: "Direct 打开",
|
|
596
|
+
relay: "Relay 打开",
|
|
597
|
+
}[transport] ?? "已记录来源";
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
function followerTraceCoverageLabel(coverage) {
|
|
601
|
+
if (coverage?.status === "complete") return "Trace 记录完整。";
|
|
602
|
+
if (coverage?.status === "partial") return "Trace 记录部分可读,结果可能不完整。";
|
|
603
|
+
if (coverage?.status === "historical_unverified") {
|
|
604
|
+
return "历史记录,当前 follower 状态不可用。";
|
|
605
|
+
}
|
|
606
|
+
return "Trace 覆盖状态未知。";
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
function followerTraceStepLabel(step) {
|
|
610
|
+
if (step?.loading) return {
|
|
611
|
+
selected: "点击侧栏任务", restored: "恢复会话页面", request_started: "开始请求会话流",
|
|
612
|
+
snapshot_received: "浏览器收到首个快照", history_ready: "浏览器历史正文就绪", content_presented: "前台正文首屏显示(绘制机会)",
|
|
613
|
+
open_received: "Desktop 收到打开请求", identity_ready: "会话身份核验完成", lease_acquired: "取得 open lease",
|
|
614
|
+
shell_sent: "Desktop 发送会话壳", history_sent: "Desktop 发送历史正文", stream_closed: "会话流关闭",
|
|
615
|
+
}[step.loading.phase] ?? "加载步骤";
|
|
616
|
+
if (step?.event_kind === "lease") {
|
|
617
|
+
return step.transition === "acquired"
|
|
618
|
+
? "已取得订阅租约"
|
|
619
|
+
: step.transition === "released"
|
|
620
|
+
? "已释放订阅租约"
|
|
621
|
+
: "已记录订阅租约";
|
|
622
|
+
}
|
|
623
|
+
const settingsGateLabels = {
|
|
624
|
+
waiting_for_settings_snapshot: "等待当前控制代际完整 settings snapshot",
|
|
625
|
+
settings_snapshot_ready: "当前控制代际 settings snapshot 已就绪",
|
|
626
|
+
settings_snapshot_invalidated: "settings snapshot 基线已失效,等待当前代际新 snapshot",
|
|
627
|
+
};
|
|
628
|
+
const stage = step?.stage ?? step?.latest_stage;
|
|
629
|
+
if (stage === "ipc_settings_snapshot_gate_changed") {
|
|
630
|
+
return settingsGateLabels[step.outcome] ?? "已记录步骤";
|
|
631
|
+
}
|
|
632
|
+
const stageLabels = {
|
|
633
|
+
lease_acquired: "已取得订阅租约",
|
|
634
|
+
lease_released: "已释放订阅租约",
|
|
635
|
+
subscription_started: "已开始 daemon 订阅",
|
|
636
|
+
subscription_stopped: "订阅已停止",
|
|
637
|
+
subscription_stop_failed: "订阅停止失败",
|
|
638
|
+
open_requested: "已请求打开会话",
|
|
639
|
+
open_rejected: "会话打开已拒绝",
|
|
640
|
+
initial_snapshot_started: "已开始读取初始快照",
|
|
641
|
+
initial_snapshot_sent: "已发送初始快照",
|
|
642
|
+
initial_snapshot_failed: "初始快照读取失败",
|
|
643
|
+
open_lease_acquired: "已取得打开租约",
|
|
644
|
+
open_lease_released: "已释放打开租约",
|
|
645
|
+
follower_intent_registered: "已登记 follower 意图",
|
|
646
|
+
related_lease_snapshot: "已观察到关联租约",
|
|
647
|
+
ipc_checked: "已检查 IPC",
|
|
648
|
+
probe_decided: "已决定 follower 探测",
|
|
649
|
+
following_sent: "已发送跟随请求",
|
|
650
|
+
following_send_failed: "跟随请求发送失败",
|
|
651
|
+
snapshot_observed: "已观察到 snapshot",
|
|
652
|
+
probe_timeout: "follower 探测超时",
|
|
653
|
+
recovery_probe_started: "已开始恢复探测",
|
|
654
|
+
follower_phase_changed: "follower 阶段已更新",
|
|
655
|
+
ipc_connection_lost: "IPC 连接已断开",
|
|
656
|
+
ipc_connection_recovered: "IPC 连接已恢复",
|
|
657
|
+
thread_read_started: "已开始读取会话",
|
|
658
|
+
thread_read_succeeded: "会话读取完成",
|
|
659
|
+
thread_read_failed: "会话读取失败",
|
|
660
|
+
thread_read_retry_scheduled: "会话读取已安排重试",
|
|
661
|
+
thread_read_cancelled: "会话读取已取消",
|
|
662
|
+
related_lease_acquired: "关联 follower 租约已取得",
|
|
663
|
+
related_lease_released: "关联 follower 租约已释放",
|
|
664
|
+
follower_retained: "follower 已保持",
|
|
665
|
+
detail_sync_changed: "详情同步状态已更新",
|
|
666
|
+
open_stream_closed: "打开连接已关闭",
|
|
667
|
+
};
|
|
668
|
+
return stageLabels[stage] ?? "已记录步骤";
|
|
669
|
+
}
|
|
670
|
+
|
|
671
|
+
function followerTraceLeaseKindLabel(kind) {
|
|
672
|
+
return {
|
|
673
|
+
active: "活跃租约",
|
|
674
|
+
open: "打开租约",
|
|
675
|
+
remote: "远端租约",
|
|
676
|
+
}[kind] ?? "已记录租约";
|
|
677
|
+
}
|
|
678
|
+
|
|
679
|
+
function followerTraceLeaseSourceLabel(source) {
|
|
680
|
+
return {
|
|
681
|
+
active_turn: "活跃 Turn",
|
|
682
|
+
external_open: "外部打开",
|
|
683
|
+
desktop_following: "Desktop following",
|
|
684
|
+
}[source] ?? "已记录来源";
|
|
685
|
+
}
|
|
686
|
+
|
|
687
|
+
function followerTraceLeaseTransitionLabel(transition) {
|
|
688
|
+
return {
|
|
689
|
+
acquired: "已取得",
|
|
690
|
+
released: "已释放",
|
|
691
|
+
}[transition] ?? "已记录变更";
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
function followerTraceKindLabel(traceKind) {
|
|
695
|
+
return traceKind === "legacy_open"
|
|
696
|
+
? "旧版外部打开 Trace"
|
|
697
|
+
: traceKind === "subscription"
|
|
698
|
+
? "会话订阅 Trace"
|
|
699
|
+
: "已记录 Trace";
|
|
700
|
+
}
|
|
701
|
+
|
|
702
|
+
function renderFollowerTraceSteps(container, data) {
|
|
703
|
+
container.replaceChildren();
|
|
704
|
+
const complete = data.coverage?.status === "complete";
|
|
705
|
+
// 明细独立展示 coverage,不能把受限读取的空结果解释为没有订阅记录。
|
|
706
|
+
if (!complete) {
|
|
707
|
+
container.append(textElement("p", followerTraceCoverageLabel(data.coverage), "list-group-item text-body-secondary"));
|
|
708
|
+
}
|
|
709
|
+
if (!Array.isArray(data.list) || data.list.length === 0) {
|
|
710
|
+
container.append(textElement("p", complete
|
|
711
|
+
? "没有可显示的会话订阅 Trace 步骤。"
|
|
712
|
+
: "当前未读到 Trace 步骤,无法确认该订阅是否没有步骤。", "list-group-item text-body-secondary"));
|
|
713
|
+
return;
|
|
714
|
+
}
|
|
715
|
+
for (const step of data.list) {
|
|
716
|
+
const item = document.createElement("article");
|
|
717
|
+
item.className = "trace-step list-group-item";
|
|
718
|
+
item.append(textElement("h4", followerTraceStepLabel(step), "h6"));
|
|
719
|
+
const details = document.createElement("dl");
|
|
720
|
+
details.className = "row row-cols-1 row-cols-sm-2 g-3 mb-0";
|
|
721
|
+
appendDefinition(details, "时间", formatLocalTime(step.occurred_at_ms));
|
|
722
|
+
if (step.event_kind === "lease") {
|
|
723
|
+
// schema v2 lease 与 lifecycle 字段互斥,按 discriminator 展示真实含义。
|
|
724
|
+
appendDefinition(details, "租约种类", followerTraceLeaseKindLabel(step.lease_kind));
|
|
725
|
+
appendDefinition(details, "来源", followerTraceLeaseSourceLabel(step.source));
|
|
726
|
+
appendDefinition(details, "变更", followerTraceLeaseTransitionLabel(step.transition));
|
|
727
|
+
appendDefinition(details, "订阅动作", subscriptionActionLabel(step.action));
|
|
728
|
+
if (step.source_transport) {
|
|
729
|
+
appendDefinition(details, "传输", followerTraceTransportLabel(step.source_transport));
|
|
730
|
+
}
|
|
731
|
+
if (step.client_class) appendDefinition(details, "客户端类型", step.client_class);
|
|
732
|
+
} else {
|
|
733
|
+
if (step.event_kind === "lifecycle") appendDefinition(details, "阶段", step.stage ?? "未知");
|
|
734
|
+
appendDefinition(details, "结果", step.outcome ?? "未知");
|
|
735
|
+
// 没有 discriminator 的 schema v1 继续按原有 scope 形状渲染。
|
|
736
|
+
if (step.scope === "thread") appendDefinition(details, "范围", "关联 follower 事件");
|
|
737
|
+
}
|
|
738
|
+
if (step.loading) {
|
|
739
|
+
appendDefinition(details, "加载编号", step.loading.load_id);
|
|
740
|
+
appendDefinition(details, "计时来源", step.loading.clock === "browser" ? "浏览器(自选择 / 恢复起)" : "Desktop(自收到请求起)");
|
|
741
|
+
appendDefinition(details, "累计耗时", `${step.loading.elapsed_ms} ms`);
|
|
742
|
+
}
|
|
743
|
+
if (step.purpose) appendDefinition(details, "读取用途", step.purpose);
|
|
744
|
+
if (step.duration_ms !== undefined) appendDefinition(details, "耗时", `${step.duration_ms} ms`);
|
|
745
|
+
if (step.applied !== undefined) appendDefinition(details, "已应用", step.applied ? "是" : "否");
|
|
746
|
+
if (step.attempt !== undefined) appendDefinition(details, "尝试", step.attempt);
|
|
747
|
+
if (step.retry_delay_ms !== undefined) {
|
|
748
|
+
appendDefinition(details, "重试延迟", `${step.retry_delay_ms} ms`);
|
|
749
|
+
}
|
|
750
|
+
if (Array.isArray(step.lease_kinds)) {
|
|
751
|
+
appendDefinition(
|
|
752
|
+
details,
|
|
753
|
+
"关联租约",
|
|
754
|
+
step.lease_kinds.map((kind) => followerTraceLeaseKindLabel(kind)).join("、"),
|
|
755
|
+
);
|
|
756
|
+
}
|
|
757
|
+
item.append(details);
|
|
758
|
+
container.append(item);
|
|
759
|
+
}
|
|
760
|
+
}
|
|
761
|
+
|
|
762
|
+
/** 两端各自排序并作差;不能把报告抵达时间当作浏览器事件发生时间。 */
|
|
763
|
+
function renderLoadingTimeline(container, data) {
|
|
764
|
+
container.replaceChildren();
|
|
765
|
+
const steps = (data.list ?? []).filter(step => step.loading);
|
|
766
|
+
if (!steps.length) {
|
|
767
|
+
container.append(textElement("p", "暂无加载采样:旧客户端、尚未上报或记录不可用。", "text-body-secondary small"));
|
|
768
|
+
return;
|
|
769
|
+
}
|
|
770
|
+
const summary = document.createElement("div");
|
|
771
|
+
summary.className = "alert alert-secondary";
|
|
772
|
+
const browser = steps.filter(step => step.loading.clock === "browser").sort((a, b) => a.loading.elapsed_ms - b.loading.elapsed_ms || a.sequence - b.sequence);
|
|
773
|
+
const daemon = steps.filter(step => step.loading.clock === "daemon").sort((a, b) => a.loading.elapsed_ms - b.loading.elapsed_ms || a.sequence - b.sequence);
|
|
774
|
+
const end = browser.find(step => step.loading.phase === "content_presented");
|
|
775
|
+
summary.append(textElement("strong", end ? `选择 / 恢复到正文显示:${end.loading.elapsed_ms} ms` : "正文显示耗时:尚未取得完整采样"));
|
|
776
|
+
const intervals = [
|
|
777
|
+
[browser, "selected", "request_started", "点击后准备"],
|
|
778
|
+
[browser, "restored", "request_started", "恢复后准备"],
|
|
779
|
+
[browser, "request_started", "snapshot_received", "等待有效快照(含重试)"],
|
|
780
|
+
[browser, "snapshot_received", "history_ready", "等待历史正文"],
|
|
781
|
+
[browser, "history_ready", "content_presented", "正文呈现"],
|
|
782
|
+
[daemon, "open_received", "identity_ready", "Desktop 身份核验"],
|
|
783
|
+
[daemon, "identity_ready", "lease_acquired", "Desktop 取得租约"],
|
|
784
|
+
[daemon, "lease_acquired", "history_sent", "Desktop 准备并发送历史"],
|
|
785
|
+
];
|
|
786
|
+
const measured = [];
|
|
787
|
+
for (const [entries, from, to, label] of intervals) {
|
|
788
|
+
const start = entries.find(step => step.loading.phase === from);
|
|
789
|
+
const finish = entries.find(step => step.loading.phase === to);
|
|
790
|
+
if (!start || !finish || finish.loading.elapsed_ms < start.loading.elapsed_ms) continue;
|
|
791
|
+
const ms = finish.loading.elapsed_ms - start.loading.elapsed_ms;
|
|
792
|
+
summary.append(textElement("div", `${label}:${ms} ms`, "small"));
|
|
793
|
+
if (entries === browser) measured.push({ label, ms });
|
|
794
|
+
}
|
|
795
|
+
const slowest = measured.sort((a, b) => b.ms - a.ms)[0];
|
|
796
|
+
if (slowest) summary.append(textElement("div", `当前已采集浏览器阶段中耗时最长:${slowest.label}(${slowest.ms} ms)`, "small mt-2"));
|
|
797
|
+
summary.append(textElement("div", "浏览器数据为客户端测量;两端时钟独立,不作跨端差值。首屏不包含图片全部解码完成,缺失采样不计为 0。", "small mt-2"));
|
|
798
|
+
container.append(summary);
|
|
799
|
+
container.append(textElement("p", `加载编号:${steps[0].loading.load_id}`, "small font-monospace text-break text-body-secondary"));
|
|
800
|
+
if (data.coverage?.status !== "complete") container.append(textElement("p", followerTraceCoverageLabel(data.coverage), "text-body-secondary small"));
|
|
801
|
+
for (const [label, entries] of [["浏览器时间线", browser], ["Desktop 时间线", daemon]]) {
|
|
802
|
+
container.append(textElement("h4", label, "h6 mt-3"));
|
|
803
|
+
const list = document.createElement("ol");
|
|
804
|
+
list.className = "list-group list-group-numbered";
|
|
805
|
+
let previous = null;
|
|
806
|
+
for (const step of entries) {
|
|
807
|
+
const item = document.createElement("li");
|
|
808
|
+
item.className = "list-group-item";
|
|
809
|
+
const ms = step.loading.elapsed_ms;
|
|
810
|
+
item.append(textElement("span", `${followerTraceStepLabel(step)} · ${ms} ms`, "fw-semibold"));
|
|
811
|
+
// 新样本显示来源端发生时间;旧样本只能展示账本记录时间,不能据耗时反推。
|
|
812
|
+
const occurredAt = step.loading.occurred_at_ms;
|
|
813
|
+
const timeLabel = occurredAt === undefined ? "记录时间(发生时间未采集)" : "发生时间";
|
|
814
|
+
item.append(textElement("div", `${timeLabel}:${formatLocalTime(occurredAt ?? step.occurred_at_ms, true)}`, "small text-body-secondary"));
|
|
815
|
+
if (previous !== null) item.append(textElement("div", `距上一阶段 ${ms - previous} ms`, "small text-body-secondary"));
|
|
816
|
+
previous = ms;
|
|
817
|
+
list.append(item);
|
|
818
|
+
}
|
|
819
|
+
if (!entries.length) list.append(textElement("li", "尚无此端采样", "list-group-item text-body-secondary"));
|
|
820
|
+
container.append(list);
|
|
821
|
+
}
|
|
822
|
+
}
|
|
823
|
+
|
|
824
|
+
async function loadFollowerTraceDetail(traceId, button, container, leaseId) {
|
|
825
|
+
button.disabled = true;
|
|
826
|
+
try {
|
|
827
|
+
const data = await getControlJson(
|
|
828
|
+
`/api/v1/control/follower-trace-detail?trace_id=${encodeURIComponent(traceId)}&page=1&page_size=50${leaseId ? `&lease_id=${encodeURIComponent(leaseId)}` : ""}`,
|
|
829
|
+
);
|
|
830
|
+
if (leaseId) renderLoadingTimeline(container, data);
|
|
831
|
+
else renderFollowerTraceSteps(container, data);
|
|
832
|
+
// 不完整结果允许用户按需重读,不引入自动轮询或重试。
|
|
833
|
+
button.hidden = !leaseId && data.coverage?.status === "complete";
|
|
834
|
+
if (!button.hidden) {
|
|
835
|
+
button.textContent = leaseId ? "刷新加载时间线" : "重新读取 Trace 步骤";
|
|
836
|
+
button.disabled = false;
|
|
837
|
+
}
|
|
838
|
+
} catch {
|
|
839
|
+
// 明细失败不替换已加载的列表,也不显示账本或底层错误内容。
|
|
840
|
+
container.replaceChildren(textElement(
|
|
841
|
+
"p",
|
|
842
|
+
"Trace 步骤读取失败,请手动刷新后重试。",
|
|
843
|
+
"list-group-item text-body-secondary",
|
|
844
|
+
));
|
|
845
|
+
button.disabled = false;
|
|
846
|
+
}
|
|
847
|
+
}
|
|
848
|
+
|
|
849
|
+
async function clearFollowerTraces() {
|
|
850
|
+
if (!followerTracesEnabled || !clearFollowerTracesButton) return;
|
|
851
|
+
if (!window.confirm("将清空全部会话订阅 Trace,且无法恢复。是否继续?")) return;
|
|
852
|
+
|
|
853
|
+
// 先使此前读取失效,防止清空前的慢响应把旧账本重新渲染到页面。
|
|
854
|
+
controlRefreshGeneration += 1;
|
|
855
|
+
clearFollowerTracesButton.disabled = true;
|
|
856
|
+
try {
|
|
857
|
+
const response = await fetch("/api/v1/control/follower-traces/clear", {
|
|
858
|
+
cache: "no-store",
|
|
859
|
+
method: "POST",
|
|
860
|
+
headers: { "Content-Type": "application/json" },
|
|
861
|
+
body: "{}",
|
|
862
|
+
});
|
|
863
|
+
const payload = await response.json();
|
|
864
|
+
if (payload.code !== 0 || payload.data?.cleared !== true) {
|
|
865
|
+
throw new Error("Control follower trace clear unavailable");
|
|
866
|
+
}
|
|
867
|
+
// daemon 已确认清空后立即替换局部视图;无关快照失败不能让旧 Trace 留在页面上。
|
|
868
|
+
renderFollowerTraces({
|
|
869
|
+
list: [],
|
|
870
|
+
coverage: { status: "complete", reasons: [] },
|
|
871
|
+
});
|
|
872
|
+
await refreshCurrentPage();
|
|
873
|
+
refreshStatus.textContent = "会话订阅 Trace 已清空。";
|
|
874
|
+
} catch {
|
|
875
|
+
// 不向页面展示 daemon 或文件系统错误,失败时保留已显示的 Trace 供继续排查。
|
|
876
|
+
refreshStatus.textContent = "Trace 清空失败,请稍后重试。";
|
|
877
|
+
} finally {
|
|
878
|
+
clearFollowerTracesButton.disabled = false;
|
|
879
|
+
}
|
|
880
|
+
}
|
|
881
|
+
|
|
882
|
+
function renderFollowerTraces(data) {
|
|
883
|
+
if (!followerTracesEnabled) return;
|
|
884
|
+
followerTracesList.replaceChildren();
|
|
885
|
+
followerTracesEmpty.hidden = data.list.length !== 0;
|
|
886
|
+
followerTracesEmpty.textContent = data.coverage?.status === "complete"
|
|
887
|
+
? "最近 24 小时没有会话订阅 Trace。"
|
|
888
|
+
: "当前未读到会话订阅 Trace,记录可能不完整。";
|
|
889
|
+
followerTracesCoverage.textContent = followerTraceCoverageLabel(data.coverage);
|
|
890
|
+
for (const trace of data.list) {
|
|
891
|
+
const article = document.createElement("article");
|
|
892
|
+
article.className = "trace card card-body shadow-sm";
|
|
893
|
+
article.append(textElement("h3", trace.title || "未命名会话", "h5"));
|
|
894
|
+
article.append(textElement("p", trace.thread_id, "small font-monospace text-break text-body-secondary"));
|
|
895
|
+
const details = document.createElement("dl");
|
|
896
|
+
details.className = "row row-cols-1 row-cols-sm-2 row-cols-xl-3 g-3 mb-0";
|
|
897
|
+
const traceKind = followerTraceKindLabel(trace.trace_kind);
|
|
898
|
+
appendDefinition(details, "Trace 类型", traceKind);
|
|
899
|
+
if (trace.trace_kind === "legacy_open") {
|
|
900
|
+
appendDefinition(details, "来源", followerTraceTransportLabel(trace.source_transport));
|
|
901
|
+
appendDefinition(details, "客户端", trace.client_class ?? "unknown");
|
|
902
|
+
}
|
|
903
|
+
if (trace.initial_lease_kind) {
|
|
904
|
+
appendDefinition(details, "初始租约", followerTraceLeaseKindLabel(trace.initial_lease_kind));
|
|
905
|
+
}
|
|
906
|
+
appendDefinition(details, `最后${traceKind} 步骤`, followerTraceStepLabel(trace));
|
|
907
|
+
appendDefinition(details, "结果", trace.outcome ?? "未知");
|
|
908
|
+
appendDefinition(details, "总耗时", `${trace.duration_ms ?? 0} ms`);
|
|
909
|
+
article.append(details);
|
|
910
|
+
const button = textElement("button", `展开${traceKind} 步骤`, "btn btn-sm btn-outline-secondary mt-3");
|
|
911
|
+
button.setAttribute("type", "button");
|
|
912
|
+
const timeline = document.createElement("div");
|
|
913
|
+
timeline.className = "list-group list-group-flush mt-3";
|
|
914
|
+
button.addEventListener("click", () => {
|
|
915
|
+
void loadFollowerTraceDetail(trace.trace_id, button, timeline);
|
|
916
|
+
});
|
|
917
|
+
article.append(button, timeline);
|
|
918
|
+
followerTracesList.append(article);
|
|
919
|
+
}
|
|
920
|
+
}
|
|
921
|
+
|
|
922
|
+
function renderLogEntry(entry, kind) {
|
|
923
|
+
const article = document.createElement("article");
|
|
924
|
+
article.className = "log-entry card card-body shadow-sm";
|
|
925
|
+
article.append(textElement("h3", formatLocalTime(entry.ts), "h5"));
|
|
926
|
+
|
|
927
|
+
const definitions = document.createElement("dl");
|
|
928
|
+
definitions.className = "row row-cols-1 row-cols-sm-2 row-cols-xl-3 g-3 mb-0";
|
|
929
|
+
const fields = kind === "error"
|
|
930
|
+
? ["failure_id", "component", "channel", "operation", "failure_kind"]
|
|
931
|
+
: ["component", "channel", "event", "outcome"];
|
|
932
|
+
for (const field of fields) appendDefinition(definitions, field, entry[field]);
|
|
933
|
+
article.append(definitions);
|
|
934
|
+
return article;
|
|
935
|
+
}
|
|
936
|
+
|
|
937
|
+
function renderLogs() {
|
|
938
|
+
const snapshot = logSnapshots[selectedLogKind];
|
|
939
|
+
errorLogTab.setAttribute("aria-selected", String(selectedLogKind === "error"));
|
|
940
|
+
debugLogTab.setAttribute("aria-selected", String(selectedLogKind === "debug"));
|
|
941
|
+
toggleBootstrapClass(errorLogTab, "active", selectedLogKind === "error");
|
|
942
|
+
toggleBootstrapClass(debugLogTab, "active", selectedLogKind === "debug");
|
|
943
|
+
logsList.replaceChildren();
|
|
944
|
+
const entries = snapshot?.list ?? [];
|
|
945
|
+
for (const entry of entries) logsList.append(renderLogEntry(entry, selectedLogKind));
|
|
946
|
+
logsEmpty.textContent = selectedLogKind === "error"
|
|
947
|
+
? "当前没有错误日志。"
|
|
948
|
+
: "当前没有调试日志。";
|
|
949
|
+
logsEmpty.hidden = entries.length !== 0;
|
|
950
|
+
loadMoreLogsButton.hidden = !snapshot || entries.length >= snapshot.total;
|
|
951
|
+
}
|
|
952
|
+
|
|
953
|
+
const turnAuditStatusLabels = {
|
|
954
|
+
sse_received: "Bridge 已接收",
|
|
955
|
+
send_started: "开始发送",
|
|
956
|
+
server_accepted: "Server 已接受",
|
|
957
|
+
server_registered: "Server 已接收",
|
|
958
|
+
send_failed: "发送失败",
|
|
959
|
+
deadline_expired: "投递期限已到",
|
|
960
|
+
dropped_busy: "并发繁忙已丢弃",
|
|
961
|
+
skipped_duplicate: "重复通知已跳过",
|
|
962
|
+
bridge_not_observed: "未观察 Bridge",
|
|
963
|
+
awaiting_bridge: "等待 Bridge",
|
|
964
|
+
unverifiable: "缺少投递证据",
|
|
965
|
+
not_required: "无需推送",
|
|
966
|
+
};
|
|
967
|
+
const turnAuditStageLabels = {
|
|
968
|
+
source_observed: "已观察源事件",
|
|
969
|
+
terminal_observed: "已观察终态",
|
|
970
|
+
...turnAuditStatusLabels,
|
|
971
|
+
};
|
|
972
|
+
const turnAuditStagePhases = new Set([
|
|
973
|
+
"terminal_observed",
|
|
974
|
+
"source_observed",
|
|
975
|
+
"sse_received",
|
|
976
|
+
"send_started",
|
|
977
|
+
"server_accepted",
|
|
978
|
+
"server_registered",
|
|
979
|
+
"send_failed",
|
|
980
|
+
"deadline_expired",
|
|
981
|
+
"dropped_busy",
|
|
982
|
+
"skipped_duplicate",
|
|
983
|
+
]);
|
|
984
|
+
const turnAuditStageFailureReasons = new Set([
|
|
985
|
+
"network",
|
|
986
|
+
"invalid_response",
|
|
987
|
+
"http_error",
|
|
988
|
+
"business_error",
|
|
989
|
+
"unknown",
|
|
990
|
+
]);
|
|
991
|
+
const turnAuditObservationReasons = {
|
|
992
|
+
awaiting_authoritative_read: "等待终态详情核验",
|
|
993
|
+
read_failed: "终态详情读取失败",
|
|
994
|
+
live_fence_closed: "通知事件发布尚未就绪",
|
|
995
|
+
};
|
|
996
|
+
const turnAuditStageIdPattern = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,255}$/u;
|
|
997
|
+
const isAttentionSession = (session) => Number.isSafeInteger(session.attention_count)
|
|
998
|
+
&& session.attention_count > 0;
|
|
999
|
+
|
|
1000
|
+
function turnAuditSummaryValue(summary, key) {
|
|
1001
|
+
return summary?.[key] ?? 0;
|
|
1002
|
+
}
|
|
1003
|
+
|
|
1004
|
+
function isValidTurnAuditStage(stage) {
|
|
1005
|
+
if (!stage || typeof stage !== "object") return false;
|
|
1006
|
+
const allowedKeys = new Set([
|
|
1007
|
+
"phase",
|
|
1008
|
+
"occurred_at",
|
|
1009
|
+
"source_event_id",
|
|
1010
|
+
"delivery_id",
|
|
1011
|
+
"push_id",
|
|
1012
|
+
"reason",
|
|
1013
|
+
"duration_ms",
|
|
1014
|
+
"http_status",
|
|
1015
|
+
"server_code",
|
|
1016
|
+
]);
|
|
1017
|
+
if (Object.keys(stage).some((key) => !allowedKeys.has(key))) return false;
|
|
1018
|
+
const hasSafeId = (value) => value === undefined
|
|
1019
|
+
|| (typeof value === "string" && turnAuditStageIdPattern.test(value));
|
|
1020
|
+
return stage
|
|
1021
|
+
&& typeof stage === "object"
|
|
1022
|
+
&& turnAuditStagePhases.has(stage.phase)
|
|
1023
|
+
&& Number.isSafeInteger(stage.occurred_at)
|
|
1024
|
+
&& stage.occurred_at >= 0
|
|
1025
|
+
&& hasSafeId(stage.source_event_id)
|
|
1026
|
+
&& hasSafeId(stage.delivery_id)
|
|
1027
|
+
&& hasSafeId(stage.push_id)
|
|
1028
|
+
&& (stage.reason === undefined
|
|
1029
|
+
|| (stage.phase === "send_failed" && turnAuditStageFailureReasons.has(stage.reason))
|
|
1030
|
+
|| (stage.phase === "terminal_observed" && Object.hasOwn(turnAuditObservationReasons, stage.reason)))
|
|
1031
|
+
&& (stage.duration_ms === undefined
|
|
1032
|
+
|| (Number.isSafeInteger(stage.duration_ms) && stage.duration_ms >= 0))
|
|
1033
|
+
&& (stage.http_status === undefined
|
|
1034
|
+
|| (stage.phase === "send_failed"
|
|
1035
|
+
&& Number.isSafeInteger(stage.http_status)
|
|
1036
|
+
&& stage.http_status >= 100
|
|
1037
|
+
&& stage.http_status <= 599))
|
|
1038
|
+
&& (stage.server_code === undefined
|
|
1039
|
+
|| (stage.phase === "send_failed"
|
|
1040
|
+
&& Number.isSafeInteger(stage.server_code)
|
|
1041
|
+
&& stage.server_code >= 0
|
|
1042
|
+
&& stage.server_code <= 999999));
|
|
1043
|
+
}
|
|
1044
|
+
|
|
1045
|
+
function turnAuditFailureContext(stage) {
|
|
1046
|
+
if (stage.phase === "terminal_observed") return turnAuditObservationReasons[stage.reason] ?? null;
|
|
1047
|
+
if (stage.phase !== "send_failed") return null;
|
|
1048
|
+
if (stage.reason === "network") return "Push Server 网络请求失败";
|
|
1049
|
+
if (stage.reason === "invalid_response") {
|
|
1050
|
+
return stage.http_status === undefined
|
|
1051
|
+
? "Push Server 响应无效"
|
|
1052
|
+
: `Push Server 响应无效(HTTP ${stage.http_status})`;
|
|
1053
|
+
}
|
|
1054
|
+
if (stage.reason === "business_error") {
|
|
1055
|
+
const details = [
|
|
1056
|
+
...(stage.http_status === undefined ? [] : [`HTTP ${stage.http_status}`]),
|
|
1057
|
+
...(stage.server_code === undefined ? [] : [`服务码 ${stage.server_code}`]),
|
|
1058
|
+
];
|
|
1059
|
+
return details.length === 0 ? "Push Server 业务拒绝" : `Push Server 业务拒绝(${details.join(",")})`;
|
|
1060
|
+
}
|
|
1061
|
+
if (stage.reason === "http_error" && stage.http_status !== undefined) {
|
|
1062
|
+
const response = `Push Server 返回 HTTP ${stage.http_status}`;
|
|
1063
|
+
return stage.server_code === undefined ? response : `${response}(服务码 ${stage.server_code})`;
|
|
1064
|
+
}
|
|
1065
|
+
if (stage.server_code !== undefined) return `Push Server 业务拒绝(服务码 ${stage.server_code})`;
|
|
1066
|
+
if (stage.reason === "http_error") return "Push Server 返回 HTTP 错误";
|
|
1067
|
+
return stage.reason === "unknown" ? "Push Server 请求失败(原因未知)" : null;
|
|
1068
|
+
}
|
|
1069
|
+
|
|
1070
|
+
function turnAuditDuration(durationMs) {
|
|
1071
|
+
if (!Number.isSafeInteger(durationMs) || durationMs < 0) return null;
|
|
1072
|
+
if (durationMs < 1000) return `耗时 ${durationMs} ms`;
|
|
1073
|
+
return `耗时 ${Math.round(durationMs / 10) / 100} 秒`;
|
|
1074
|
+
}
|
|
1075
|
+
|
|
1076
|
+
function turnAuditStageText(stage) {
|
|
1077
|
+
const parts = [turnAuditStageLabels[stage.phase] ?? "阶段不可得"];
|
|
1078
|
+
const failureContext = turnAuditFailureContext(stage);
|
|
1079
|
+
const duration = turnAuditDuration(stage.duration_ms);
|
|
1080
|
+
if (failureContext) parts.push(failureContext);
|
|
1081
|
+
if (duration) parts.push(duration);
|
|
1082
|
+
return parts.join(" · ");
|
|
1083
|
+
}
|
|
1084
|
+
|
|
1085
|
+
function renderTurnAuditSummary(summary) {
|
|
1086
|
+
const labels = {
|
|
1087
|
+
terminal: "终态",
|
|
1088
|
+
push_required: "应推送",
|
|
1089
|
+
not_required: "无需推送",
|
|
1090
|
+
server_accepted: "Server 已接受",
|
|
1091
|
+
server_registered: "Server 已接收",
|
|
1092
|
+
bridge_not_observed: "未观察 Bridge",
|
|
1093
|
+
send_failed: "发送失败",
|
|
1094
|
+
awaiting_bridge: "等待 Bridge",
|
|
1095
|
+
unverifiable: "缺少投递证据",
|
|
1096
|
+
};
|
|
1097
|
+
for (const [key, label] of Object.entries(labels)) {
|
|
1098
|
+
const button = document.getElementById(`turn-audit-summary-${key.replaceAll("_", "-")}`);
|
|
1099
|
+
if (button) button.textContent = `${label} ${turnAuditSummaryValue(summary, key)}`;
|
|
1100
|
+
}
|
|
1101
|
+
}
|
|
1102
|
+
|
|
1103
|
+
function renderTurnAuditCoverage(coverage) {
|
|
1104
|
+
if (!turnAuditCoverage) return;
|
|
1105
|
+
const status = coverage?.status === "complete" ? "完整" : "部分覆盖,证据可能不可得";
|
|
1106
|
+
const reasons = Array.isArray(coverage?.reasons) && coverage.reasons.length > 0
|
|
1107
|
+
? `(${coverage.reasons.join("、")})`
|
|
1108
|
+
: "";
|
|
1109
|
+
turnAuditCoverage.textContent = `证据覆盖:${status}${reasons}`;
|
|
1110
|
+
turnAuditCoverage.className = coverage?.status === "complete"
|
|
1111
|
+
? "alert alert-success"
|
|
1112
|
+
: "alert alert-warning";
|
|
1113
|
+
}
|
|
1114
|
+
|
|
1115
|
+
function renderTurnAuditDetail(detail) {
|
|
1116
|
+
const section = document.createElement("section");
|
|
1117
|
+
section.className = "turn-audit-detail border-top pt-3 mt-3";
|
|
1118
|
+
section.append(textElement("h4", "Turn 明细", "h6"));
|
|
1119
|
+
|
|
1120
|
+
const definitions = document.createElement("dl");
|
|
1121
|
+
definitions.className = "row row-cols-1 row-cols-sm-2 row-cols-xl-3 g-3 mb-0";
|
|
1122
|
+
appendDefinition(definitions, "结果", detail.outcome ?? "内容不可得");
|
|
1123
|
+
appendDefinition(definitions, "最新状态", turnAuditStatusLabels[detail.latest_status] ?? detail.latest_status ?? "内容不可得");
|
|
1124
|
+
appendDefinition(definitions, "终态时间", formatLocalTime(detail.terminal_at));
|
|
1125
|
+
section.append(definitions);
|
|
1126
|
+
|
|
1127
|
+
const timeline = Array.isArray(detail.stages)
|
|
1128
|
+
&& detail.stages.length > 0
|
|
1129
|
+
&& detail.stages.every(isValidTurnAuditStage)
|
|
1130
|
+
? detail.stages
|
|
1131
|
+
: null;
|
|
1132
|
+
if (timeline) {
|
|
1133
|
+
const stages = document.createElement("ol");
|
|
1134
|
+
stages.className = "audit-timeline list-group list-group-numbered my-3";
|
|
1135
|
+
for (const stage of timeline) {
|
|
1136
|
+
const item = document.createElement("li");
|
|
1137
|
+
item.className = "list-group-item d-flex flex-column flex-sm-row justify-content-between gap-2";
|
|
1138
|
+
item.append(
|
|
1139
|
+
textElement("span", turnAuditStageText(stage), "badge text-bg-primary text-wrap text-start"),
|
|
1140
|
+
textElement("time", formatLocalTime(stage.occurred_at), "small text-body-secondary"),
|
|
1141
|
+
);
|
|
1142
|
+
stages.append(item);
|
|
1143
|
+
}
|
|
1144
|
+
section.append(stages);
|
|
1145
|
+
}
|
|
1146
|
+
|
|
1147
|
+
const userText = detail.user_text ?? "用户消息不可得";
|
|
1148
|
+
const finalText = detail.final_text ?? "最终回答不可得";
|
|
1149
|
+
section.append(
|
|
1150
|
+
textElement("h4", "用户消息", "h6 mt-3"),
|
|
1151
|
+
textElement("pre", userText, "bg-body-tertiary border rounded p-3 text-break mb-0"),
|
|
1152
|
+
);
|
|
1153
|
+
if (detail.user_text_truncated === true) {
|
|
1154
|
+
section.append(textElement("p", "用户消息已截断", "small text-warning mt-2 mb-0"));
|
|
1155
|
+
}
|
|
1156
|
+
section.append(
|
|
1157
|
+
textElement("h4", "最终回答", "h6 mt-3"),
|
|
1158
|
+
textElement("pre", finalText, "bg-body-tertiary border rounded p-3 text-break mb-0"),
|
|
1159
|
+
);
|
|
1160
|
+
if (detail.final_text_truncated === true) {
|
|
1161
|
+
section.append(textElement("p", "最终回答已截断", "small text-warning mt-2 mb-0"));
|
|
1162
|
+
}
|
|
1163
|
+
section.append(
|
|
1164
|
+
textElement("h4", "失败摘要", "h6 mt-3"),
|
|
1165
|
+
textElement("p", detail.failure_summary ?? "无固定失败摘要", "text-danger mb-0"),
|
|
1166
|
+
);
|
|
1167
|
+
return section;
|
|
1168
|
+
}
|
|
1169
|
+
|
|
1170
|
+
const triggerLabels = { pending_input: "等待输入", pending_approval: "等待授权", turn_completed: "回答完成", turn_failed: "执行失败" };
|
|
1171
|
+
|
|
1172
|
+
function renderTriggerList() {
|
|
1173
|
+
const entries = triggerSnapshot?.list ?? [];
|
|
1174
|
+
turnAuditList.replaceChildren();
|
|
1175
|
+
turnAuditEmpty.hidden = entries.length !== 0;
|
|
1176
|
+
turnAuditEmpty.textContent = triggerBusy ? "正在读取推送触发事件…" : triggerError ? "读取失败,请点击刷新当前页重试。" : triggerStatus !== "all" || triggerType !== "all" ? "最近 24 小时没有符合筛选条件的事件,可切换全部或调整触发类型。" : "最近 24 小时没有可读取的推送触发事件。";
|
|
1177
|
+
if (turnAuditEventCount) turnAuditEventCount.textContent = triggerSnapshot ? `已显示 ${entries.length} / ${triggerSnapshot.total} 次触发 · 最新在前${triggerExhausted && entries.length < triggerSnapshot.total ? " · 列表期间有变化,请刷新查看最新事件" : ""}` : "";
|
|
1178
|
+
if (turnAuditHistoryNote) turnAuditHistoryNote.hidden = !triggerSnapshot?.coverage?.reasons?.includes("historical_gap");
|
|
1179
|
+
for (const event of entries) {
|
|
1180
|
+
const article = document.createElement("article");
|
|
1181
|
+
article.className = "border rounded p-3 text-break";
|
|
1182
|
+
const header = document.createElement("div");
|
|
1183
|
+
header.className = "d-flex flex-column flex-sm-row justify-content-between gap-2 mb-2";
|
|
1184
|
+
header.append(textElement("h3", triggerLabels[event.event_type] ?? "推送触发", "h6 mb-0"),
|
|
1185
|
+
textElement("time", formatLocalTime(event.triggered_at), "small text-body-secondary"));
|
|
1186
|
+
article.append(header, textElement("p", event.title || "未命名会话", "fw-semibold mb-2"));
|
|
1187
|
+
const summary = event.summary ?? "消息摘要不可得";
|
|
1188
|
+
article.append(textElement("p", Array.from(summary).slice(0, 180).join("") + (Array.from(summary).length > 180 ? "…" : ""), "mb-2"));
|
|
1189
|
+
article.append(textElement("p", turnAuditStatusLabels[event.latest_status] ?? "状态不可得", "small fw-semibold mb-2"));
|
|
1190
|
+
// 异常类别独立显示,不能把执行失败误写成推送失败。
|
|
1191
|
+
const failurePhases = ["send_failed", "deadline_expired", "dropped_busy"];
|
|
1192
|
+
const acceptedAt = event.stages.reduce((last, stage) => stage.phase === "server_accepted" ? Math.max(last, stage.occurred_at) : last, -1);
|
|
1193
|
+
const recovered = event.latest_status === "server_accepted" && event.stages.some(stage => failurePhases.includes(stage.phase) && stage.occurred_at <= acceptedAt);
|
|
1194
|
+
if (event.event_type === "turn_failed") article.append(textElement("p", "执行异常", "small text-danger mb-2"));
|
|
1195
|
+
if (recovered) article.append(textElement("p", "重试后恢复", "small text-warning mb-2"));
|
|
1196
|
+
else if (failurePhases.includes(event.latest_status) || event.latest_status === "bridge_not_observed") {
|
|
1197
|
+
article.append(textElement("p", "推送异常 · 请展开投递证据排查", "small text-danger mb-2"));
|
|
1198
|
+
}
|
|
1199
|
+
const details = document.createElement("details");
|
|
1200
|
+
details.append(textElement("summary", "查看投递证据", "text-primary"));
|
|
1201
|
+
const ids = document.createElement("dl"); ids.className = "small mt-3 mb-2";
|
|
1202
|
+
appendDefinition(ids, "会话 ID", event.thread_id);
|
|
1203
|
+
appendDefinition(ids, "Turn ID", event.turn_id ?? "不可得");
|
|
1204
|
+
appendDefinition(ids, "触发事件 ID", event.source_event_id);
|
|
1205
|
+
details.append(ids, textElement("pre", summary, "text-break text-wrap bg-body-tertiary p-3 rounded"));
|
|
1206
|
+
if (event.summary_truncated) details.append(textElement("p", "摘要已截断", "small text-body-secondary"));
|
|
1207
|
+
const stages = document.createElement("ol"); stages.className = "list-group list-group-numbered";
|
|
1208
|
+
for (const stage of event.stages ?? []) {
|
|
1209
|
+
const item = document.createElement("li"); item.className = "list-group-item small text-break";
|
|
1210
|
+
item.append(textElement("span", `${turnAuditStageText(stage)} · ${formatLocalTime(stage.occurred_at)}`));
|
|
1211
|
+
if (stage.delivery_id) item.append(textElement("div", `投递 ID:${stage.delivery_id}`, "text-body-secondary"));
|
|
1212
|
+
stages.append(item);
|
|
1213
|
+
}
|
|
1214
|
+
details.append(stages); article.append(details); turnAuditList.append(article);
|
|
1215
|
+
}
|
|
1216
|
+
if (turnAuditLoadMoreSessionsButton) {
|
|
1217
|
+
turnAuditLoadMoreSessionsButton.textContent = "加载更多触发事件";
|
|
1218
|
+
turnAuditLoadMoreSessionsButton.hidden = !triggerSnapshot || triggerExhausted || entries.length >= triggerSnapshot.total;
|
|
1219
|
+
turnAuditLoadMoreSessionsButton.disabled = triggerBusy;
|
|
1220
|
+
}
|
|
1221
|
+
if (triggerSnapshot) renderTurnAuditCoverage(triggerSnapshot.coverage);
|
|
1222
|
+
}
|
|
1223
|
+
|
|
1224
|
+
async function loadTriggerEvents(more = false) {
|
|
1225
|
+
if (more && (triggerBusy || triggerExhausted || !triggerSnapshot || triggerSnapshot.list.length >= triggerSnapshot.total)) return;
|
|
1226
|
+
const generation = ++triggerGeneration;
|
|
1227
|
+
const snapshot = triggerSnapshot;
|
|
1228
|
+
const page = more ? snapshot.page + 1 : 1;
|
|
1229
|
+
triggerBusy = true; triggerError = false; renderTurnAuditList();
|
|
1230
|
+
try {
|
|
1231
|
+
const query = `${triggerStatus === "all" ? "" : `&status=${triggerStatus}`}${triggerType === "all" ? "" : `&event_type=${triggerType}`}`;
|
|
1232
|
+
const data = await getControlJson(`/api/v1/control/turn-notifications/events?page=${page}&page_size=50${query}`);
|
|
1233
|
+
if (generation !== triggerGeneration) return;
|
|
1234
|
+
if (!Array.isArray(data.list) || data.page !== page || data.page_size !== 50 || !Number.isSafeInteger(data.total)
|
|
1235
|
+
|| data.total < data.list.length) throw new Error("Trigger page unavailable");
|
|
1236
|
+
// 刷新与切换后旧请求失效;分页合并按事件身份去重,不按 Turn 去重。
|
|
1237
|
+
const combined = more ? [...snapshot.list, ...data.list] : data.list;
|
|
1238
|
+
const unique = new Map(combined.map(event => [`${event.thread_id}\0${event.source_event_id}`, event]));
|
|
1239
|
+
triggerSnapshot = { ...data, list: [...unique.values()] };
|
|
1240
|
+
// offset 分页期间新增头部事件会使去重后的数量小于 total;到达响应末页即停止。
|
|
1241
|
+
triggerExhausted = more ? data.list.length === 0 || page * data.page_size >= data.total : data.list.length >= data.total;
|
|
1242
|
+
} catch {
|
|
1243
|
+
if (generation === triggerGeneration) {
|
|
1244
|
+
triggerError = true;
|
|
1245
|
+
refreshStatus.textContent = "推送触发事件读取失败,请刷新重试。";
|
|
1246
|
+
}
|
|
1247
|
+
} finally {
|
|
1248
|
+
if (generation === triggerGeneration) { triggerBusy = false; renderTurnAuditList(); }
|
|
1249
|
+
}
|
|
1250
|
+
}
|
|
1251
|
+
|
|
1252
|
+
function selectTurnAuditView(view) {
|
|
1253
|
+
if (turnAuditView === view) return;
|
|
1254
|
+
turnAuditView = view;
|
|
1255
|
+
// 失效另一视图的在途请求,防止迟到响应改变当前内容或状态提示。
|
|
1256
|
+
triggerGeneration++; triggerBusy = false;
|
|
1257
|
+
controlRefreshGeneration++;
|
|
1258
|
+
if (!daemonActionBusy) refreshButton.disabled = false;
|
|
1259
|
+
for (const [threadId, generation] of turnAuditDetailGenerations) turnAuditDetailGenerations.set(threadId, generation + 1);
|
|
1260
|
+
turnAuditDetailRequests.clear();
|
|
1261
|
+
turnAuditListGeneration++; turnAuditSessionPageRequest = null; turnAuditRefreshPending = false;
|
|
1262
|
+
refreshStatus.textContent = "";
|
|
1263
|
+
renderTurnAuditList();
|
|
1264
|
+
if (view === "events" && !triggerSnapshot) void loadTriggerEvents();
|
|
1265
|
+
if (view === "sessions" && !turnAuditSnapshot) void refreshTurnAuditPage();
|
|
1266
|
+
}
|
|
1267
|
+
|
|
1268
|
+
function renderTurnAuditList() {
|
|
1269
|
+
if (!turnAuditAvailable || !turnAuditList || !turnAuditEmpty) return;
|
|
1270
|
+
for (const [button, view] of [[turnAuditEventsButton, "events"], [turnAuditSessionsButton, "sessions"]]) {
|
|
1271
|
+
if (button) { button.setAttribute("aria-pressed", String(turnAuditView === view)); button.className = turnAuditView === view ? "btn btn-primary" : "btn btn-outline-primary"; }
|
|
1272
|
+
}
|
|
1273
|
+
if (triggerFilters) triggerFilters.hidden = turnAuditView !== "events";
|
|
1274
|
+
for (const status of triggerFilterNames) {
|
|
1275
|
+
const button = document.getElementById(`trigger-filter-${status}`);
|
|
1276
|
+
if (button) { button.setAttribute("aria-pressed", String(triggerStatus === status)); button.className = triggerStatus === status ? "btn btn-sm btn-primary" : "btn btn-sm btn-outline-primary"; }
|
|
1277
|
+
}
|
|
1278
|
+
if (turnAuditView === "events") { renderTriggerList(); return; }
|
|
1279
|
+
if (turnAuditEventCount) turnAuditEventCount.textContent = "";
|
|
1280
|
+
if (turnAuditHistoryNote) turnAuditHistoryNote.hidden = true;
|
|
1281
|
+
if (turnAuditLoadMoreSessionsButton) turnAuditLoadMoreSessionsButton.textContent = "加载更多会话";
|
|
1282
|
+
if (turnAuditSnapshot) renderTurnAuditCoverage(turnAuditSnapshot.coverage);
|
|
1283
|
+
turnAuditEmpty.textContent = "当前没有 Turn 审计会话。";
|
|
1284
|
+
const entries = turnAuditSnapshot?.list ?? [];
|
|
1285
|
+
turnAuditList.replaceChildren();
|
|
1286
|
+
turnAuditEmpty.hidden = entries.length !== 0;
|
|
1287
|
+
for (const session of entries) {
|
|
1288
|
+
const article = document.createElement("article");
|
|
1289
|
+
article.className = isAttentionSession(session)
|
|
1290
|
+
? "turn-audit-entry attention card card-body shadow-sm border-warning"
|
|
1291
|
+
: "turn-audit-entry card card-body shadow-sm";
|
|
1292
|
+
const button = textElement(
|
|
1293
|
+
"button",
|
|
1294
|
+
`${session.title || "未命名会话"} · ${session.thread_id}`,
|
|
1295
|
+
"turn-audit-session btn btn-link p-0 text-start fw-semibold",
|
|
1296
|
+
);
|
|
1297
|
+
button.setAttribute("id", `turn-audit-session-${session.thread_id}`);
|
|
1298
|
+
button.setAttribute("type", "button");
|
|
1299
|
+
button.addEventListener("click", () => {
|
|
1300
|
+
void loadTurnAuditDetail(session);
|
|
1301
|
+
});
|
|
1302
|
+
article.append(button);
|
|
1303
|
+
|
|
1304
|
+
const metadata = document.createElement("dl");
|
|
1305
|
+
metadata.className = "row row-cols-1 row-cols-sm-2 row-cols-xl-3 g-3 mt-3 mb-0";
|
|
1306
|
+
appendDefinition(metadata, "Turn 数", session.turn_count ?? "内容不可得");
|
|
1307
|
+
appendDefinition(metadata, "应推送", session.push_required ?? "内容不可得");
|
|
1308
|
+
appendDefinition(metadata, "Server 已接受", session.server_accepted ?? "内容不可得");
|
|
1309
|
+
appendDefinition(metadata, "需关注", session.attention_count ?? "内容不可得");
|
|
1310
|
+
appendDefinition(metadata, "最近终态时间", formatLocalTime(session.latest_terminal_at));
|
|
1311
|
+
article.append(metadata);
|
|
1312
|
+
const detailState = turnAuditDetails.get(session.thread_id);
|
|
1313
|
+
if (detailState) {
|
|
1314
|
+
for (const detail of detailState.list) article.append(renderTurnAuditDetail(detail));
|
|
1315
|
+
if (detailState.list.length < detailState.total) {
|
|
1316
|
+
const more = textElement("button", "加载更多 Turn", "btn btn-sm btn-outline-secondary mt-3");
|
|
1317
|
+
more.setAttribute("id", `turn-audit-more-${session.thread_id}`);
|
|
1318
|
+
more.setAttribute("type", "button");
|
|
1319
|
+
more.addEventListener("click", () => {
|
|
1320
|
+
void loadTurnAuditDetail(session, detailState.page + 1);
|
|
1321
|
+
});
|
|
1322
|
+
article.append(more);
|
|
1323
|
+
}
|
|
1324
|
+
}
|
|
1325
|
+
turnAuditList.append(article);
|
|
1326
|
+
}
|
|
1327
|
+
if (turnAuditLoadMoreSessionsButton) {
|
|
1328
|
+
turnAuditLoadMoreSessionsButton.hidden = !turnAuditSnapshot
|
|
1329
|
+
|| entries.length >= turnAuditSnapshot.total;
|
|
1330
|
+
turnAuditLoadMoreSessionsButton.disabled = turnAuditSessionPageRequest !== null
|
|
1331
|
+
|| turnAuditRefreshPending;
|
|
1332
|
+
}
|
|
1333
|
+
}
|
|
1334
|
+
|
|
1335
|
+
async function loadMoreTurnAuditSessions() {
|
|
1336
|
+
const snapshot = turnAuditSnapshot;
|
|
1337
|
+
if (
|
|
1338
|
+
!turnAuditAvailable
|
|
1339
|
+
|| !snapshot
|
|
1340
|
+
|| snapshot.list.length >= snapshot.total
|
|
1341
|
+
|| turnAuditSessionPageRequest
|
|
1342
|
+
|| turnAuditRefreshPending
|
|
1343
|
+
) return;
|
|
1344
|
+
|
|
1345
|
+
const request = { snapshot, generation: turnAuditListGeneration };
|
|
1346
|
+
const nextPage = snapshot.page + 1;
|
|
1347
|
+
turnAuditSessionPageRequest = request;
|
|
1348
|
+
renderTurnAuditList();
|
|
1349
|
+
try {
|
|
1350
|
+
const data = await getControlJson(
|
|
1351
|
+
`/api/v1/control/turn-notifications?page=${nextPage}&page_size=50`,
|
|
1352
|
+
);
|
|
1353
|
+
if (turnAuditSessionPageRequest !== request || turnAuditListGeneration !== request.generation) return;
|
|
1354
|
+
if (
|
|
1355
|
+
!Array.isArray(data.list)
|
|
1356
|
+
|| data.page !== nextPage
|
|
1357
|
+
|| data.page_size !== 50
|
|
1358
|
+
|| !Number.isSafeInteger(data.total)
|
|
1359
|
+
|| data.total < snapshot.list.length + data.list.length
|
|
1360
|
+
) {
|
|
1361
|
+
throw new Error("Turn audit session page unavailable");
|
|
1362
|
+
}
|
|
1363
|
+
turnAuditSnapshot = { ...data, list: [...snapshot.list, ...data.list] };
|
|
1364
|
+
renderTurnAuditCoverage(data.coverage);
|
|
1365
|
+
renderTurnAuditSummary(data.summary);
|
|
1366
|
+
} catch {
|
|
1367
|
+
// 补页失败不替换当前已读取的会话页,用户可手动再次尝试。
|
|
1368
|
+
if (turnAuditSessionPageRequest === request && turnAuditListGeneration === request.generation) refreshStatus.textContent = "Turn 审计会话读取失败,请稍后重试。";
|
|
1369
|
+
} finally {
|
|
1370
|
+
if (turnAuditSessionPageRequest === request) {
|
|
1371
|
+
turnAuditSessionPageRequest = null;
|
|
1372
|
+
renderTurnAuditList();
|
|
1373
|
+
}
|
|
1374
|
+
}
|
|
1375
|
+
}
|
|
1376
|
+
|
|
1377
|
+
async function loadTurnAuditDetail(session, page = 1) {
|
|
1378
|
+
if (!turnAuditAvailable) return;
|
|
1379
|
+
const threadId = session.thread_id;
|
|
1380
|
+
const current = turnAuditDetails.get(threadId);
|
|
1381
|
+
// 首次展开只读第一页;后续页必须由用户显式点击“加载更多”触发。
|
|
1382
|
+
if ((page === 1 && current) || (current && page <= current.page) || turnAuditDetailRequests.has(threadId)) return;
|
|
1383
|
+
const generation = (turnAuditDetailGenerations.get(threadId) ?? 0) + 1;
|
|
1384
|
+
turnAuditDetailGenerations.set(threadId, generation);
|
|
1385
|
+
turnAuditDetailRequests.add(threadId);
|
|
1386
|
+
try {
|
|
1387
|
+
const detail = await getControlJson(
|
|
1388
|
+
`/api/v1/control/turn-notifications/detail?thread_id=${encodeURIComponent(threadId)}&page=${page}&page_size=50`,
|
|
1389
|
+
);
|
|
1390
|
+
if (turnAuditDetailGenerations.get(threadId) !== generation) return;
|
|
1391
|
+
if (!Array.isArray(detail.list) || detail.page !== page || detail.page_size !== 50
|
|
1392
|
+
|| !Number.isSafeInteger(detail.total) || detail.total < detail.list.length) {
|
|
1393
|
+
throw new Error("Turn detail unavailable");
|
|
1394
|
+
}
|
|
1395
|
+
const list = page === 1 ? detail.list : [...(current?.list ?? []), ...detail.list];
|
|
1396
|
+
turnAuditDetails.set(threadId, { list, page, total: detail.total });
|
|
1397
|
+
renderTurnAuditList();
|
|
1398
|
+
} catch {
|
|
1399
|
+
// 明细失败只影响当前会话,保留已成功的列表快照和其它明细。
|
|
1400
|
+
if (turnAuditDetailGenerations.get(threadId) === generation) refreshStatus.textContent = "Turn 明细读取失败,请稍后重试。";
|
|
1401
|
+
} finally {
|
|
1402
|
+
if (turnAuditDetailGenerations.get(threadId) === generation) turnAuditDetailRequests.delete(threadId);
|
|
1403
|
+
}
|
|
1404
|
+
}
|
|
1405
|
+
|
|
1406
|
+
async function refreshTurnAuditPage() {
|
|
1407
|
+
if (!turnAuditAvailable) return;
|
|
1408
|
+
// 时间列表与旧终态汇总独立读取,历史核对慢或失败不能阻断事件展示。
|
|
1409
|
+
const eventLoad = turnAuditView === "events" ? loadTriggerEvents() : Promise.resolve();
|
|
1410
|
+
const refreshGeneration = controlRefreshGeneration;
|
|
1411
|
+
const turnAuditGeneration = ++turnAuditListGeneration;
|
|
1412
|
+
// 刷新始终以第 1 页为新的审计会话基线,旧补页响应不得回写。
|
|
1413
|
+
turnAuditSessionPageRequest = null;
|
|
1414
|
+
// 刷新完成前禁止补页,避免旧快照与新一代响应交错覆盖。
|
|
1415
|
+
turnAuditRefreshPending = true;
|
|
1416
|
+
renderTurnAuditList();
|
|
1417
|
+
try {
|
|
1418
|
+
const turnAuditData = await getControlJson("/api/v1/control/turn-notifications?page=1&page_size=50");
|
|
1419
|
+
if (
|
|
1420
|
+
controlRefreshGeneration !== refreshGeneration
|
|
1421
|
+
|| turnAuditGeneration !== turnAuditListGeneration
|
|
1422
|
+
) return;
|
|
1423
|
+
// 仅当前成功快照才失效明细与在途请求,失败时保留最近成功内容。
|
|
1424
|
+
for (const [threadId, generation] of turnAuditDetailGenerations) {
|
|
1425
|
+
turnAuditDetailGenerations.set(threadId, generation + 1);
|
|
1426
|
+
}
|
|
1427
|
+
turnAuditDetails.clear();
|
|
1428
|
+
turnAuditSnapshot = turnAuditData;
|
|
1429
|
+
if (turnAuditView === "sessions") renderTurnAuditCoverage(turnAuditData.coverage);
|
|
1430
|
+
renderTurnAuditSummary(turnAuditData.summary);
|
|
1431
|
+
renderTurnAuditList();
|
|
1432
|
+
} finally {
|
|
1433
|
+
await eventLoad;
|
|
1434
|
+
if (turnAuditGeneration === turnAuditListGeneration) {
|
|
1435
|
+
// 刷新失败也要恢复旧快照的补页入口;只有当前代次可以解除禁用。
|
|
1436
|
+
turnAuditRefreshPending = false;
|
|
1437
|
+
renderTurnAuditList();
|
|
1438
|
+
}
|
|
1439
|
+
}
|
|
1440
|
+
}
|
|
1441
|
+
|
|
1442
|
+
async function refreshLogsPage(kind) {
|
|
1443
|
+
const logRequestGeneration = beginLogRequest(kind);
|
|
1444
|
+
const logsData = await getControlJson(`/api/v1/control/logs?kind=${kind}&page=1&page_size=50`);
|
|
1445
|
+
if (!isCurrentLogRequest(kind, logRequestGeneration)) return;
|
|
1446
|
+
logSnapshots[kind] = logsData;
|
|
1447
|
+
// 用户切换 kind 后,旧刷新只更新自身缓存,不触碰当前可见日志。
|
|
1448
|
+
if (selectedLogKind === kind && intendedLogKind === kind) renderLogs();
|
|
1449
|
+
}
|
|
1450
|
+
|
|
1451
|
+
const CONNECTION_STATE_LABELS = { probe: "探测中", active: "可用", draining: "正在释放" };
|
|
1452
|
+
let connectionsBusy = false;
|
|
1453
|
+
let connectionsTimer = null;
|
|
1454
|
+
let connectionsPageClosed = false;
|
|
1455
|
+
|
|
1456
|
+
function renderConnections(data) {
|
|
1457
|
+
connectionsCurrent.replaceChildren();
|
|
1458
|
+
connectionsEmpty.hidden = true;
|
|
1459
|
+
if (data.availability !== "available") {
|
|
1460
|
+
connectionsStatus.textContent = "daemon 未运行,当前连接状态不可读取。";
|
|
1461
|
+
return;
|
|
1462
|
+
}
|
|
1463
|
+
const groups = new Map();
|
|
1464
|
+
const groupFor = (row) => {
|
|
1465
|
+
if (!groups.has(row.binding_ref)) groups.set(row.binding_ref, {
|
|
1466
|
+
name: row.client_name === "unknown" ? "名称不可用" : row.client_name,
|
|
1467
|
+
ref: row.binding_ref, sessions: [], pending: { direct: 0, relay: 0 },
|
|
1468
|
+
});
|
|
1469
|
+
return groups.get(row.binding_ref);
|
|
1470
|
+
};
|
|
1471
|
+
for (const row of data.list) groupFor(row).sessions.push(row);
|
|
1472
|
+
for (const row of data.pending) groupFor(row).pending[row.transport] += row.count;
|
|
1473
|
+
connectionsEmpty.hidden = groups.size !== 0;
|
|
1474
|
+
for (const group of [...groups.values()].sort((a, b) => a.name.localeCompare(b.name) || a.ref.localeCompare(b.ref))) {
|
|
1475
|
+
const section = document.createElement("section");
|
|
1476
|
+
section.className = "card shadow-sm";
|
|
1477
|
+
const body = document.createElement("div");
|
|
1478
|
+
body.className = "card-body";
|
|
1479
|
+
body.append(textElement("h3", group.name, "h5 mb-1"),
|
|
1480
|
+
textElement("p", `设备引用 ${group.ref}`, "small font-monospace text-body-secondary text-break mb-3"));
|
|
1481
|
+
const quota = document.createElement("p");
|
|
1482
|
+
quota.className = "mb-3";
|
|
1483
|
+
const occupied = group.sessions.filter(row => row.occupies_slot).length;
|
|
1484
|
+
const direct = group.sessions.filter(row => row.transport === "direct" && row.occupies_slot).length;
|
|
1485
|
+
const relay = group.sessions.filter(row => row.transport === "relay" && row.occupies_slot).length;
|
|
1486
|
+
quota.textContent = `共享业务额度 ${occupied} / ${data.limits.shared} · Direct ${direct} · Relay ${relay}`;
|
|
1487
|
+
for (const transport of ["direct", "relay"]) {
|
|
1488
|
+
if (group.pending[transport]) quota.append(textElement("span",
|
|
1489
|
+
` · ${transport === "direct" ? "Direct" : "Relay"} 创建预约 ${group.pending[transport]}`));
|
|
1490
|
+
}
|
|
1491
|
+
body.append(quota);
|
|
1492
|
+
if (group.sessions.length) {
|
|
1493
|
+
const scroll = document.createElement("div");
|
|
1494
|
+
scroll.className = "table-responsive";
|
|
1495
|
+
const table = document.createElement("table");
|
|
1496
|
+
table.className = "table table-sm align-middle mb-0";
|
|
1497
|
+
const caption = textElement("caption", `${group.name} 的当前连接会话`, "visually-hidden");
|
|
1498
|
+
const head = document.createElement("thead");
|
|
1499
|
+
const header = document.createElement("tr");
|
|
1500
|
+
for (const label of ["通道 / 会话引用", "状态", "额度", "建立时间", "到期时间", "在途 / 订阅"]) {
|
|
1501
|
+
const cell = textElement("th", label, "text-nowrap");
|
|
1502
|
+
cell.setAttribute("scope", "col");
|
|
1503
|
+
header.append(cell);
|
|
1504
|
+
}
|
|
1505
|
+
head.append(header);
|
|
1506
|
+
const rows = document.createElement("tbody");
|
|
1507
|
+
for (const row of group.sessions.sort((a, b) => a.transport.localeCompare(b.transport) || a.created_at_ms - b.created_at_ms)) {
|
|
1508
|
+
const tr = document.createElement("tr");
|
|
1509
|
+
const identity = document.createElement("td");
|
|
1510
|
+
identity.append(textElement("div", row.transport === "direct" ? "Direct" : "Relay"),
|
|
1511
|
+
textElement("div", row.session_ref, "small font-monospace text-nowrap"));
|
|
1512
|
+
const state = document.createElement("td");
|
|
1513
|
+
state.append(textElement("span", CONNECTION_STATE_LABELS[row.state],
|
|
1514
|
+
`badge ${row.state === "draining" ? "text-bg-warning" : row.state === "active" ? "text-bg-success" : "text-bg-secondary"}`));
|
|
1515
|
+
tr.append(identity, state, textElement("td", row.occupies_slot ? "占用" : "不占用", "text-nowrap"),
|
|
1516
|
+
textElement("td", formatLocalTime(row.created_at_ms), "small text-nowrap"),
|
|
1517
|
+
textElement("td", formatLocalTime(row.expires_at_ms), "small text-nowrap"),
|
|
1518
|
+
textElement("td", `${row.in_flight} / ${row.subscriptions}`, "font-monospace text-nowrap"));
|
|
1519
|
+
rows.append(tr);
|
|
1520
|
+
}
|
|
1521
|
+
table.append(caption, head, rows);
|
|
1522
|
+
scroll.append(table);
|
|
1523
|
+
body.append(scroll);
|
|
1524
|
+
}
|
|
1525
|
+
section.append(body);
|
|
1526
|
+
connectionsCurrent.append(section);
|
|
1527
|
+
}
|
|
1528
|
+
connectionsStatus.textContent = `快照时间:${formatLocalTime(data.captured_at_ms)} · ${data.list.length} 条会话 · 每 3 秒刷新`;
|
|
1529
|
+
}
|
|
1530
|
+
|
|
1531
|
+
function stopConnectionsRefresh() {
|
|
1532
|
+
if (connectionsTimer !== null) clearTimeout(connectionsTimer);
|
|
1533
|
+
connectionsTimer = null;
|
|
1534
|
+
}
|
|
1535
|
+
|
|
1536
|
+
function scheduleConnectionsRefresh() {
|
|
1537
|
+
stopConnectionsRefresh();
|
|
1538
|
+
if (!connectionsPageClosed && !document.hidden) {
|
|
1539
|
+
// 在读取完成后再预约下一轮;慢请求与手动刷新不会产生并发查询。
|
|
1540
|
+
connectionsTimer = setTimeout(() => { connectionsTimer = null; void refreshConnections(); }, 3_000);
|
|
1541
|
+
}
|
|
1542
|
+
}
|
|
1543
|
+
|
|
1544
|
+
async function refreshConnections() {
|
|
1545
|
+
if (connectionsBusy || connectionsPageClosed || document.hidden) return;
|
|
1546
|
+
stopConnectionsRefresh();
|
|
1547
|
+
connectionsBusy = true;
|
|
1548
|
+
refreshButton.disabled = true;
|
|
1549
|
+
refreshStatus.textContent = "正在刷新…";
|
|
1550
|
+
try {
|
|
1551
|
+
const data = await getControlJson("/api/v1/control/connections");
|
|
1552
|
+
if (connectionsPageClosed) return;
|
|
1553
|
+
renderConnections(data);
|
|
1554
|
+
refreshStatus.textContent = "已刷新。";
|
|
1555
|
+
} catch {
|
|
1556
|
+
if (connectionsPageClosed) return;
|
|
1557
|
+
connectionsStatus.textContent = "读取失败,保留上次快照,数据可能已过时。可手动刷新重试。";
|
|
1558
|
+
refreshStatus.textContent = "刷新失败,请稍后重试。";
|
|
1559
|
+
} finally {
|
|
1560
|
+
connectionsBusy = false;
|
|
1561
|
+
refreshButton.disabled = false;
|
|
1562
|
+
if (!connectionsPageClosed) scheduleConnectionsRefresh();
|
|
1563
|
+
}
|
|
1564
|
+
}
|
|
1565
|
+
|
|
1566
|
+
async function refreshCurrentPage() {
|
|
1567
|
+
if (currentPage.key === "connections") return refreshConnections();
|
|
1568
|
+
controlRefreshGeneration += 1;
|
|
1569
|
+
const refreshGeneration = controlRefreshGeneration;
|
|
1570
|
+
refreshButton.disabled = true;
|
|
1571
|
+
refreshStatus.textContent = "正在刷新…";
|
|
1572
|
+
try {
|
|
1573
|
+
if (currentPage.key === "overview") {
|
|
1574
|
+
const metricsRange = selectedDaemonMetricsRange;
|
|
1575
|
+
daemonMetricsRangeGeneration += 1;
|
|
1576
|
+
const [data, metricsCurrent, metricsHistory] = await Promise.all([
|
|
1577
|
+
getControlJson("/api/v1/control/overview"),
|
|
1578
|
+
getControlJson("/api/v1/control/daemon-metrics/current").catch(() => null),
|
|
1579
|
+
getControlJson(`/api/v1/control/daemon-metrics/history?range=${metricsRange}`).catch(() => null),
|
|
1580
|
+
]);
|
|
1581
|
+
if (controlRefreshGeneration !== refreshGeneration) return;
|
|
1582
|
+
renderOverview(data);
|
|
1583
|
+
if (metricsCurrent && metricsHistory) renderDaemonMetrics(metricsCurrent, metricsHistory);
|
|
1584
|
+
else renderDaemonMetricsUnavailable();
|
|
1585
|
+
}
|
|
1586
|
+
if (currentPage.key === "sessions") {
|
|
1587
|
+
const data = await getControlJson("/api/v1/control/followers?page=1&page_size=50");
|
|
1588
|
+
if (controlRefreshGeneration !== refreshGeneration) return;
|
|
1589
|
+
renderFollowers(data);
|
|
1590
|
+
}
|
|
1591
|
+
if (currentPage.key === "subscriptions") {
|
|
1592
|
+
const data = await getControlJson("/api/v1/control/follower-traces?page=1&page_size=50");
|
|
1593
|
+
if (controlRefreshGeneration !== refreshGeneration) return;
|
|
1594
|
+
renderFollowerTraces(data);
|
|
1595
|
+
}
|
|
1596
|
+
if (currentPage.key === "turn-audit") await refreshTurnAuditPage();
|
|
1597
|
+
|
|
1598
|
+
if (currentPage.key === "logs") await refreshLogsPage(selectedLogKind);
|
|
1599
|
+
if (controlRefreshGeneration === refreshGeneration) refreshStatus.textContent = currentPage.key === "turn-audit" && turnAuditView === "events" && triggerError
|
|
1600
|
+
? "推送触发事件读取失败,请刷新重试。" : "已刷新。";
|
|
1601
|
+
} catch {
|
|
1602
|
+
if (controlRefreshGeneration === refreshGeneration) refreshStatus.textContent = "刷新失败,请稍后重试。";
|
|
1603
|
+
} finally {
|
|
1604
|
+
if (controlRefreshGeneration === refreshGeneration) refreshButton.disabled = false;
|
|
1605
|
+
}
|
|
1606
|
+
}
|
|
1607
|
+
|
|
1608
|
+
async function selectLogKind(kind) {
|
|
1609
|
+
intendedLogKind = kind;
|
|
1610
|
+
if (kind === selectedLogKind) {
|
|
1611
|
+
refreshStatus.textContent = "";
|
|
1612
|
+
return;
|
|
1613
|
+
}
|
|
1614
|
+
if (logSnapshots[kind]) {
|
|
1615
|
+
selectedLogKind = kind;
|
|
1616
|
+
renderLogs();
|
|
1617
|
+
refreshStatus.textContent = "";
|
|
1618
|
+
return;
|
|
1619
|
+
}
|
|
1620
|
+
|
|
1621
|
+
const logRequestGeneration = beginLogRequest(kind);
|
|
1622
|
+
refreshStatus.textContent = "正在读取日志…";
|
|
1623
|
+
try {
|
|
1624
|
+
const logsData = await getControlJson(
|
|
1625
|
+
`/api/v1/control/logs?kind=${kind}&page=1&page_size=50`,
|
|
1626
|
+
);
|
|
1627
|
+
if (!isCurrentLogRequest(kind, logRequestGeneration)) return;
|
|
1628
|
+
logSnapshots[kind] = logsData;
|
|
1629
|
+
if (intendedLogKind !== kind) return;
|
|
1630
|
+
// 首次读取成功后才切换视图,失败时保留最近一次成功日志。
|
|
1631
|
+
selectedLogKind = kind;
|
|
1632
|
+
renderLogs();
|
|
1633
|
+
refreshStatus.textContent = "日志已更新。";
|
|
1634
|
+
} catch {
|
|
1635
|
+
if (
|
|
1636
|
+
isCurrentLogRequest(kind, logRequestGeneration)
|
|
1637
|
+
&& intendedLogKind === kind
|
|
1638
|
+
) {
|
|
1639
|
+
refreshStatus.textContent = "日志读取失败,请稍后重试。";
|
|
1640
|
+
}
|
|
1641
|
+
}
|
|
1642
|
+
}
|
|
1643
|
+
|
|
1644
|
+
async function loadMoreLogs() {
|
|
1645
|
+
const kind = selectedLogKind;
|
|
1646
|
+
const previous = logSnapshots[kind];
|
|
1647
|
+
if (!previous || previous.list.length >= previous.total) return;
|
|
1648
|
+
|
|
1649
|
+
const logRequestGeneration = beginLogRequest(kind);
|
|
1650
|
+
loadMoreLogsButton.disabled = true;
|
|
1651
|
+
try {
|
|
1652
|
+
const next = await getControlJson(
|
|
1653
|
+
`/api/v1/control/logs?kind=${kind}&page=${previous.page + 1}&page_size=50`,
|
|
1654
|
+
);
|
|
1655
|
+
if (!isCurrentLogRequest(kind, logRequestGeneration)) return;
|
|
1656
|
+
logSnapshots[kind] = {
|
|
1657
|
+
...next,
|
|
1658
|
+
list: [...previous.list, ...next.list],
|
|
1659
|
+
};
|
|
1660
|
+
if (selectedLogKind === kind) renderLogs();
|
|
1661
|
+
} catch {
|
|
1662
|
+
if (
|
|
1663
|
+
isCurrentLogRequest(kind, logRequestGeneration)
|
|
1664
|
+
&& selectedLogKind === kind
|
|
1665
|
+
) {
|
|
1666
|
+
refreshStatus.textContent = "日志读取失败,请稍后重试。";
|
|
1667
|
+
}
|
|
1668
|
+
} finally {
|
|
1669
|
+
loadMoreLogsButton.disabled = false;
|
|
1670
|
+
}
|
|
1671
|
+
}
|
|
1672
|
+
|
|
1673
|
+
function updateDaemonActionSubmitState() {
|
|
1674
|
+
if (!daemonActionSubmitButton || !daemonActionConfirmationInput) return;
|
|
1675
|
+
const confirmation = daemonActionConfirmations[selectedDaemonAction];
|
|
1676
|
+
daemonActionSubmitButton.disabled = daemonActionBusy
|
|
1677
|
+
|| !confirmation
|
|
1678
|
+
|| daemonActionConfirmationInput.value !== confirmation;
|
|
1679
|
+
}
|
|
1680
|
+
|
|
1681
|
+
function clearDaemonActionConfirmation() {
|
|
1682
|
+
selectedDaemonAction = null;
|
|
1683
|
+
if (daemonActionConfirmationInput) {
|
|
1684
|
+
daemonActionConfirmationInput.value = "";
|
|
1685
|
+
daemonActionConfirmationInput.hidden = true;
|
|
1686
|
+
}
|
|
1687
|
+
if (daemonActionConfirmationLabel) {
|
|
1688
|
+
daemonActionConfirmationLabel.textContent = "";
|
|
1689
|
+
daemonActionConfirmationLabel.hidden = true;
|
|
1690
|
+
}
|
|
1691
|
+
if (daemonActionSubmitButton) {
|
|
1692
|
+
daemonActionSubmitButton.hidden = true;
|
|
1693
|
+
daemonActionSubmitButton.disabled = true;
|
|
1694
|
+
}
|
|
1695
|
+
}
|
|
1696
|
+
|
|
1697
|
+
function setDaemonActionBusy(busy) {
|
|
1698
|
+
daemonActionBusy = busy;
|
|
1699
|
+
// 动作会使旧刷新过期;由动作负责恢复其占用的刷新按钮。
|
|
1700
|
+
refreshButton.disabled = busy;
|
|
1701
|
+
for (const button of [daemonStartButton, daemonStopButton, daemonRestartButton]) {
|
|
1702
|
+
if (button) button.disabled = busy;
|
|
1703
|
+
}
|
|
1704
|
+
if (daemonActionConfirmationInput) daemonActionConfirmationInput.disabled = busy;
|
|
1705
|
+
updateDaemonActionSubmitState();
|
|
1706
|
+
}
|
|
1707
|
+
|
|
1708
|
+
function selectDaemonAction(action) {
|
|
1709
|
+
if (daemonActionBusy || !daemonActionConfirmationInput || !daemonActionSubmitButton) return;
|
|
1710
|
+
selectedDaemonAction = action;
|
|
1711
|
+
// 每次选择高风险操作都丢弃上一次输入,避免确认内容跨操作复用。
|
|
1712
|
+
daemonActionConfirmationInput.value = "";
|
|
1713
|
+
daemonActionConfirmationInput.hidden = false;
|
|
1714
|
+
if (daemonActionConfirmationLabel) {
|
|
1715
|
+
daemonActionConfirmationLabel.textContent = `请输入 ${daemonActionConfirmations[action]} 以继续`;
|
|
1716
|
+
daemonActionConfirmationLabel.hidden = false;
|
|
1717
|
+
}
|
|
1718
|
+
daemonActionSubmitButton.hidden = false;
|
|
1719
|
+
updateDaemonActionSubmitState();
|
|
1720
|
+
}
|
|
1721
|
+
|
|
1722
|
+
function hasExpectedDaemonActionResult(data, action) {
|
|
1723
|
+
const hasExactKeys = (value, keys) => {
|
|
1724
|
+
const actual = Object.keys(value);
|
|
1725
|
+
return actual.length === keys.length
|
|
1726
|
+
&& keys.every((key) => Object.prototype.hasOwnProperty.call(value, key));
|
|
1727
|
+
};
|
|
1728
|
+
if (
|
|
1729
|
+
!data
|
|
1730
|
+
|| typeof data !== "object"
|
|
1731
|
+
|| Array.isArray(data)
|
|
1732
|
+
|| !hasExactKeys(data, ["action", "outcome", "daemon"])
|
|
1733
|
+
|| data.action !== action
|
|
1734
|
+
|| !data.daemon
|
|
1735
|
+
|| typeof data.daemon !== "object"
|
|
1736
|
+
|| Array.isArray(data.daemon)
|
|
1737
|
+
) return false;
|
|
1738
|
+
|
|
1739
|
+
if (action === "stop") {
|
|
1740
|
+
return (data.outcome === "stopped" || data.outcome === "already_stopped")
|
|
1741
|
+
&& hasExactKeys(data.daemon, ["state"])
|
|
1742
|
+
&& data.daemon.state === "stopped";
|
|
1743
|
+
}
|
|
1744
|
+
if (action === "restart") {
|
|
1745
|
+
return data.outcome === "restarted"
|
|
1746
|
+
&& hasExactKeys(data.daemon, ["state", "mode"])
|
|
1747
|
+
&& data.daemon.state === "running"
|
|
1748
|
+
&& data.daemon.mode === "background";
|
|
1749
|
+
}
|
|
1750
|
+
if (!hasExactKeys(data.daemon, ["state", "mode"]) || data.daemon.state !== "running") {
|
|
1751
|
+
return false;
|
|
1752
|
+
}
|
|
1753
|
+
if (data.outcome === "started") return data.daemon.mode === "background";
|
|
1754
|
+
return data.outcome === "already_running"
|
|
1755
|
+
&& (data.daemon.mode === "foreground" || data.daemon.mode === "background");
|
|
1756
|
+
}
|
|
1757
|
+
|
|
1758
|
+
async function executeDaemonAction(action) {
|
|
1759
|
+
const confirmation = daemonActionConfirmations[action];
|
|
1760
|
+
if (confirmation && daemonActionConfirmationInput?.value !== confirmation) return;
|
|
1761
|
+
|
|
1762
|
+
// 先使正在进行的页面快照过期,避免动作后的新状态被旧请求回写。
|
|
1763
|
+
controlRefreshGeneration += 1;
|
|
1764
|
+
setDaemonActionBusy(true);
|
|
1765
|
+
try {
|
|
1766
|
+
const body = confirmation ? { action, confirmation } : { action };
|
|
1767
|
+
const response = await fetch("/api/v1/control/daemon/action", {
|
|
1768
|
+
cache: "no-store",
|
|
1769
|
+
method: "POST",
|
|
1770
|
+
headers: { "Content-Type": "application/json" },
|
|
1771
|
+
body: JSON.stringify(body),
|
|
1772
|
+
});
|
|
1773
|
+
const payload = await response.json();
|
|
1774
|
+
if (payload.code !== 0 || !hasExpectedDaemonActionResult(payload.data, action)) {
|
|
1775
|
+
throw new Error("Control daemon lifecycle action unavailable");
|
|
1776
|
+
}
|
|
1777
|
+
clearDaemonActionConfirmation();
|
|
1778
|
+
await refreshCurrentPage();
|
|
1779
|
+
} catch {
|
|
1780
|
+
// 不向本机页面暴露服务返回 message 或下游错误文本。
|
|
1781
|
+
refreshStatus.textContent = "生命周期操作失败,请查看安全日志后重试。";
|
|
1782
|
+
} finally {
|
|
1783
|
+
// 无论成功或失败,确认输入都只属于当前一次动作。
|
|
1784
|
+
clearDaemonActionConfirmation();
|
|
1785
|
+
setDaemonActionBusy(false);
|
|
1786
|
+
}
|
|
1787
|
+
}
|
|
1788
|
+
|
|
1789
|
+
refreshButton.addEventListener("click", () => {
|
|
1790
|
+
void refreshCurrentPage();
|
|
1791
|
+
});
|
|
1792
|
+
errorLogTab.addEventListener("click", () => {
|
|
1793
|
+
void selectLogKind("error");
|
|
1794
|
+
});
|
|
1795
|
+
debugLogTab.addEventListener("click", () => {
|
|
1796
|
+
void selectLogKind("debug");
|
|
1797
|
+
});
|
|
1798
|
+
loadMoreLogsButton.addEventListener("click", () => {
|
|
1799
|
+
void loadMoreLogs();
|
|
1800
|
+
});
|
|
1801
|
+
// 条件变更清空旧快照并由 generation 隔离在途分页;同条件刷新仍可保留快照。
|
|
1802
|
+
function changeTriggerFilters(status, type) {
|
|
1803
|
+
if (status === triggerStatus && type === triggerType) return;
|
|
1804
|
+
triggerStatus = status; triggerType = type;
|
|
1805
|
+
triggerSnapshot = null; triggerExhausted = false;
|
|
1806
|
+
refreshStatus.textContent = "";
|
|
1807
|
+
void loadTriggerEvents();
|
|
1808
|
+
}
|
|
1809
|
+
for (const status of triggerFilterNames) document.getElementById(`trigger-filter-${status}`)?.addEventListener("click", () => changeTriggerFilters(status, triggerType));
|
|
1810
|
+
triggerTypeSelect?.addEventListener("input", () => changeTriggerFilters(triggerStatus, triggerTypeSelect.value));
|
|
1811
|
+
turnAuditEventsButton?.addEventListener("click", () => selectTurnAuditView("events"));
|
|
1812
|
+
turnAuditSessionsButton?.addEventListener("click", () => selectTurnAuditView("sessions"));
|
|
1813
|
+
turnAuditLoadMoreSessionsButton?.addEventListener("click", () => {
|
|
1814
|
+
if (turnAuditView === "events") void loadTriggerEvents(true);
|
|
1815
|
+
else void loadMoreTurnAuditSessions();
|
|
1816
|
+
});
|
|
1817
|
+
clearFollowerTracesButton?.addEventListener("click", () => {
|
|
1818
|
+
void clearFollowerTraces();
|
|
1819
|
+
});
|
|
1820
|
+
daemonStartButton?.addEventListener("click", () => {
|
|
1821
|
+
void executeDaemonAction("start");
|
|
1822
|
+
});
|
|
1823
|
+
daemonStopButton?.addEventListener("click", () => {
|
|
1824
|
+
selectDaemonAction("stop");
|
|
1825
|
+
});
|
|
1826
|
+
daemonRestartButton?.addEventListener("click", () => {
|
|
1827
|
+
selectDaemonAction("restart");
|
|
1828
|
+
});
|
|
1829
|
+
daemonActionConfirmationInput?.addEventListener("input", () => {
|
|
1830
|
+
updateDaemonActionSubmitState();
|
|
1831
|
+
});
|
|
1832
|
+
daemonActionSubmitButton?.addEventListener("click", () => {
|
|
1833
|
+
if (selectedDaemonAction) void executeDaemonAction(selectedDaemonAction);
|
|
1834
|
+
});
|
|
1835
|
+
for (const button of daemonMetricsRangeButtons) {
|
|
1836
|
+
button.addEventListener("click", () => {
|
|
1837
|
+
const range = button.getAttribute("data-daemon-metrics-range")
|
|
1838
|
+
?? button.getAttribute("id")?.replace("daemon-metrics-range-", "");
|
|
1839
|
+
void refreshDaemonMetricsHistoryRange(range);
|
|
1840
|
+
});
|
|
1841
|
+
}
|
|
1842
|
+
|
|
1843
|
+
if (currentPage.key === "connections") {
|
|
1844
|
+
document.addEventListener("visibilitychange", () => {
|
|
1845
|
+
stopConnectionsRefresh();
|
|
1846
|
+
if (!document.hidden) void refreshConnections();
|
|
1847
|
+
});
|
|
1848
|
+
window.addEventListener("pagehide", () => { connectionsPageClosed = true; stopConnectionsRefresh(); });
|
|
1849
|
+
// 浏览器返回缓存页面时恢复可见性调度。
|
|
1850
|
+
window.addEventListener("pageshow", (event) => {
|
|
1851
|
+
if (!event.persisted) return;
|
|
1852
|
+
connectionsPageClosed = false;
|
|
1853
|
+
void refreshConnections();
|
|
1854
|
+
});
|
|
1855
|
+
}
|
|
1856
|
+
// 其他栏目仍只在首次加载及用户显式操作时读取。
|
|
1857
|
+
void refreshCurrentPage();
|
|
1858
|
+
})();
|