openclaw-observability 2026.4.1 → 2026.4.21

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.
Files changed (112) hide show
  1. package/README.md +4 -4
  2. package/dist/cloud/api-key-auth.d.ts.map +1 -1
  3. package/dist/cloud/api-key-auth.js +4 -9
  4. package/dist/cloud/api-key-auth.js.map +1 -1
  5. package/dist/cloud/types.d.ts +2 -3
  6. package/dist/cloud/types.d.ts.map +1 -1
  7. package/dist/config.d.ts +34 -5
  8. package/dist/config.d.ts.map +1 -1
  9. package/dist/config.js +35 -2
  10. package/dist/config.js.map +1 -1
  11. package/dist/gateway/register-observability-gateway.d.ts +6 -4
  12. package/dist/gateway/register-observability-gateway.d.ts.map +1 -1
  13. package/dist/gateway/register-observability-gateway.js +105 -2
  14. package/dist/gateway/register-observability-gateway.js.map +1 -1
  15. package/dist/hooks/messages.d.ts +4 -3
  16. package/dist/hooks/messages.d.ts.map +1 -1
  17. package/dist/hooks/messages.js +23 -1
  18. package/dist/hooks/messages.js.map +1 -1
  19. package/dist/hooks/session.d.ts +4 -3
  20. package/dist/hooks/session.d.ts.map +1 -1
  21. package/dist/hooks/session.js +9 -4
  22. package/dist/hooks/session.js.map +1 -1
  23. package/dist/hooks/subagent.d.ts +4 -3
  24. package/dist/hooks/subagent.d.ts.map +1 -1
  25. package/dist/hooks/subagent.js +4 -1
  26. package/dist/hooks/subagent.js.map +1 -1
  27. package/dist/hooks/tools.d.ts +3 -3
  28. package/dist/hooks/tools.d.ts.map +1 -1
  29. package/dist/hooks/tools.js +122 -4
  30. package/dist/hooks/tools.js.map +1 -1
  31. package/dist/index.d.ts +2 -2
  32. package/dist/index.d.ts.map +1 -1
  33. package/dist/index.js +472 -118
  34. package/dist/index.js.map +1 -1
  35. package/dist/llm/replay-runtime.d.ts +16 -0
  36. package/dist/llm/replay-runtime.d.ts.map +1 -0
  37. package/dist/llm/replay-runtime.js +596 -0
  38. package/dist/llm/replay-runtime.js.map +1 -0
  39. package/dist/llm/replay.d.ts +3 -0
  40. package/dist/llm/replay.d.ts.map +1 -1
  41. package/dist/llm/replay.js.map +1 -1
  42. package/dist/redaction.d.ts +1 -1
  43. package/dist/redaction.js +1 -1
  44. package/dist/runtime/index.d.ts +1 -1
  45. package/dist/runtime/index.d.ts.map +1 -1
  46. package/dist/runtime/index.js +3 -1
  47. package/dist/runtime/index.js.map +1 -1
  48. package/dist/runtime/session-context.d.ts +4 -3
  49. package/dist/runtime/session-context.d.ts.map +1 -1
  50. package/dist/runtime/session-context.js +37 -17
  51. package/dist/runtime/session-context.js.map +1 -1
  52. package/dist/security/chain-detector.d.ts +4 -4
  53. package/dist/security/chain-detector.d.ts.map +1 -1
  54. package/dist/security/chain-detector.js.map +1 -1
  55. package/dist/security/rules.d.ts +2 -2
  56. package/dist/security/rules.d.ts.map +1 -1
  57. package/dist/security/rules.js +9 -2
  58. package/dist/security/rules.js.map +1 -1
  59. package/dist/security/scanner.d.ts +8 -3
  60. package/dist/security/scanner.d.ts.map +1 -1
  61. package/dist/security/scanner.js +85 -7
  62. package/dist/security/scanner.js.map +1 -1
  63. package/dist/security/types.d.ts +3 -0
  64. package/dist/security/types.d.ts.map +1 -1
  65. package/dist/storage/buffer.d.ts +7 -7
  66. package/dist/storage/buffer.d.ts.map +1 -1
  67. package/dist/storage/buffer.js +2 -2
  68. package/dist/storage/buffer.js.map +1 -1
  69. package/dist/storage/cloud-export-writer.d.ts +23 -0
  70. package/dist/storage/cloud-export-writer.d.ts.map +1 -0
  71. package/dist/storage/cloud-export-writer.js +202 -0
  72. package/dist/storage/cloud-export-writer.js.map +1 -0
  73. package/dist/storage/duckdb-local-writer.d.ts +19 -3
  74. package/dist/storage/duckdb-local-writer.d.ts.map +1 -1
  75. package/dist/storage/duckdb-local-writer.js +261 -81
  76. package/dist/storage/duckdb-local-writer.js.map +1 -1
  77. package/dist/storage/duckdb-observability-forwarder.d.ts +16 -0
  78. package/dist/storage/duckdb-observability-forwarder.d.ts.map +1 -0
  79. package/dist/storage/duckdb-observability-forwarder.js +289 -0
  80. package/dist/storage/duckdb-observability-forwarder.js.map +1 -0
  81. package/dist/storage/mysql-writer.d.ts +35 -6
  82. package/dist/storage/mysql-writer.d.ts.map +1 -1
  83. package/dist/storage/mysql-writer.js +251 -32
  84. package/dist/storage/mysql-writer.js.map +1 -1
  85. package/dist/storage/schema.d.ts +2 -2
  86. package/dist/storage/schema.d.ts.map +1 -1
  87. package/dist/storage/schema.js +181 -53
  88. package/dist/storage/schema.js.map +1 -1
  89. package/dist/storage/structured-model.d.ts +11 -2
  90. package/dist/storage/structured-model.d.ts.map +1 -1
  91. package/dist/storage/structured-model.js +183 -5
  92. package/dist/storage/structured-model.js.map +1 -1
  93. package/dist/storage/writer.d.ts +14 -2
  94. package/dist/storage/writer.d.ts.map +1 -1
  95. package/dist/types.d.ts +28 -4
  96. package/dist/types.d.ts.map +1 -1
  97. package/dist/types.js +3 -1
  98. package/dist/types.js.map +1 -1
  99. package/dist/web/api.d.ts +80 -2
  100. package/dist/web/api.d.ts.map +1 -1
  101. package/dist/web/api.js +917 -113
  102. package/dist/web/api.js.map +1 -1
  103. package/dist/web/routes.d.ts +22 -2
  104. package/dist/web/routes.d.ts.map +1 -1
  105. package/dist/web/routes.js +264 -21
  106. package/dist/web/routes.js.map +1 -1
  107. package/dist/web/ui.d.ts +3 -1
  108. package/dist/web/ui.d.ts.map +1 -1
  109. package/dist/web/ui.js +2678 -633
  110. package/dist/web/ui.js.map +1 -1
  111. package/openclaw.plugin.json +145 -4
  112. package/package.json +1 -1
package/dist/web/ui.js CHANGED
@@ -11,7 +11,9 @@
11
11
  */
12
12
  Object.defineProperty(exports, "__esModule", { value: true });
13
13
  exports.getAppHtml = getAppHtml;
14
- function getAppHtml() {
14
+ function getAppHtml(options) {
15
+ const showTenantScope = options?.showTenantScope !== false;
16
+ const uiBootstrap = `window.__OPENCLAW_OBS_UI=${JSON.stringify({ showTenantScope })};`;
15
17
  return '<!DOCTYPE html>\n' +
16
18
  '<html lang="en">\n' +
17
19
  '<head>\n' +
@@ -26,6 +28,7 @@ function getAppHtml() {
26
28
  '<body>\n' +
27
29
  '<div id="app"></div>\n' +
28
30
  '<script>\n' +
31
+ uiBootstrap + '\n' +
29
32
  CLIENT_JS +
30
33
  '</script>\n' +
31
34
  '</body>\n' +
@@ -58,6 +61,7 @@ const CSS = `
58
61
  --shadow-sm:0 1px 2px rgba(0,0,0,.2);
59
62
  --shadow-md:0 4px 12px rgba(0,0,0,.25),0 0 0 1px rgba(255,255,255,.03);
60
63
  --radius-sm:6px;--radius-md:8px;--radius-lg:12px;--radius-full:9999px;
64
+ --topbar-control-h:32px;
61
65
  --duration-fast:.12s;--duration-normal:.2s;
62
66
  --ease-out:cubic-bezier(.16,1,.3,1);
63
67
  color-scheme:dark
@@ -89,25 +93,43 @@ a:hover{text-decoration:underline}
89
93
  ::-webkit-scrollbar-thumb{background:var(--border);border-radius:var(--radius-full)}
90
94
  ::-webkit-scrollbar-thumb:hover{background:var(--border-strong)}
91
95
 
92
- /* ---- Shell layout ---- */
93
- .shell{min-height:100vh;display:flex;flex-direction:column}
94
- .shell--trace{height:100vh;max-height:100vh;min-height:100vh;overflow:hidden}
95
-
96
- /* ---- Topbar (matches OpenClaw .topbar) ---- */
97
- .topbar{position:sticky;top:0;z-index:40;display:flex;justify-content:space-between;align-items:center;gap:16px;padding:0 20px;height:56px;border-bottom:1px solid var(--border);background:var(--bg)}
98
- .topbar-left{display:flex;align-items:center;gap:12px}
96
+ /* ---- App shell ---- */
97
+ .shell{min-height:100vh;background:var(--bg)}
98
+ .shell--trace{height:100vh;max-height:100vh;overflow:hidden}
99
+ .shell--trace .app-shell{height:100%;min-height:0}
100
+ .shell--trace .main-shell{height:100%;min-height:0}
101
+ .shell--trace .content--trace{height:100%}
102
+ .shell--trace .content--trace .content-inner{height:100%}
103
+ .app-shell{display:flex;min-height:100vh}
104
+ .side-nav{width:208px;flex:0 0 208px;border-right:1px solid var(--border);background:var(--bg-accent);display:flex;flex-direction:column;position:sticky;top:0;height:100vh}
105
+ .side-nav-head{padding:14px 14px 10px;border-bottom:1px solid var(--border)}
99
106
  .brand{display:flex;align-items:center;gap:10px}
100
107
  .brand-logo{width:28px;height:28px;flex-shrink:0;display:flex;align-items:center;justify-content:center}
101
108
  .brand-logo svg{width:24px;height:24px;stroke:var(--accent);fill:none;stroke-width:1.5;stroke-linecap:round;stroke-linejoin:round}
102
109
  .brand-text{display:flex;flex-direction:column;gap:1px}
103
110
  .brand-title{font-size:16px;font-weight:700;letter-spacing:-.03em;line-height:1.1;color:var(--text-strong)}
104
111
  .brand-sub{font-size:10px;font-weight:500;color:var(--muted);letter-spacing:.05em;text-transform:uppercase;line-height:1}
105
- .topbar-nav{display:flex;gap:4px;margin-left:24px}
106
- .topbar-nav a{color:var(--muted);text-decoration:none;padding:6px 14px;border-radius:var(--radius-md);font-size:13px;font-weight:500;transition:all var(--duration-fast) var(--ease-out)}
107
- .topbar-nav a:hover{color:var(--text);background:var(--bg-hover);text-decoration:none}
108
- .topbar-nav a.active{color:var(--accent-foreground);background:var(--accent)}
109
- .topbar-right{display:flex;align-items:center;gap:8px}
110
- .topbar-right .back-link{display:inline-flex;align-items:center;gap:6px;color:var(--muted);font-size:13px;font-weight:500;padding:6px 12px;border:1px solid var(--border);border-radius:var(--radius-md);background:var(--bg-elevated);text-decoration:none;transition:all var(--duration-fast) var(--ease-out)}
112
+ .side-nav-links{padding:10px;display:flex;flex-direction:column;gap:6px}
113
+ .side-nav-links a{color:var(--muted);text-decoration:none;padding:8px 10px;border-radius:var(--radius-md);font-size:13px;font-weight:600;transition:all var(--duration-fast) var(--ease-out)}
114
+ .side-nav-links a:hover{color:var(--text);background:var(--bg-hover);text-decoration:none}
115
+ .side-nav-links a.active{color:var(--accent-foreground);background:var(--accent)}
116
+ .main-shell{min-width:0;flex:1;display:flex;flex-direction:column;min-height:100vh}
117
+
118
+ /* ---- Topbar ---- */
119
+ .topbar{position:sticky;top:0;z-index:40;display:flex;justify-content:space-between;align-items:center;gap:16px;padding:10px 20px;height:56px;border-bottom:1px solid var(--border);background:var(--bg)}
120
+ .topbar-left{display:flex;align-items:center;gap:10px;min-width:0;height:var(--topbar-control-h);flex-wrap:nowrap}
121
+ .tenant-field{display:flex;align-items:center;gap:8px;min-width:230px;height:var(--topbar-control-h);align-self:center}
122
+ .tenant-label{display:inline-flex;align-items:center;font-size:12px;line-height:1;color:var(--muted);font-weight:600;letter-spacing:.02em;text-transform:none;white-space:nowrap}
123
+ .tenant-dropdown{position:relative;min-width:140px;max-width:260px;flex:1}
124
+ .tenant-btn{width:100%;justify-content:space-between}
125
+ /* Topbar dropdowns: keep "main dropdown" look while enforcing aligned height */
126
+ .topbar .time-btn{height:var(--topbar-control-h);padding:0 12px;border-radius:10px;font-size:14px}
127
+ .tenant-menu{min-width:100%;max-height:320px;overflow:auto}
128
+ .tenant-menu .time-menu-item{padding:8px 12px}
129
+ .topbar-right{display:flex;align-items:center;gap:8px;height:var(--topbar-control-h)}
130
+ .topbar-right .tenant-dropdown{min-width:auto;max-width:none;flex:0 0 auto}
131
+ .build-id{font-family:var(--mono);font-size:10px;color:var(--muted);padding:2px 6px;border:1px solid var(--border);border-radius:var(--radius-sm);background:var(--bg-elevated)}
132
+ .topbar-right .back-link{display:inline-flex;align-items:center;height:var(--topbar-control-h);gap:6px;color:var(--muted);font-size:13px;font-weight:500;padding:0 12px;border:1px solid var(--border);border-radius:var(--radius-md);background:var(--bg-elevated);text-decoration:none;transition:all var(--duration-fast) var(--ease-out)}
111
133
  .topbar-right .back-link:hover{color:var(--text);border-color:var(--border-strong);background:var(--bg-hover);text-decoration:none}
112
134
  .topbar-right .back-link svg{width:14px;height:14px;stroke:currentColor;fill:none;stroke-width:1.5;stroke-linecap:round;stroke-linejoin:round}
113
135
  .theme-btn{width:32px;height:32px;display:flex;align-items:center;justify-content:center;border:1px solid var(--border);border-radius:var(--radius-md);background:var(--bg-elevated);color:var(--muted);cursor:pointer;transition:all var(--duration-fast)}
@@ -130,10 +152,17 @@ a:hover{text-decoration:underline}
130
152
 
131
153
  /* ---- Filter bar ---- */
132
154
  .filter-bar{display:flex;gap:10px;align-items:center;margin-bottom:16px;flex-wrap:wrap}
133
- .filter-bar input[type=text]{background:var(--bg-elevated);border:1px solid var(--border);border-radius:var(--radius-md);padding:7px 12px;font-size:13px;color:var(--text);outline:none;transition:border-color var(--duration-fast);flex:1;min-width:200px}
155
+ .filter-bar input[type=text]{height:34px;background:var(--bg-elevated);border:1px solid var(--border);border-radius:var(--radius-md);padding:0 12px;font-size:13px;color:var(--text);outline:none;transition:border-color var(--duration-fast);flex:1;min-width:200px}
134
156
  .filter-bar input[type=text]:focus{border-color:var(--accent);box-shadow:var(--focus-ring)}
157
+ .filter-bar .filter-select{height:32px;border:1px solid var(--border);border-radius:var(--radius-md);background:var(--bg-elevated);color:var(--text);font-size:13px;padding:0 30px 0 12px;outline:none;cursor:pointer;transition:all var(--duration-fast);appearance:none;-webkit-appearance:none;min-width:170px}
158
+ .filter-bar .time-btn{height:34px;padding:0 12px;border-radius:var(--radius-md);font-size:13px;gap:6px}
159
+ .filter-bar .icon-refresh-btn{height:34px;width:34px;min-width:34px}
160
+ .filter-bar .filter-select:focus{border-color:var(--accent);box-shadow:var(--focus-ring)}
161
+ .filter-bar .filter-select:hover{border-color:var(--border-strong);background:var(--bg-hover)}
162
+ .agent-type-dropdown{position:relative}
163
+ .agent-type-menu{min-width:140px}
135
164
  .filter-bar .filter-sep{width:1px;height:24px;background:var(--border);flex-shrink:0}
136
- .filter-bar .btn-clear{background:none;border:1px solid var(--border);border-radius:var(--radius-md);padding:6px 12px;font-size:12px;color:var(--muted);cursor:pointer;transition:all var(--duration-fast)}
165
+ .filter-bar .btn-clear{height:34px;background:none;border:1px solid var(--border);border-radius:var(--radius-md);padding:0 12px;font-size:12px;color:var(--muted);cursor:pointer;transition:all var(--duration-fast)}
137
166
  .filter-bar .btn-clear:hover{color:var(--text);border-color:var(--border-strong);background:var(--bg-hover)}
138
167
  .sec-runtime{background:var(--card);border:1px solid var(--border);border-radius:var(--radius-lg);padding:16px 18px;margin-bottom:14px;box-shadow:inset 0 1px 0 var(--card-highlight)}
139
168
  .sec-runtime-head{display:flex;align-items:flex-end;justify-content:space-between;gap:12px;flex-wrap:wrap;margin-bottom:12px}
@@ -176,8 +205,9 @@ a:hover{text-decoration:underline}
176
205
  .sec-custom-form{display:grid;grid-template-columns:1fr 1.8fr auto;gap:8px;align-items:end}
177
206
  .sec-custom-field{display:flex;flex-direction:column;gap:4px;min-width:0}
178
207
  .sec-custom-field-label{font-size:10px;letter-spacing:.04em;text-transform:uppercase;color:var(--muted);font-weight:600}
179
- .sec-custom-form input,.sec-custom-form select{height:30px;border:1px solid var(--border);border-radius:var(--radius-sm);background:var(--bg);color:var(--text);padding:0 9px;font-size:12px;outline:none}
208
+ .sec-custom-form input,.sec-custom-form select{height:32px;border:1px solid var(--border);border-radius:var(--radius-md);background:var(--bg-elevated);color:var(--text);padding:0 12px;font-size:13px;outline:none;transition:all var(--duration-fast)}
180
209
  .sec-custom-form input:focus,.sec-custom-form select:focus{border-color:var(--accent);box-shadow:var(--focus-ring)}
210
+ .sec-custom-form select:hover{border-color:var(--border-strong);background:var(--bg-hover)}
181
211
  .sec-custom-form button{height:30px;border:1px solid var(--accent);border-radius:var(--radius-sm);background:var(--accent);color:var(--accent-foreground);padding:0 12px;font-size:12px;font-weight:700;cursor:pointer;letter-spacing:.01em}
182
212
  .sec-custom-form button:hover{filter:brightness(1.06)}
183
213
  .sec-custom-list{margin-top:10px;display:flex;flex-direction:column;gap:6px;max-height:220px;overflow:auto;padding-right:2px}
@@ -190,15 +220,19 @@ a:hover{text-decoration:underline}
190
220
  .sec-custom-item-del:hover{background:rgba(239,68,68,.2);border-color:rgba(239,68,68,.55)}
191
221
  /* Time range dropdown */
192
222
  .time-dropdown{position:relative}
193
- .time-btn{display:inline-flex;align-items:center;gap:6px;background:var(--bg-elevated);border:1px solid var(--border);border-radius:var(--radius-md);padding:7px 12px;font-size:13px;color:var(--text);cursor:pointer;white-space:nowrap;transition:all var(--duration-fast)}
223
+ .time-btn{display:inline-flex;align-items:center;gap:6px;background:var(--bg-elevated);border:1px solid var(--border);border-radius:10px;padding:0 14px;height:42px;font-size:14px;color:var(--text);cursor:pointer;white-space:nowrap;transition:all var(--duration-fast)}
194
224
  .time-btn:hover{border-color:var(--border-strong);background:var(--bg-hover)}
195
225
  .time-btn svg{width:14px;height:14px;stroke:currentColor;fill:none;stroke-width:1.5;flex-shrink:0}
196
- .time-menu{position:absolute;top:100%;left:0;margin-top:4px;background:var(--card);border:1px solid var(--border);border-radius:var(--radius-md);box-shadow:var(--shadow-md);z-index:50;min-width:160px;padding:4px 0;display:none}
226
+ .time-menu{position:absolute;top:100%;left:0;margin-top:6px;background:var(--card);border:1px solid var(--border);border-radius:10px;box-shadow:var(--shadow-md);z-index:50;min-width:170px;padding:6px 0;display:none}
197
227
  .time-menu.open{display:block}
198
- .time-menu-item{padding:8px 16px;font-size:13px;color:var(--text);cursor:pointer;display:flex;align-items:center;gap:8px;transition:background var(--duration-fast)}
228
+ .time-menu-item{padding:10px 16px;font-size:15px;color:var(--text);cursor:pointer;display:flex;align-items:center;gap:8px;transition:background var(--duration-fast)}
199
229
  .time-menu-item:hover{background:var(--bg-hover)}
200
230
  .time-menu-item.active{color:var(--accent);font-weight:600}
201
231
  .time-menu-item .check{width:14px;display:inline-block;text-align:center;font-size:12px}
232
+ .time-range-preview{display:inline-flex;align-items:center;height:42px;padding:0 14px;border:1px solid var(--border);border-radius:10px;background:var(--bg-elevated);font-family:var(--mono);font-size:13px;color:var(--muted);min-width:360px}
233
+ .time-input{height:42px;border:1px solid var(--border);border-radius:10px;background:var(--bg-elevated);color:var(--text);padding:0 12px;font-size:13px;outline:none;transition:all var(--duration-fast)}
234
+ .time-input:hover{border-color:var(--border-strong);background:var(--bg-hover)}
235
+ .time-input:focus{border-color:var(--accent);box-shadow:var(--focus-ring)}
202
236
 
203
237
  /* ---- Section title ---- */
204
238
  .section-title{font-size:15px;font-weight:600;color:var(--text-strong);margin-bottom:14px;display:flex;align-items:center;justify-content:space-between;letter-spacing:-.02em}
@@ -211,6 +245,8 @@ a:hover{text-decoration:underline}
211
245
  .session-top{display:flex;align-items:center;gap:12px;flex-wrap:wrap}
212
246
  .session-id{font-family:var(--mono);font-size:12px;color:var(--accent);font-weight:600;background:var(--accent-subtle);padding:2px 8px;border-radius:var(--radius-sm)}
213
247
  .session-model{font-size:12px;color:var(--muted);background:var(--secondary);padding:2px 8px;border-radius:var(--radius-sm);border:1px solid var(--border)}
248
+ .session-role{font-size:11px;font-weight:700;padding:2px 8px;border-radius:999px;border:1px solid transparent;letter-spacing:.02em}
249
+ .session-role.subagent{color:#1d4ed8;background:#eff6ff;border-color:#bfdbfe}
214
250
  .session-user{font-size:12px;color:var(--muted)}
215
251
  .session-channel{font-size:12px;color:var(--muted);background:var(--secondary);padding:2px 8px;border-radius:var(--radius-sm);border:1px solid var(--border)}
216
252
  .session-time{font-size:11px;color:var(--muted);margin-left:auto;font-family:var(--mono)}
@@ -220,6 +256,10 @@ a:hover{text-decoration:underline}
220
256
  .mini-trace{display:flex;gap:3px;flex-wrap:wrap;margin-left:auto}
221
257
  .mini-dot{width:18px;height:6px;border-radius:3px;opacity:.75}
222
258
  .mini-dot-more{font-size:10px;color:var(--muted);margin-left:2px;white-space:nowrap}
259
+ .skills-list{gap:10px}
260
+ .skills-card{cursor:pointer}
261
+ .skills-card.is-active{border-color:var(--accent);box-shadow:var(--shadow-md),inset 0 1px 0 var(--card-highlight)}
262
+ .skills-expand{display:none;margin-top:12px;padding-top:12px;border-top:1px dashed var(--border)}
223
263
 
224
264
  /* ---- Pagination (matches OpenClaw .btn) ---- */
225
265
  .pagination{display:flex;justify-content:center;align-items:center;gap:8px;margin-top:20px;padding:12px 0}
@@ -232,9 +272,17 @@ a:hover{text-decoration:underline}
232
272
  .content--trace{padding:0!important;overflow:hidden!important;flex:1!important;min-height:0!important;display:flex!important;flex-direction:column!important}
233
273
  .content--trace .content-inner{max-width:none;width:100%;margin:0;flex:1;min-height:0;display:flex;flex-direction:row;overflow:hidden}
234
274
 
235
- .trace-top{flex:1;min-width:0;min-height:120px;overflow-y:scroll;scrollbar-gutter:stable;position:relative}
275
+ .trace-top{flex:1;min-width:0;min-height:120px;overflow-y:auto;scrollbar-gutter:stable;position:relative}
236
276
  .trace-header{position:sticky;top:0;z-index:12;background:var(--card);padding:16px 24px;border-bottom:1px solid var(--border);box-shadow:inset 0 1px 0 var(--card-highlight),0 6px 18px rgba(0,0,0,.08)}
237
277
  .trace-header-top{display:flex;align-items:center;justify-content:space-between;gap:12px}
278
+ .trace-header-actions{display:flex;align-items:center;gap:8px;flex-wrap:wrap}
279
+ .trace-run-state{font-size:11px;font-weight:700;padding:3px 10px;border-radius:999px;text-transform:uppercase;letter-spacing:.04em;border:1px solid transparent}
280
+ .trace-run-state.completed{color:var(--ok);background:var(--ok-subtle);border-color:rgba(34,197,94,.3)}
281
+ .trace-run-state.failed{color:var(--danger);background:var(--danger-subtle);border-color:rgba(239,68,68,.3)}
282
+ .trace-run-state.running{color:var(--warn);background:var(--warn-subtle);border-color:rgba(245,158,11,.3)}
283
+ .trace-replay-btn{height:26px;display:inline-flex;align-items:center;justify-content:center;padding:0 10px;border:1px solid var(--border);border-radius:var(--radius-full);background:var(--bg-elevated);color:var(--text);font-size:11px;font-weight:600;cursor:pointer;transition:all var(--duration-fast)}
284
+ .trace-replay-btn:hover:not(:disabled){border-color:var(--accent);color:var(--accent);background:var(--accent-subtle)}
285
+ .trace-replay-btn:disabled{opacity:.45;cursor:not-allowed}
238
286
  .trace-back{display:inline-flex;align-items:center;gap:6px;color:var(--accent);text-decoration:none;font-size:13px;margin-bottom:10px;font-weight:500}
239
287
  .trace-header-top .trace-back{margin-bottom:0}
240
288
  .trace-back:hover{text-decoration:underline}
@@ -242,6 +290,17 @@ a:hover{text-decoration:underline}
242
290
  .trace-meta{display:flex;gap:16px;flex-wrap:wrap}
243
291
  .trace-meta-item{font-size:13px;color:var(--muted)}
244
292
  .trace-meta-item b{color:var(--text-strong);font-weight:600}
293
+ .trace-subagents{margin-top:10px;display:grid;grid-template-columns:repeat(auto-fit,minmax(280px,1fr));gap:8px}
294
+ .trace-subagent-item{border:1px solid var(--border);border-radius:var(--radius-md);background:var(--bg-elevated);padding:8px 10px}
295
+ .trace-subagent-row{display:flex;align-items:center;justify-content:space-between;gap:10px}
296
+ .trace-subagent-name{font-size:12px;font-weight:700;color:var(--text-strong)}
297
+ .trace-subagent-status{font-size:10px;font-weight:700;padding:2px 8px;border-radius:var(--radius-full);text-transform:uppercase;letter-spacing:.04em}
298
+ .trace-subagent-status.running{color:var(--warn);background:var(--warn-subtle);border:1px solid rgba(245,158,11,.35)}
299
+ .trace-subagent-status.done{color:var(--ok);background:var(--ok-subtle);border:1px solid rgba(34,197,94,.35)}
300
+ .trace-subagent-status.fail{color:var(--danger);background:var(--danger-subtle);border:1px solid rgba(239,68,68,.35)}
301
+ .trace-subagent-meta{margin-top:5px;font-size:11px;color:var(--muted);line-height:1.4}
302
+ .trace-subagent-link{font-family:var(--mono);font-size:11px}
303
+ .trace-subagent-link a{color:var(--accent)}
245
304
  .trace-search-bar{margin-top:14px;margin-bottom:0}
246
305
  .trace-search-bar input[type=text]{min-width:280px}
247
306
  .trace-search-stats{font-size:12px;color:var(--muted);white-space:nowrap}
@@ -284,6 +343,10 @@ a:hover{text-decoration:underline}
284
343
  .wf-group-title{font-size:12px;font-weight:700;letter-spacing:.04em;text-transform:uppercase;color:var(--text-strong)}
285
344
  .wf-group-hit{font-size:10px;padding:1px 6px;border-radius:var(--radius-full);background:var(--accent-subtle);color:var(--accent);font-weight:700;flex-shrink:0}
286
345
  .wf-group-snippet{font-size:12px;color:var(--muted);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;max-width:100%}
346
+ .wf-group-right{display:flex;align-items:center;gap:8px;flex-shrink:0}
347
+ .wf-group-replay-btn{height:24px;display:inline-flex;align-items:center;justify-content:center;padding:0 8px;border:1px solid var(--border);border-radius:var(--radius-full);background:var(--bg-elevated);color:var(--text);font-size:11px;font-weight:600;cursor:pointer;transition:all var(--duration-fast)}
348
+ .wf-group-replay-btn:hover:not(:disabled){border-color:var(--accent);color:var(--accent);background:var(--accent-subtle)}
349
+ .wf-group-replay-btn:disabled{opacity:.45;cursor:not-allowed}
287
350
  .wf-group-meta{font-size:11px;color:var(--muted);font-family:var(--mono);background:var(--secondary);border:1px solid var(--border);border-radius:var(--radius-full);padding:3px 10px;flex-shrink:0}
288
351
  .search-hit{background:rgba(245,158,11,.22);color:var(--text-strong);border-radius:3px;padding:0 2px}
289
352
 
@@ -297,7 +360,7 @@ body.resizing{cursor:col-resize!important;-webkit-user-select:none!important;use
297
360
  body.resizing *{cursor:col-resize!important}
298
361
 
299
362
  /* ---- Detail panel (right pane) ---- */
300
- .trace-bottom{flex:0 0 420px;min-width:320px;max-width:720px;overflow-y:scroll;scrollbar-gutter:stable;background:var(--bg-accent);border-left:1px solid var(--border)}
363
+ .trace-bottom{flex:0 0 420px;min-width:320px;max-width:720px;overflow-y:auto;scrollbar-gutter:stable;background:var(--bg-accent);border-left:1px solid var(--border)}
301
364
  .trace-bottom .detail-panel{background:var(--card);overflow:hidden;border:none;border-radius:0;margin:0;width:100%}
302
365
  .trace-bottom .detail-header{padding:10px 18px;background:var(--bg-accent);border-bottom:1px solid var(--border);display:flex;align-items:center;gap:10px;position:sticky;top:0;z-index:5}
303
366
  .detail-header .dot{width:10px;height:10px;border-radius:50%;flex-shrink:0}
@@ -397,8 +460,27 @@ body.resizing *{cursor:col-resize!important}
397
460
  .an-grid{display:grid;gap:20px;margin-bottom:24px}
398
461
  .an-grid-2{grid-template-columns:1fr 1fr}
399
462
  .an-grid-3{grid-template-columns:1fr 1fr 1fr}
400
- .metrics-kpi-grid{display:grid;gap:14px;grid-template-columns:repeat(auto-fit,minmax(min(100%,380px),1fr));margin-bottom:16px}
401
- @media(max-width:900px){.an-grid-2,.an-grid-3{grid-template-columns:1fr}}
463
+ .metrics-kpi-grid{display:grid;gap:14px;grid-template-columns:repeat(2,minmax(0,1fr));margin-bottom:16px}
464
+ .metrics-panel-grid{display:grid;gap:14px;grid-template-columns:repeat(2,minmax(0,1fr));margin-bottom:16px}
465
+ .metrics-series-grid{display:grid;gap:14px;grid-template-columns:repeat(2,minmax(0,1fr));margin-bottom:16px}
466
+ .metrics-panel{border:1px solid var(--border);border-radius:var(--radius-lg);background:var(--card);padding:14px 16px;box-shadow:inset 0 1px 0 var(--card-highlight)}
467
+ .metrics-panel-head{display:flex;align-items:center;justify-content:space-between;gap:10px;margin-bottom:8px}
468
+ .metrics-panel-title{font-size:16px;font-weight:700;color:var(--text-strong);letter-spacing:-.02em}
469
+ .metrics-panel-sub{font-size:11px;color:var(--muted)}
470
+ .metrics-panel-chart{position:relative;height:220px;border:1px solid var(--border);border-radius:var(--radius-md);padding:8px;background:var(--bg-elevated)}
471
+ .metrics-panel-svg{width:100%;height:100%;display:block}
472
+ .metrics-panel-legend{display:flex;gap:10px;flex-wrap:wrap;margin-top:8px}
473
+ .metrics-panel-legend-item{display:flex;align-items:center;gap:6px;font-size:12px;color:var(--muted)}
474
+ .metrics-panel-dot{width:9px;height:9px;border-radius:999px}
475
+ .metrics-panel-overlay{position:absolute;left:8px;right:8px;top:8px;bottom:8px;z-index:2;cursor:crosshair;touch-action:none;-webkit-user-select:none;user-select:none}
476
+ .metrics-panel-marker{position:absolute;top:8px;bottom:8px;width:1px;background:var(--accent);opacity:.75;display:none;z-index:3;pointer-events:none}
477
+ .metrics-panel-point{position:absolute;width:8px;height:8px;border-radius:999px;border:2px solid var(--bg-elevated);display:none;z-index:4;pointer-events:none;transform:translate(-50%,-50%)}
478
+ .metrics-panel-tip{position:absolute;z-index:5;top:12px;left:12px;max-width:320px;background:var(--card);border:1px solid var(--border);border-radius:var(--radius-sm);padding:6px 8px;font-size:11px;line-height:1.35;color:var(--text);box-shadow:var(--shadow-md);pointer-events:none;display:none}
479
+ @media(max-width:900px){
480
+ .an-grid-2,.an-grid-3{grid-template-columns:1fr}
481
+ .metrics-kpi-grid,.metrics-panel-grid,.metrics-series-grid{grid-template-columns:1fr}
482
+ .time-range-preview{min-width:0;flex:1;width:100%}
483
+ }
402
484
  .an-card{background:var(--card);border:1px solid var(--border);border-radius:var(--radius-lg);padding:20px 24px;box-shadow:inset 0 1px 0 var(--card-highlight)}
403
485
  .an-card h3{font-size:14px;font-weight:600;color:var(--text-strong);margin-bottom:16px;display:flex;align-items:center;gap:8px;letter-spacing:-.02em}
404
486
  .an-card h3 .icon{font-size:16px}
@@ -432,9 +514,45 @@ body.resizing *{cursor:col-resize!important}
432
514
  .metrics-toolbar{display:flex;flex-wrap:wrap;gap:12px;align-items:flex-end;margin-bottom:16px}
433
515
  .metrics-toolbar .toolbar-field{display:flex;flex-direction:column;gap:4px;min-width:0}
434
516
  .metrics-toolbar .toolbar-label{font-size:10px;font-weight:600;letter-spacing:.04em;text-transform:uppercase;color:var(--muted)}
435
- .metrics-select{height:34px;padding:0 10px;border:1px solid var(--border);border-radius:var(--radius-md);background:var(--bg-elevated);color:var(--text);font-size:13px;outline:none;transition:border-color var(--duration-fast)}
517
+ .metrics-select{height:42px;padding:0 30px 0 12px;border:1px solid var(--border);border-radius:10px;background:var(--bg-elevated);color:var(--text);font-size:13px;outline:none;transition:all var(--duration-fast);appearance:none;-webkit-appearance:none}
518
+ .metrics-select:hover{border-color:var(--border-strong);background:var(--bg-hover)}
436
519
  .metrics-select:focus{border-color:var(--accent);box-shadow:var(--focus-ring)}
437
520
  .metrics-select--grow{min-width:200px;flex:1;max-width:560px}
521
+ .metrics-toolbar .icon-refresh-btn{width:42px;height:42px;border-radius:10px}
522
+ .btn-apply{width:42px;height:42px;display:inline-flex;align-items:center;justify-content:center;border:1px solid var(--border);border-radius:10px;background:var(--bg-elevated);color:var(--text);cursor:pointer;transition:all var(--duration-fast);padding:0;flex-shrink:0}
523
+ .btn-apply:hover{border-color:var(--border-strong);background:var(--bg-hover)}
524
+ .btn-apply svg{width:16px;height:16px;stroke:currentColor;fill:none;stroke-width:2;stroke-linecap:round;stroke-linejoin:round}
525
+ .replay-toolbar{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:12px;margin-bottom:14px}
526
+ .replay-toolbar .toolbar-field{display:flex;flex-direction:column;gap:6px}
527
+ .replay-toolbar .toolbar-label{font-size:10px;font-weight:600;letter-spacing:.04em;text-transform:uppercase;color:var(--muted)}
528
+ .replay-input{height:42px;border:1px solid var(--border);border-radius:10px;background:var(--bg-elevated);color:var(--text);padding:0 12px;font-size:14px;outline:none;transition:all var(--duration-fast)}
529
+ .replay-input:hover{border-color:var(--border-strong);background:var(--bg-hover)}
530
+ .replay-input:focus{border-color:var(--accent);box-shadow:var(--focus-ring)}
531
+ .replay-area{min-height:112px;border:1px solid var(--border);border-radius:10px;background:var(--bg-elevated);color:var(--text);padding:10px 12px;font-size:14px;line-height:1.55;resize:vertical;outline:none;transition:all var(--duration-fast)}
532
+ .replay-area:hover{border-color:var(--border-strong);background:var(--bg-hover)}
533
+ .replay-area:focus{border-color:var(--accent);box-shadow:var(--focus-ring)}
534
+ .replay-actions{display:flex;align-items:center;gap:10px;margin-bottom:12px}
535
+ .replay-run{height:42px;display:inline-flex;align-items:center;justify-content:center;padding:0 16px;border:1px solid var(--border);border-radius:10px;background:var(--bg-elevated);color:var(--text);font-size:14px;font-weight:600;cursor:pointer;transition:all var(--duration-fast)}
536
+ .replay-run:hover{border-color:var(--border-strong);background:var(--bg-hover)}
537
+ .replay-run:disabled{opacity:.5;cursor:not-allowed}
538
+ .replay-meta{font-size:12px;color:var(--muted)}
539
+ .replay-result{border:1px solid var(--border);border-radius:10px;background:var(--bg-elevated);padding:12px;min-height:140px;white-space:pre-wrap;line-height:1.6;font-size:14px}
540
+ .replay-compare{display:grid;grid-template-columns:1fr 1fr;gap:12px}
541
+ .replay-compare .replay-result{min-height:180px}
542
+ .replay-caption{font-size:12px;color:var(--muted);margin:6px 0}
543
+ .replay-kv-list{display:flex;flex-direction:column;gap:10px;margin-top:8px}
544
+ .replay-kv-item{border:1px solid var(--border);border-radius:10px;background:var(--bg-elevated);padding:10px 12px}
545
+ .replay-kv-key{font-family:var(--mono);font-size:12px;font-weight:700;color:var(--info);margin-bottom:8px;word-break:break-all}
546
+ .replay-kv-primitive{width:100%;border:1px solid var(--border);border-radius:8px;background:var(--card);color:var(--text);padding:8px 10px;font-size:13px;line-height:1.5;outline:none}
547
+ .replay-kv-primitive:focus{border-color:var(--accent);box-shadow:var(--focus-ring)}
548
+ .replay-kv-json{width:100%;min-height:120px;border:1px solid var(--border);border-radius:8px;background:var(--card);color:var(--text);padding:8px 10px;font-size:12px;line-height:1.5;outline:none;resize:vertical;font-family:var(--mono)}
549
+ .replay-kv-json:focus{border-color:var(--accent);box-shadow:var(--focus-ring)}
550
+ .replay-kv-summary{cursor:pointer;font-size:12px;color:var(--muted);user-select:none}
551
+ .replay-kv-summary:hover{color:var(--text)}
552
+ .replay-raw-details{margin-top:8px}
553
+ .replay-raw-details summary{cursor:pointer;font-size:12px;color:var(--muted);user-select:none}
554
+ .replay-raw-details summary:hover{color:var(--text)}
555
+ @media(max-width:900px){.replay-compare{grid-template-columns:1fr}}
438
556
  .metrics-callout{border:1px solid var(--border);border-radius:var(--radius-lg);padding:18px 20px;background:var(--card);margin-bottom:16px;box-shadow:inset 0 1px 0 var(--card-highlight)}
439
557
  .metrics-callout-title{font-size:15px;font-weight:700;color:var(--text-strong);letter-spacing:-.02em}
440
558
  .metrics-callout-body{font-size:13px;color:var(--text);margin-top:10px;line-height:1.55}
@@ -501,6 +619,15 @@ body.resizing *{cursor:col-resize!important}
501
619
  /* ---- Responsive ---- */
502
620
  @media(max-width:900px){.stat-grid{grid-template-columns:repeat(3,1fr)}}
503
621
  @media(max-width:900px){
622
+ .app-shell{display:block}
623
+ .side-nav{width:100%;height:auto;position:static;border-right:none;border-bottom:1px solid var(--border)}
624
+ .side-nav-links{flex-direction:row;flex-wrap:wrap}
625
+ .main-shell{min-height:0}
626
+ .topbar{height:auto;min-height:56px;padding:10px 12px;flex-wrap:wrap}
627
+ .topbar-left{width:100%;height:auto;flex-wrap:wrap;align-items:center}
628
+ .tenant-field{min-width:130px;flex:1;max-width:none}
629
+ .tenant-dropdown{min-width:100px;max-width:none;flex:1}
630
+ .topbar-right{height:auto}
504
631
  .content--trace .content-inner{flex-direction:column}
505
632
  .trace-resize{display:none}
506
633
  .trace-bottom{flex:0 0 320px;min-width:0;max-width:none;border-left:none;border-top:1px solid var(--border)}
@@ -509,7 +636,7 @@ body.resizing *{cursor:col-resize!important}
509
636
  }
510
637
  @media(max-width:560px){
511
638
  .stat-grid{grid-template-columns:repeat(2,1fr)}
512
- .topbar{padding:0 12px;height:48px}
639
+ .topbar{padding:8px 10px}
513
640
  .content{padding:12px}
514
641
  .waterfall{margin:10px}
515
642
  .wf-header,.wf-row{grid-template-columns:200px 70px 1fr}
@@ -520,6 +647,7 @@ body.resizing *{cursor:col-resize!important}
520
647
  /* ================================================================== */
521
648
  const CLIENT_JS = `
522
649
  "use strict";
650
+ var UI_BUILD_ID = "obs-ui-20260403-1435";
523
651
 
524
652
  // Use localhost secure-context semantics for control-ui websocket auth.
525
653
  // 127.0.0.1 is not always treated the same as localhost by gateway policy.
@@ -534,7 +662,7 @@ try {
534
662
  /* ---------- constants ---------- */
535
663
  var API = window.location.pathname.replace(/\\/+$/, '') + '/api';
536
664
  var TYPE_COLORS = {
537
- message:'#8b5cf6', assistant_stream:'#7c3aed', thinking:'#0f766e', tool_call:'#f59e0b', tool_update:'#fb923c', tool_persist:'#f97316',
665
+ message:'#8b5cf6', replay:'#0891b2', assistant_stream:'#7c3aed', thinking:'#0f766e', tool_call:'#f59e0b', tool_update:'#fb923c', tool_persist:'#f97316',
538
666
  prompt_build:'#3b82f6', model_resolve:'#06b6d4', agent_end:'#10b981',
539
667
  session_start:'#22c55e', session_end:'#ef4444', session_snapshot:'#16a34a', compaction:'#14b8a6',
540
668
  reset:'#f43f5e', user_message:'#0ea5e9', assistant_msg:'#a855f7',
@@ -542,7 +670,7 @@ var TYPE_COLORS = {
542
670
  gateway_start:'#94a3b8', gateway_stop:'#475569'
543
671
  };
544
672
  var TYPE_LABELS = {
545
- message:'LLM Call', assistant_stream:'Assistant Stream', thinking:'Thinking', tool_call:'Tool Call', tool_update:'Tool Update', tool_persist:'Tool Persist',
673
+ message:'LLM Call', replay:'Replay', assistant_stream:'Assistant Stream', thinking:'Thinking', tool_call:'Tool Call', tool_update:'Tool Update', tool_persist:'Tool Persist',
546
674
  prompt_build:'Prompt Build', model_resolve:'Model Resolve', agent_end:'Agent End',
547
675
  session_start:'Session Start', session_end:'Session End', session_snapshot:'Session Snapshot', compaction:'Compaction',
548
676
  reset:'Reset', user_message:'User Message', assistant_msg:'Assistant Msg',
@@ -554,28 +682,293 @@ var app = document.getElementById('app');
554
682
  var currentPage = 1;
555
683
  var filterSearch = '';
556
684
  var filterTimeRange = '24h'; // default to 24h for better performance
685
+ var filterTimeFrom = '';
686
+ var filterTimeTo = '';
687
+ var filterAgentType = 'main'; // default hide subagent in list
688
+ var tenantOptions = [];
689
+ var selectedScopeId = 'local';
690
+ var defaultScopeId = 'local';
691
+ var tenantsLoaded = false;
692
+ var tenantsLoading = null;
693
+ var SHOW_TENANT_SCOPE = !(window.__OPENCLAW_OBS_UI && window.__OPENCLAW_OBS_UI.showTenantScope === false);
694
+ var SCOPE_STORAGE_KEY = 'openclaw.observability.scopeId';
557
695
  var metricsRangeMinutes = 60;
558
696
  var metricsStepSec = 30;
697
+ var metricsTimeFrom = '';
698
+ var metricsTimeTo = '';
559
699
  var metricsTableSort = 'name';
560
700
  var metricsSnapshotFilter = '';
561
701
  var metricsSelected = '';
562
702
  /** Set true to show the Metrics nav item and #/metrics page (hidden by default). */
563
- var SHOW_METRICS_PAGE = false;
703
+ var SHOW_METRICS_PAGE = true;
564
704
  var sparklineSeq = 0;
565
705
  var sparklineStore = {};
706
+ var metricsPanelSeq = 0;
707
+ var metricsPanelStore = {};
708
+ var METRICS_WINDOW_OPTIONS = [30, 60, 180, 360, 720, 1440];
709
+ var replayProvidersCache = [];
710
+ var replayState = {
711
+ providerId: '',
712
+ model: '',
713
+ systemPrompt: '',
714
+ userPrompt: '',
715
+ replayInput: null,
716
+ replayInputText: '',
717
+ replayInputError: '',
718
+ temperature: '',
719
+ maxTokens: '',
720
+ running: false,
721
+ result: null,
722
+ error: '',
723
+ sourceSessionId: '',
724
+ sourceObservationId: '',
725
+ sourceActionName: '',
726
+ sourceCreatedAt: '',
727
+ baselineText: '',
728
+ baselinePromptTokens: null,
729
+ baselineCompletionTokens: null,
730
+ baselineLatencyMs: null,
731
+ };
732
+
733
+ var LANG_STORAGE_KEY = 'openclaw.observability.lang';
734
+ function normalizeLang(v) {
735
+ var s = String(v || '').trim().toLowerCase();
736
+ return s === 'zh' ? 'zh' : 'en';
737
+ }
738
+ function loadLang() {
739
+ try {
740
+ return normalizeLang(window.localStorage.getItem(LANG_STORAGE_KEY) || '');
741
+ } catch (_) {
742
+ return 'en';
743
+ }
744
+ }
745
+ var currentLang = loadLang();
746
+ var I18N = {
747
+ en: {
748
+ nav_dashboard: 'Dashboard',
749
+ nav_trace: 'Trace',
750
+ nav_skills: 'Skills',
751
+ nav_replay: 'Replay',
752
+ nav_metrics: 'Metrics',
753
+ nav_security: 'Security',
754
+ top_control_panel: 'Control Panel',
755
+ lang: 'Language',
756
+ tenant_scope: 'Scope ID',
757
+ select_scope: 'Select scope',
758
+ loading: 'Loading...',
759
+ loading_dashboard: 'Loading dashboard...',
760
+ loading_skills: 'Loading skills...',
761
+ loading_metrics: 'Loading metrics...',
762
+ loading_replay: 'Loading replay workspace...',
763
+ failed_load: 'Failed to load: ',
764
+ sessions: 'Sessions',
765
+ actions: 'Actions',
766
+ tokens: 'Tokens',
767
+ avg_latency: 'Avg Latency',
768
+ success: 'Success',
769
+ search_session: 'Search session ID, key, user, model...',
770
+ search: 'Search',
771
+ clear: 'Clear',
772
+ refresh_trace_list: 'Refresh trace list',
773
+ traces: 'Traces',
774
+ matched: 'matched',
775
+ total: 'total',
776
+ no_sessions: 'No sessions recorded yet',
777
+ main: 'Main',
778
+ subagent: 'Subagent',
779
+ system: 'System',
780
+ replay: 'Replay',
781
+ all: 'All',
782
+ filter_agent_type: 'Filter agent type',
783
+ start_time: 'Start time',
784
+ end_time: 'End time',
785
+ page: 'Page',
786
+ prev: 'Prev',
787
+ next: 'Next',
788
+ parent: 'Parent',
789
+ heuristic_subagent_session: 'Heuristic subagent session',
790
+ actions_unit: 'actions',
791
+ tokens_unit: 'tokens',
792
+ trace_detail_loading: 'Loading trace...',
793
+ back_to_traces: 'Back to Traces',
794
+ completed: 'Completed',
795
+ failed: 'Failed',
796
+ running: 'Running',
797
+ duration: 'Duration',
798
+ snapshots: 'Snapshots',
799
+ time: 'Time',
800
+ search_this_session: 'Search this session: action, input, output, model...',
801
+ refresh_current_trace: 'Refresh current trace',
802
+ prev: 'Prev',
803
+ next: 'Next',
804
+ search_within_session: 'Search within this session',
805
+ no_action_matched: 'No action matched',
806
+ try_hint: 'Try a tool name, model, prompt fragment, or error text.',
807
+ input: 'Input',
808
+ output: 'Output',
809
+ type: 'Type',
810
+ model: 'Model',
811
+ agent: 'Agent',
812
+ channel: 'Channel',
813
+ prompt_tokens: 'Prompt Tokens',
814
+ completion_tokens: 'Completion Tokens',
815
+ metrics_disabled: 'Disabled',
816
+ refresh_metrics: 'Refresh metrics',
817
+ apply_time_range: 'Apply time range',
818
+ start: 'Start',
819
+ end: 'End',
820
+ latest_metrics_snapshot: 'Latest Metrics Snapshot',
821
+ filter_by_metric_name: 'Filter by metric name…',
822
+ no_replay_result: 'No replay result yet.',
823
+ run_replay: 'Run Replay',
824
+ replay_running: 'Running...',
825
+ replay_title: 'Replay',
826
+ replay_subtitle: 'LLM sandbox',
827
+ replay_full_input: 'Full replay input (JSON)',
828
+ replay_full_input_hint: 'Edit full source input including historyMessages/messages/streamParams.',
829
+ replay_full_input_invalid: 'Replay input JSON is invalid',
830
+ skills_title: 'Skills',
831
+ skill_name: 'Skill',
832
+ call_count: 'Calls',
833
+ run_count: 'Runs',
834
+ session_count: 'Sessions',
835
+ last_seen: 'Last Seen',
836
+ no_skills: 'No skills detected in selected scope/time range',
837
+ skill_detail: 'Skill Detail',
838
+ skill_description: 'Description',
839
+ skill_location: 'Location',
840
+ skill_content: 'SKILL.md Content',
841
+ recent_calls: 'Recent Calls',
842
+ status: 'Status',
843
+ success_short: 'OK',
844
+ fail_short: 'Failed',
845
+ no_recent_calls: 'No recent skill calls',
846
+ close: 'Close',
847
+ },
848
+ zh: {
849
+ nav_dashboard: '总览',
850
+ nav_trace: '链路',
851
+ nav_skills: '技能',
852
+ nav_replay: '回放',
853
+ nav_metrics: '指标',
854
+ nav_security: '安全',
855
+ top_control_panel: '控制台',
856
+ lang: '语言',
857
+ tenant_scope: 'Scope ID',
858
+ select_scope: '选择 Scope',
859
+ loading: '加载中...',
860
+ loading_dashboard: '加载看板中...',
861
+ loading_skills: '加载技能中...',
862
+ loading_metrics: '加载指标中...',
863
+ loading_replay: '加载回放工作区中...',
864
+ failed_load: '加载失败:',
865
+ sessions: '会话数',
866
+ actions: '动作数',
867
+ tokens: 'Token',
868
+ avg_latency: '平均耗时',
869
+ success: '成功率',
870
+ search_session: '搜索会话 ID、Key、用户、模型...',
871
+ search: '搜索',
872
+ clear: '清空',
873
+ refresh_trace_list: '刷新链路列表',
874
+ traces: '链路',
875
+ matched: '命中',
876
+ total: '总计',
877
+ no_sessions: '暂无会话数据',
878
+ main: '主代理',
879
+ subagent: '子代理',
880
+ system: '系统',
881
+ replay: '回放',
882
+ all: '全部',
883
+ filter_agent_type: '筛选代理类型',
884
+ start_time: '开始时间',
885
+ end_time: '结束时间',
886
+ page: '第',
887
+ prev: '上一页',
888
+ next: '下一页',
889
+ parent: '父会话',
890
+ heuristic_subagent_session: '启发式识别子代理会话',
891
+ actions_unit: '次动作',
892
+ tokens_unit: 'Token',
893
+ trace_detail_loading: '加载链路详情中...',
894
+ back_to_traces: '返回链路列表',
895
+ completed: '已完成',
896
+ failed: '失败',
897
+ running: '运行中',
898
+ duration: '耗时',
899
+ snapshots: '快照',
900
+ time: '时间',
901
+ search_this_session: '搜索本会话:动作、输入、输出、模型...',
902
+ refresh_current_trace: '刷新当前链路',
903
+ search_within_session: '在当前会话中搜索',
904
+ no_action_matched: '未匹配到动作',
905
+ try_hint: '可尝试工具名、模型名、Prompt 片段或错误文本。',
906
+ input: '输入',
907
+ output: '输出',
908
+ type: '类型',
909
+ model: '模型',
910
+ agent: '代理',
911
+ channel: '通道',
912
+ prompt_tokens: '提示词 Token',
913
+ completion_tokens: '输出 Token',
914
+ metrics_disabled: '已禁用',
915
+ refresh_metrics: '刷新指标',
916
+ apply_time_range: '应用时间范围',
917
+ start: '开始',
918
+ end: '结束',
919
+ latest_metrics_snapshot: '最新指标快照',
920
+ filter_by_metric_name: '按指标名筛选…',
921
+ no_replay_result: '暂无回放结果。',
922
+ run_replay: '执行回放',
923
+ replay_running: '执行中...',
924
+ replay_title: '回放',
925
+ replay_subtitle: 'LLM 沙箱',
926
+ replay_full_input: '完整回放参数(JSON)',
927
+ replay_full_input_hint: '可编辑完整源输入,包括 historyMessages/messages/streamParams。',
928
+ replay_full_input_invalid: '回放参数 JSON 不合法',
929
+ skills_title: '技能列表',
930
+ skill_name: '技能',
931
+ call_count: '调用次数',
932
+ run_count: '运行数',
933
+ session_count: '会话数',
934
+ last_seen: '最近调用',
935
+ no_skills: '当前 Scope/时间范围未识别到技能',
936
+ skill_detail: '技能详情',
937
+ skill_description: '描述',
938
+ skill_location: '路径',
939
+ skill_content: 'SKILL.md 内容',
940
+ recent_calls: '最近调用',
941
+ status: '状态',
942
+ success_short: '成功',
943
+ fail_short: '失败',
944
+ no_recent_calls: '暂无技能调用记录',
945
+ close: '关闭',
946
+ }
947
+ };
948
+ function t(key) {
949
+ var pack = I18N[currentLang] || I18N.en;
950
+ return String((pack && pack[key]) || I18N.en[key] || key);
951
+ }
952
+ function tr(enText, zhText) {
953
+ return currentLang === 'zh' ? zhText : enText;
954
+ }
566
955
 
567
956
  var TIME_PRESETS = [
568
- { key:'30m', label:'30 min', ms: 30*60*1000 },
569
- { key:'1h', label:'1 hour', ms: 60*60*1000 },
570
- { key:'6h', label:'6 hours', ms: 6*60*60*1000 },
571
- { key:'24h', label:'24 hours', ms: 24*60*60*1000 },
572
- { key:'3d', label:'3 days', ms: 3*24*60*60*1000 },
573
- { key:'7d', label:'7 days', ms: 7*24*60*60*1000 },
574
- { key:'14d', label:'14 days', ms: 14*24*60*60*1000 },
575
- { key:'1mo', label:'1 month', ms: 30*24*60*60*1000 },
576
- { key:'3mo', label:'3 months', ms: 90*24*60*60*1000 },
577
- { key:'', label:'All time', ms: 0 }
957
+ { key:'30m', labelEn:'30 min', labelZh:'近 30 分钟', ms: 30*60*1000 },
958
+ { key:'1h', labelEn:'1 hour', labelZh:'近 1 小时', ms: 60*60*1000 },
959
+ { key:'6h', labelEn:'6 hours', labelZh:'近 6 小时', ms: 6*60*60*1000 },
960
+ { key:'24h', labelEn:'24 hours', labelZh:'近 24 小时', ms: 24*60*60*1000 },
961
+ { key:'3d', labelEn:'3 days', labelZh:'近 3 天', ms: 3*24*60*60*1000 },
962
+ { key:'7d', labelEn:'7 days', labelZh:'近 7 天', ms: 7*24*60*60*1000 },
963
+ { key:'14d', labelEn:'14 days', labelZh:'近 14 天', ms: 14*24*60*60*1000 },
964
+ { key:'1mo', labelEn:'1 month', labelZh:'近 1 个月', ms: 30*24*60*60*1000 },
965
+ { key:'3mo', labelEn:'3 months', labelZh:'近 3 个月', ms: 90*24*60*60*1000 },
966
+ { key:'', labelEn:'All time', labelZh:'全部时间', ms: 0 }
578
967
  ];
968
+ function timePresetLabel(preset) {
969
+ if (!preset) return currentLang === 'zh' ? '全部时间' : 'All time';
970
+ return currentLang === 'zh' ? String(preset.labelZh || preset.labelEn || '') : String(preset.labelEn || preset.labelZh || '');
971
+ }
579
972
 
580
973
  function getTimeFromISO() {
581
974
  if (!filterTimeRange) return '';
@@ -584,10 +977,169 @@ function getTimeFromISO() {
584
977
  return new Date(Date.now() - preset.ms).toISOString();
585
978
  }
586
979
 
980
+ function getTimeToISO() {
981
+ if (!filterTimeRange) return '';
982
+ var preset = TIME_PRESETS.find(function(p){ return p.key === filterTimeRange; });
983
+ if (!preset || !preset.ms) return '';
984
+ return new Date().toISOString();
985
+ }
986
+
987
+ function hasCustomTimeRange() {
988
+ return !!(filterTimeFrom || filterTimeTo);
989
+ }
990
+
991
+ function getTraceTimeFilter() {
992
+ if (hasCustomTimeRange()) {
993
+ return {
994
+ timeFrom: filterTimeFrom || '',
995
+ timeTo: filterTimeTo || '',
996
+ };
997
+ }
998
+ return {
999
+ timeFrom: getTimeFromISO(),
1000
+ timeTo: getTimeToISO(),
1001
+ };
1002
+ }
1003
+
1004
+ function toLocalDateTimeInputValue(isoText) {
1005
+ if (!isoText) return '';
1006
+ var d = new Date(isoText);
1007
+ if (!Number.isFinite(d.getTime())) return '';
1008
+ var y = d.getFullYear();
1009
+ var m = String(d.getMonth() + 1).padStart(2, '0');
1010
+ var day = String(d.getDate()).padStart(2, '0');
1011
+ var hh = String(d.getHours()).padStart(2, '0');
1012
+ var mm = String(d.getMinutes()).padStart(2, '0');
1013
+ return y + '-' + m + '-' + day + 'T' + hh + ':' + mm;
1014
+ }
1015
+
1016
+ function toISOFromDateTimeInputValue(localVal) {
1017
+ if (!localVal) return '';
1018
+ var d = new Date(localVal);
1019
+ if (!Number.isFinite(d.getTime())) return '';
1020
+ return d.toISOString();
1021
+ }
1022
+
587
1023
  function getTimeLabel() {
588
- if (!filterTimeRange) return 'All time';
1024
+ if (hasCustomTimeRange()) return currentLang === 'zh' ? '自定义' : 'Custom';
1025
+ if (!filterTimeRange) return currentLang === 'zh' ? '全部时间' : 'All time';
589
1026
  var preset = TIME_PRESETS.find(function(p){ return p.key === filterTimeRange; });
590
- return preset ? preset.label : 'All time';
1027
+ return preset ? timePresetLabel(preset) : (currentLang === 'zh' ? '全部时间' : 'All time');
1028
+ }
1029
+
1030
+ function getTimeRangePreview() {
1031
+ if (hasCustomTimeRange()) {
1032
+ var s = filterTimeFrom ? new Date(filterTimeFrom).toLocaleString() : '-';
1033
+ var e = filterTimeTo ? new Date(filterTimeTo).toLocaleString() : '-';
1034
+ return s + ' - ' + e;
1035
+ }
1036
+ if (!filterTimeRange) return 'All time';
1037
+ var from = getTimeFromISO();
1038
+ if (!from) return 'All time';
1039
+ var to = getTimeToISO();
1040
+ var start = new Date(from).toLocaleString();
1041
+ var end = to ? new Date(to).toLocaleString() : new Date().toLocaleString();
1042
+ return start + ' - ' + end;
1043
+ }
1044
+
1045
+ function getMetricsWindowLabel() {
1046
+ return String(metricsRangeMinutes) + ' min';
1047
+ }
1048
+
1049
+ function tenantLabel(t) {
1050
+ return (t && t.scope_id) ? String(t.scope_id) : 'local';
1051
+ }
1052
+
1053
+ function uniqueStrings(rows, getter) {
1054
+ var set = {};
1055
+ var out = [];
1056
+ (rows || []).forEach(function(r) {
1057
+ var v = String(getter(r) || '').trim();
1058
+ if (!v) return;
1059
+ if (set[v]) return;
1060
+ set[v] = true;
1061
+ out.push(v);
1062
+ });
1063
+ return out;
1064
+ }
1065
+
1066
+ function loadSavedScopeId() {
1067
+ try {
1068
+ var v = String(window.localStorage.getItem(SCOPE_STORAGE_KEY) || '').trim();
1069
+ return v || '';
1070
+ } catch (_) {
1071
+ return '';
1072
+ }
1073
+ }
1074
+
1075
+ function saveScopeId(v) {
1076
+ try {
1077
+ window.localStorage.setItem(SCOPE_STORAGE_KEY, String(v || 'local'));
1078
+ } catch (_) {}
1079
+ }
1080
+
1081
+ function getTenantScopes() {
1082
+ var all = uniqueStrings(tenantOptions, function(t) { return t && t.scope_id; });
1083
+ return all.length ? all : ['local'];
1084
+ }
1085
+
1086
+ function syncTenantSelection() {
1087
+ var scopes = getTenantScopes();
1088
+ if (scopes.indexOf(selectedScopeId) < 0) selectedScopeId = scopes[0];
1089
+ }
1090
+
1091
+ async function ensureTenantsLoaded() {
1092
+ if (!SHOW_TENANT_SCOPE) {
1093
+ tenantOptions = [];
1094
+ selectedScopeId = 'local';
1095
+ defaultScopeId = 'local';
1096
+ tenantsLoaded = true;
1097
+ tenantsLoading = null;
1098
+ return;
1099
+ }
1100
+ if (tenantsLoaded) return;
1101
+ if (tenantsLoading) {
1102
+ await tenantsLoading;
1103
+ return;
1104
+ }
1105
+ tenantsLoading = (async function() {
1106
+ var rerenderNeeded = false;
1107
+ try {
1108
+ var res = await fetchApi('/tenants', { skipScope: true });
1109
+ var rows = (res && Array.isArray(res.tenants)) ? res.tenants.slice() : [];
1110
+ tenantOptions = rows;
1111
+ var scopes = rows.map(function(t) { return String((t && t.scope_id) || '').trim(); }).filter(Boolean);
1112
+ var current = String(selectedScopeId || '').trim();
1113
+ var saved = loadSavedScopeId();
1114
+ if (current && scopes.indexOf(current) >= 0) selectedScopeId = current;
1115
+ else if (saved && scopes.indexOf(saved) >= 0) selectedScopeId = saved;
1116
+ else {
1117
+ var preferred = rows.find(function(t) {
1118
+ return String((t && t.scope_id) || '').toLowerCase() === 'local';
1119
+ });
1120
+ if (preferred && preferred.scope_id) selectedScopeId = String(preferred.scope_id);
1121
+ else if (rows.length > 0 && rows[0].scope_id) selectedScopeId = String(rows[0].scope_id);
1122
+ else selectedScopeId = 'local';
1123
+ }
1124
+ syncTenantSelection();
1125
+ saveScopeId(selectedScopeId);
1126
+ defaultScopeId = selectedScopeId;
1127
+ rerenderNeeded = true;
1128
+ } catch (_) {
1129
+ tenantOptions = [];
1130
+ selectedScopeId = 'local';
1131
+ defaultScopeId = 'local';
1132
+ } finally {
1133
+ tenantsLoaded = true;
1134
+ tenantsLoading = null;
1135
+ if (rerenderNeeded) {
1136
+ setTimeout(function() {
1137
+ try { router(); } catch (_) {}
1138
+ }, 0);
1139
+ }
1140
+ }
1141
+ })();
1142
+ await tenantsLoading;
591
1143
  }
592
1144
 
593
1145
  /* ---------- theme ---------- */
@@ -609,6 +1161,9 @@ function toggleTheme() {
609
1161
  applyTheme(cur === 'dark' ? 'light' : 'dark');
610
1162
  }
611
1163
  applyTheme(getTheme());
1164
+ if (SHOW_TENANT_SCOPE) {
1165
+ ensureTenantsLoaded().catch(function() {});
1166
+ }
612
1167
 
613
1168
  /* ---------- helpers ---------- */
614
1169
  function esc(s) { if (!s) return ''; var d = document.createElement('div'); d.textContent = s; return d.innerHTML; }
@@ -788,6 +1343,7 @@ function getToolCallId(action) {
788
1343
  }
789
1344
  function getActionRunId(action) {
790
1345
  if (!action) return '';
1346
+ if (typeof action.run_id === 'string' && action.run_id) return action.run_id;
791
1347
  var input = parseJson(action.input_params);
792
1348
  if (input && typeof input === 'object' && typeof input.runId === 'string' && input.runId) {
793
1349
  return input.runId;
@@ -798,6 +1354,174 @@ function getActionRunId(action) {
798
1354
  }
799
1355
  return '';
800
1356
  }
1357
+ function getActionParentRunId(action) {
1358
+ if (!action) return '';
1359
+ if (typeof action.parent_run_id === 'string' && action.parent_run_id) return action.parent_run_id;
1360
+ var input = parseJson(action.input_params);
1361
+ if (input && typeof input === 'object' && typeof input.parentRunId === 'string' && input.parentRunId) {
1362
+ return input.parentRunId;
1363
+ }
1364
+ var output = parseJson(action.output_result);
1365
+ if (output && typeof output === 'object' && typeof output.parentRunId === 'string' && output.parentRunId) {
1366
+ return output.parentRunId;
1367
+ }
1368
+ return '';
1369
+ }
1370
+ function parseJsonObjectFromText(text) {
1371
+ if (typeof text !== 'string') return null;
1372
+ var t = text.trim();
1373
+ if (!t) return null;
1374
+ if (t.charAt(0) !== '{' && t.charAt(0) !== '[') return null;
1375
+ try {
1376
+ var parsed = JSON.parse(t);
1377
+ return parsed && typeof parsed === 'object' ? parsed : null;
1378
+ } catch (_) {
1379
+ return null;
1380
+ }
1381
+ }
1382
+ function readSessionsSpawnDetails(outputObj) {
1383
+ if (!outputObj || typeof outputObj !== 'object') return null;
1384
+ if (outputObj.details && typeof outputObj.details === 'object') {
1385
+ return outputObj.details;
1386
+ }
1387
+ if (Array.isArray(outputObj.content)) {
1388
+ for (var i = 0; i < outputObj.content.length; i++) {
1389
+ var item = outputObj.content[i];
1390
+ if (!item || typeof item !== 'object') continue;
1391
+ if (item.type !== 'text' || typeof item.text !== 'string') continue;
1392
+ var parsed = parseJsonObjectFromText(item.text);
1393
+ if (parsed && typeof parsed === 'object' && parsed.childSessionKey) {
1394
+ return parsed;
1395
+ }
1396
+ }
1397
+ }
1398
+ return null;
1399
+ }
1400
+ function shortSessionKey(v) {
1401
+ var s = String(v || '');
1402
+ if (!s) return '';
1403
+ if (s.length <= 40) return s;
1404
+ return s.slice(0, 22) + '...' + s.slice(-14);
1405
+ }
1406
+ function getSessionsSpawnInfo(action) {
1407
+ if (!action || action.action_name !== 'tool_call:sessions_spawn') return null;
1408
+ var input = parseJson(action.input_params);
1409
+ if (!input || typeof input !== 'object') input = {};
1410
+ var output = parseJson(action.output_result);
1411
+ if (!output || typeof output !== 'object') output = {};
1412
+ var details = readSessionsSpawnDetails(output) || {};
1413
+ var childSessionKey = String(details.childSessionKey || '').trim();
1414
+ var childRunId = String(details.runId || '').trim();
1415
+ var label = String(input.label || '').trim();
1416
+ var task = String(input.task || '').trim();
1417
+ var runtime = String(input.runtime || '').trim();
1418
+ if (!childSessionKey && !label && !task) return null;
1419
+ return {
1420
+ childSessionKey: childSessionKey,
1421
+ childRunId: childRunId,
1422
+ label: label,
1423
+ task: task,
1424
+ runtime: runtime,
1425
+ };
1426
+ }
1427
+ function formatTraceActionName(action) {
1428
+ if (!action) return '';
1429
+ var runId = getActionRunId(action) || '';
1430
+ var announceInfo = parseAnnounceRunInfo(runId);
1431
+ if (announceInfo && action.action_type === 'message' && String(action.action_name || '').indexOf('llm_call:') === 0) {
1432
+ return 'subagent_callback -> ' + shortSessionKey(announceInfo.childSessionKey);
1433
+ }
1434
+ if (action.action_name === 'tool_start:sessions_spawn') {
1435
+ return 'subagent_spawn_start';
1436
+ }
1437
+ if (action.action_name === 'tool_call:sessions_spawn') {
1438
+ var info = getSessionsSpawnInfo(action);
1439
+ if (info && info.childSessionKey) {
1440
+ return 'subagent_spawn_request -> ' + shortSessionKey(info.childSessionKey);
1441
+ }
1442
+ return 'subagent_spawn_request';
1443
+ }
1444
+ if (action.action_name === 'tool_start:sessions_yield') {
1445
+ return 'subagent_wait_start';
1446
+ }
1447
+ if (action.action_name === 'tool_call:sessions_yield') {
1448
+ return 'subagent_wait';
1449
+ }
1450
+ return String(action.action_name || '');
1451
+ }
1452
+ function buildTraceSubagentSummary(actions) {
1453
+ var list = [];
1454
+ var byKey = {};
1455
+ ensureArray(actions).forEach(function(a) {
1456
+ var info = getSessionsSpawnInfo(a);
1457
+ if (!info || !info.childSessionKey) return;
1458
+ var key = info.childSessionKey;
1459
+ var entry = byKey[key];
1460
+ if (!entry) {
1461
+ entry = {
1462
+ childSessionKey: key,
1463
+ childSessionId: '',
1464
+ childRunId: info.childRunId || '',
1465
+ label: info.label || '',
1466
+ task: info.task || '',
1467
+ runtime: info.runtime || '',
1468
+ spawnedAt: a.created_at || '',
1469
+ status: 'running',
1470
+ completedAt: '',
1471
+ };
1472
+ byKey[key] = entry;
1473
+ list.push(entry);
1474
+ return;
1475
+ }
1476
+ if (!entry.childRunId && info.childRunId) entry.childRunId = info.childRunId;
1477
+ if (!entry.label && info.label) entry.label = info.label;
1478
+ if (!entry.task && info.task) entry.task = info.task;
1479
+ if (!entry.runtime && info.runtime) entry.runtime = info.runtime;
1480
+ });
1481
+ if (!list.length) return [];
1482
+
1483
+ var completionSessionIdRegex = /session_id:\\s*([0-9a-fA-F-]{36})/;
1484
+ ensureArray(actions).forEach(function(a) {
1485
+ var hay = stringifySearchValue(a.input_params) + '\\n' + stringifySearchValue(a.output_result);
1486
+ var lower = hay.toLowerCase();
1487
+ list.forEach(function(entry) {
1488
+ if (!entry || !entry.childSessionKey) return;
1489
+ if (lower.indexOf(entry.childSessionKey.toLowerCase()) < 0) return;
1490
+ var m = hay.match(completionSessionIdRegex);
1491
+ if (m && m[1] && !entry.childSessionId) entry.childSessionId = m[1];
1492
+ if (lower.indexOf('completed successfully') >= 0 || lower.indexOf('status: completed') >= 0) {
1493
+ entry.status = 'done';
1494
+ if (!entry.completedAt) entry.completedAt = a.created_at || '';
1495
+ } else if (lower.indexOf('failed') >= 0 || lower.indexOf('status: error') >= 0) {
1496
+ entry.status = 'fail';
1497
+ if (!entry.completedAt) entry.completedAt = a.created_at || '';
1498
+ }
1499
+ });
1500
+ });
1501
+
1502
+ return list;
1503
+ }
1504
+
1505
+ function parseAnnounceChildRunId(runId) {
1506
+ var s = String(runId || '');
1507
+ if (s.indexOf('announce:v1:') !== 0) return '';
1508
+ var last = s.lastIndexOf(':');
1509
+ if (last < 0 || last >= s.length - 1) return '';
1510
+ var tail = s.slice(last + 1);
1511
+ return /^[0-9a-fA-F-]{16,}$/.test(tail) ? tail : '';
1512
+ }
1513
+ function parseAnnounceRunInfo(runId) {
1514
+ var s = String(runId || '');
1515
+ if (s.indexOf('announce:v1:') !== 0) return null;
1516
+ var childRunId = parseAnnounceChildRunId(s);
1517
+ if (!childRunId) return null;
1518
+ var prefix = 'announce:v1:';
1519
+ var cut = s.length - childRunId.length - 1;
1520
+ if (cut <= prefix.length) return null;
1521
+ var childSessionKey = s.slice(prefix.length, cut);
1522
+ if (!childSessionKey) return null;
1523
+ return { childSessionKey: childSessionKey, childRunId: childRunId };
1524
+ }
801
1525
  function normalizeTraceSearch(s) {
802
1526
  return (s || '').trim().toLowerCase();
803
1527
  }
@@ -1204,13 +1928,20 @@ function mapGetApiToRpc(path) {
1204
1928
  var u = new URL(path, 'http://local');
1205
1929
  var p = u.pathname;
1206
1930
  var q = u.searchParams;
1931
+ if (p === '/tenants') {
1932
+ return {
1933
+ method: 'observability.tenants',
1934
+ params: {}
1935
+ };
1936
+ }
1207
1937
 
1208
1938
  if (p === '/stats') {
1209
1939
  return {
1210
1940
  method: 'observability.stats',
1211
1941
  params: {
1212
1942
  timeFrom: q.get('timeFrom') || undefined,
1213
- timeTo: q.get('timeTo') || undefined
1943
+ timeTo: q.get('timeTo') || undefined,
1944
+ agentType: q.get('agentType') || undefined
1214
1945
  }
1215
1946
  };
1216
1947
  }
@@ -1224,6 +1955,7 @@ function mapGetApiToRpc(path) {
1224
1955
  model: q.get('model') || undefined,
1225
1956
  sessionId: q.get('sessionId') || undefined,
1226
1957
  search: q.get('search') || undefined,
1958
+ agentType: q.get('agentType') || undefined,
1227
1959
  timeFrom: q.get('timeFrom') || undefined,
1228
1960
  timeTo: q.get('timeTo') || undefined
1229
1961
  }
@@ -1312,6 +2044,27 @@ function mapGetApiToRpc(path) {
1312
2044
  }
1313
2045
  };
1314
2046
  }
2047
+ if (p === '/skills/overview') {
2048
+ return {
2049
+ method: 'observability.skills.overview',
2050
+ params: {
2051
+ limit: toIntOrUndef(q.get('limit')),
2052
+ timeFrom: q.get('timeFrom') || undefined,
2053
+ timeTo: q.get('timeTo') || undefined
2054
+ }
2055
+ };
2056
+ }
2057
+ if (p === '/skills/detail') {
2058
+ return {
2059
+ method: 'observability.skills.detail',
2060
+ params: {
2061
+ skill: q.get('skill') || '',
2062
+ limit: toIntOrUndef(q.get('limit')),
2063
+ timeFrom: q.get('timeFrom') || undefined,
2064
+ timeTo: q.get('timeTo') || undefined
2065
+ }
2066
+ };
2067
+ }
1315
2068
  if (p === '/security/config') return { method: 'observability.security.config.get', params: {} };
1316
2069
  if (p === '/metrics/overview') {
1317
2070
  return {
@@ -1337,6 +2090,12 @@ function mapGetApiToRpc(path) {
1337
2090
  }
1338
2091
  };
1339
2092
  }
2093
+ if (p === '/llm/providers') {
2094
+ return {
2095
+ method: 'observability.llm.providers',
2096
+ params: {}
2097
+ };
2098
+ }
1340
2099
  return null;
1341
2100
  }
1342
2101
 
@@ -1349,6 +2108,12 @@ function mapPostApiToRpc(path, payload) {
1349
2108
  params: payload || {}
1350
2109
  };
1351
2110
  }
2111
+ if (p === '/llm/replay') {
2112
+ return {
2113
+ method: 'observability.llm.replay',
2114
+ params: payload || {}
2115
+ };
2116
+ }
1352
2117
  var m = p.match(/^\\/alerts\\/([^/]+)\\/status$/);
1353
2118
  if (m) {
1354
2119
  return {
@@ -1414,14 +2179,27 @@ async function postApiHttp(path, payload) {
1414
2179
  }
1415
2180
 
1416
2181
  async function fetchApi(path) {
2182
+ var options = arguments.length > 1 ? arguments[1] : null;
1417
2183
  var mapped = mapGetApiToRpc(path);
1418
2184
  if (!mapped) throw new Error('Unsupported API path (WS-only): ' + path);
2185
+ if (!(options && options.skipScope) && mapped.method !== 'observability.tenants') {
2186
+ if (!mapped.params) mapped.params = {};
2187
+ if (!mapped.params.scopeId) mapped.params.scopeId = selectedScopeId || 'local';
2188
+ }
1419
2189
  try {
1420
2190
  return await getGatewayWsClient().request(mapped.method, mapped.params || {});
1421
2191
  } catch (err) {
1422
2192
  var wsDetail = (err && err.message) ? String(err.message) : 'unknown';
1423
2193
  try {
1424
- return await fetchApiHttp(path);
2194
+ var fallbackPath = path;
2195
+ if (!(options && options.skipScope) && mapped.method !== 'observability.tenants') {
2196
+ var scope = String((mapped.params && mapped.params.scopeId) || selectedScopeId || 'local');
2197
+ var sep = fallbackPath.indexOf('?') >= 0 ? '&' : '?';
2198
+ if (fallbackPath.indexOf('scopeId=') < 0 && fallbackPath.indexOf('scope_id=') < 0) {
2199
+ fallbackPath += sep + 'scopeId=' + encodeURIComponent(scope);
2200
+ }
2201
+ }
2202
+ return await fetchApiHttp(fallbackPath);
1425
2203
  } catch (httpErr) {
1426
2204
  var httpDetail = (httpErr && httpErr.message) ? String(httpErr.message) : 'unknown';
1427
2205
  throw new Error('WS request failed for ' + mapped.method + ': ' + wsDetail + '; HTTP fallback failed: ' + httpDetail);
@@ -1432,6 +2210,10 @@ async function fetchApi(path) {
1432
2210
  async function postApi(path, payload) {
1433
2211
  var mapped = mapPostApiToRpc(path, payload);
1434
2212
  if (!mapped) throw new Error('Unsupported API path (WS-only): ' + path);
2213
+ if (mapped.method !== 'observability.security.config.update') {
2214
+ if (!mapped.params) mapped.params = {};
2215
+ if (!mapped.params.scopeId) mapped.params.scopeId = selectedScopeId || 'local';
2216
+ }
1435
2217
  try {
1436
2218
  return await getGatewayWsClient().request(mapped.method, mapped.params || {});
1437
2219
  } catch (err) {
@@ -1451,6 +2233,7 @@ var ICON_SUN = '<svg viewBox="0 0 24 24"><circle cx="12" cy="12" r="5"/><path d=
1451
2233
  var ICON_MOON = '<svg viewBox="0 0 24 24"><path d="M21 12.79A9 9 0 1111.21 3 7 7 0 0021 12.79z"/></svg>';
1452
2234
  var ICON_ACTIVITY = '<svg viewBox="0 0 24 24"><polyline points="22 12 18 12 15 21 9 3 6 12 2 12"/></svg>';
1453
2235
  var ICON_REFRESH = '<svg viewBox="0 0 24 24" aria-hidden="true"><path d="M20 4v6h-6"/><path d="M4 20v-6h6"/><path d="M19.2 14A8 8 0 0 1 6.1 18.3L4 14"/><path d="M4.8 10A8 8 0 0 1 17.9 5.7L20 10"/></svg>';
2236
+ var ICON_CHECK = '<svg viewBox="0 0 24 24" aria-hidden="true"><polyline points="20 6 9 17 4 12"/></svg>';
1454
2237
 
1455
2238
  /* ---------- router ---------- */
1456
2239
  function parseHashParams(hash) {
@@ -1475,6 +2258,8 @@ function router() {
1475
2258
  renderTraceList();
1476
2259
  } else if (hash.indexOf('#/security') === 0) {
1477
2260
  renderSecurity();
2261
+ } else if (hash.indexOf('#/skills') === 0) {
2262
+ renderSkills();
1478
2263
  } else if (hash.indexOf('#/metrics') === 0) {
1479
2264
  if (!SHOW_METRICS_PAGE) {
1480
2265
  try {
@@ -1486,6 +2271,8 @@ function router() {
1486
2271
  return;
1487
2272
  }
1488
2273
  renderMetrics();
2274
+ } else if (hash.indexOf('#/replay') === 0) {
2275
+ renderReplay();
1489
2276
  } else {
1490
2277
  renderDashboard();
1491
2278
  }
@@ -1498,29 +2285,57 @@ function renderLayout(active, content) {
1498
2285
  var isTrace = active === 'trace';
1499
2286
  var shellCls = isTrace ? 'shell shell--trace' : 'shell';
1500
2287
  var contentCls = isTrace ? 'content content--trace' : 'content';
2288
+ if (SHOW_TENANT_SCOPE) syncTenantSelection();
2289
+ var scopes = SHOW_TENANT_SCOPE ? getTenantScopes() : [];
2290
+ var projectSelect = '<div class="tenant-field"><div class="tenant-label">' + esc(t('tenant_scope')) + '</div><div class="tenant-dropdown" id="tenant-project-dd"><button class="time-btn tenant-btn" onclick="toggleTenantMenu(event,\\'project\\')" aria-label="' + esc(t('select_scope')) + '">' + esc(selectedScopeId) + ' <svg viewBox="0 0 24 24" style="width:12px;height:12px"><polyline points="6 9 12 15 18 9"/></svg></button><div class="time-menu tenant-menu" id="tenant-project-menu">';
2291
+ scopes.forEach(function(scopeId) {
2292
+ projectSelect += '<div class="time-menu-item' + (scopeId === selectedScopeId ? ' active' : '') + '" onclick="selectTenantScopeMenu(\\'' + esc(scopeId) + '\\')"><span class="check">' + (scopeId === selectedScopeId ? '✓' : '') + '</span>' + esc(scopeId) + '</div>';
2293
+ });
2294
+ projectSelect += '</div></div></div>';
2295
+ var languageLabel = currentLang === 'zh' ? '中文' : 'English';
2296
+ var langSelect =
2297
+ '<div class="tenant-dropdown" id="lang-dd">' +
2298
+ '<button class="time-btn tenant-btn" onclick="toggleLanguageMenu(event)" aria-label="' + esc(t('lang')) + '">' + esc(languageLabel) + ' <svg viewBox="0 0 24 24" style="width:12px;height:12px"><polyline points="6 9 12 15 18 9"/></svg></button>' +
2299
+ '<div class="time-menu tenant-menu" id="lang-menu">' +
2300
+ '<div class="time-menu-item' + (currentLang === 'zh' ? ' active' : '') + '" onclick="selectLanguageMenu(\\'zh\\')"><span class="check">' + (currentLang === 'zh' ? '✓' : '') + '</span>中文</div>' +
2301
+ '<div class="time-menu-item' + (currentLang === 'en' ? ' active' : '') + '" onclick="selectLanguageMenu(\\'en\\')"><span class="check">' + (currentLang === 'en' ? '✓' : '') + '</span>English</div>' +
2302
+ '</div>' +
2303
+ '</div>';
1501
2304
  return '<div class="' + shellCls + '">' +
1502
- '<div class="topbar">' +
1503
- '<div class="topbar-left">' +
1504
- '<div class="brand">' +
1505
- '<div class="brand-logo">' + ICON_ACTIVITY + '</div>' +
1506
- '<div class="brand-text">' +
1507
- '<div class="brand-title">OpenClaw</div>' +
1508
- '<div class="brand-sub">Observability</div>' +
2305
+ '<div class="app-shell">' +
2306
+ '<aside class="side-nav">' +
2307
+ '<div class="side-nav-head">' +
2308
+ '<div class="brand">' +
2309
+ '<div class="brand-logo">' + ICON_ACTIVITY + '</div>' +
2310
+ '<div class="brand-text">' +
2311
+ '<div class="brand-title">OpenClaw</div>' +
2312
+ '<div class="brand-sub">Observability</div>' +
2313
+ '</div>' +
1509
2314
  '</div>' +
1510
2315
  '</div>' +
1511
- '<div class="topbar-nav">' +
1512
- '<a href="#/" class="' + (active==='dashboard'?'active':'') + '">Dashboard</a>' +
1513
- '<a href="#/traces" class="' + (active==='traces'?'active':'') + '">Trace</a>' +
1514
- (SHOW_METRICS_PAGE ? ('<a href="#/metrics" class="' + (active==='metrics'?'active':'') + '">Metrics</a>') : '') +
1515
- '<a href="#/security" class="' + (active==='security'?'active':'') + '">Security' + (window.__alertCount > 0 ? '<span class="nav-badge">' + window.__alertCount + '</span>' : '') + '</a>' +
1516
- '</div>' +
2316
+ '<nav class="side-nav-links">' +
2317
+ '<a href="#/" class="' + (active==='dashboard'?'active':'') + '">' + esc(t('nav_dashboard')) + '</a>' +
2318
+ '<a href="#/traces" class="' + ((active==='traces' || active==='trace')?'active':'') + '">' + esc(t('nav_trace')) + '</a>' +
2319
+ '<a href="#/skills" class="' + (active==='skills'?'active':'') + '">' + esc(t('nav_skills')) + '</a>' +
2320
+ '<a href="#/replay" class="' + (active==='replay'?'active':'') + '">' + esc(t('nav_replay')) + '</a>' +
2321
+ (SHOW_METRICS_PAGE ? ('<a href="#/metrics" class="' + (active==='metrics'?'active':'') + '">' + esc(t('nav_metrics')) + '</a>') : '') +
2322
+ '<a href="#/security" class="' + (active==='security'?'active':'') + '">' + esc(t('nav_security')) + (window.__alertCount > 0 ? '<span class="nav-badge">' + window.__alertCount + '</span>' : '') + '</a>' +
2323
+ '</nav>' +
2324
+ '</aside>' +
2325
+ '<main class="main-shell">' +
2326
+ '<div class="topbar">' +
2327
+ '<div class="topbar-left">' +
2328
+ (SHOW_TENANT_SCOPE ? projectSelect : '') +
1517
2329
  '</div>' +
1518
2330
  '<div class="topbar-right">' +
2331
+ langSelect +
1519
2332
  '<button class="theme-btn" onclick="toggleTheme();router()" title="Toggle theme">' + themeIcon + '</button>' +
1520
- '<a href="/" class="back-link">' + ICON_BACK + ' Control Panel</a>' +
2333
+ '<a href="/" class="back-link">' + ICON_BACK + ' ' + esc(t('top_control_panel')) + '</a>' +
1521
2334
  '</div>' +
1522
2335
  '</div>' +
1523
2336
  '<div class="' + contentCls + '"><div class="content-inner">' + content + '</div></div>' +
2337
+ '</main>' +
2338
+ '</div>' +
1524
2339
  '</div>';
1525
2340
  }
1526
2341
 
@@ -1529,17 +2344,24 @@ function renderLayout(active, content) {
1529
2344
  /* ================================================================ */
1530
2345
 
1531
2346
  async function renderTraceList() {
1532
- app.innerHTML = renderLayout('traces', '<div class="loading">Loading...</div>');
2347
+ app.innerHTML = renderLayout('traces', '<div class="loading">' + esc(t('loading')) + '</div>');
1533
2348
 
1534
2349
  try {
2350
+ await ensureTenantsLoaded();
1535
2351
  // Build sessions request with filter params
1536
2352
  var sessQs = 'page=' + currentPage + '&limit=20';
1537
2353
  if (filterSearch) sessQs += '&search=' + encodeURIComponent(filterSearch);
1538
- var timeFromISO = getTimeFromISO();
1539
- if (timeFromISO) sessQs += '&timeFrom=' + encodeURIComponent(timeFromISO);
2354
+ var tf = getTraceTimeFilter();
2355
+ if (tf.timeFrom) sessQs += '&timeFrom=' + encodeURIComponent(tf.timeFrom);
2356
+ if (tf.timeTo) sessQs += '&timeTo=' + encodeURIComponent(tf.timeTo);
2357
+ if (filterAgentType) sessQs += '&agentType=' + encodeURIComponent(filterAgentType);
2358
+ var statsQs = '';
2359
+ if (tf.timeFrom) statsQs += (statsQs ? '&' : '') + 'timeFrom=' + encodeURIComponent(tf.timeFrom);
2360
+ if (tf.timeTo) statsQs += (statsQs ? '&' : '') + 'timeTo=' + encodeURIComponent(tf.timeTo);
2361
+ if (filterAgentType) statsQs += (statsQs ? '&' : '') + 'agentType=' + encodeURIComponent(filterAgentType);
1540
2362
 
1541
2363
  var data = await Promise.all([
1542
- fetchApi('/stats'),
2364
+ fetchApi('/stats' + (statsQs ? ('?' + statsQs) : '')),
1543
2365
  fetchApi('/sessions?' + sessQs)
1544
2366
  ]);
1545
2367
  var stats = data[0];
@@ -1549,22 +2371,36 @@ async function renderTraceList() {
1549
2371
 
1550
2372
  // Stats cards
1551
2373
  html += '<div class="stat-grid">';
1552
- html += statCard('Sessions', fmtNum(stats.totalSessions));
1553
- html += statCard('Actions', fmtNum(stats.totalActions));
1554
- html += statCard('Tokens', fmtNum(stats.totalTokens));
1555
- html += statCard('Avg Latency', fmtDur(stats.avgLatencyMs));
1556
- html += statCard('Success', stats.successRate + '%');
2374
+ html += statCard(t('sessions'), fmtNum(stats.totalSessions));
2375
+ html += statCard(t('actions'), fmtNum(stats.totalActions));
2376
+ html += statCard(t('tokens'), fmtNum(stats.totalTokens));
2377
+ html += statCard(t('avg_latency'), fmtDur(stats.avgLatencyMs));
2378
+ html += statCard(t('success'), stats.successRate + '%');
1557
2379
  html += '</div>';
1558
2380
 
1559
2381
  // Filter bar
1560
- var hasFilter = filterSearch || filterTimeRange;
2382
+ var hasFilter = filterSearch || filterTimeRange || (filterAgentType !== 'main');
1561
2383
  html += '<div class="filter-bar">';
1562
- html += '<input type="text" id="f-search" placeholder="Search session ID, key, user, model..." value="' + esc(filterSearch) + '" onkeydown="if(event.key===\\'Enter\\')applyFilter()">';
2384
+ html += '<input type="text" id="f-search" placeholder="' + esc(t('search_session')) + '" value="' + esc(filterSearch) + '" onkeydown="if(event.key===\\'Enter\\')applyFilter()">';
2385
+ var agentTypeLabel = filterAgentType === 'subagent'
2386
+ ? t('subagent')
2387
+ : (filterAgentType === 'replay'
2388
+ ? t('replay')
2389
+ : (filterAgentType === 'system'
2390
+ ? t('system')
2391
+ : (filterAgentType === 'all' ? t('all') : t('main'))));
2392
+ html += '<div class="agent-type-dropdown">';
2393
+ html += '<button class="time-btn" onclick="toggleAgentTypeMenu(event)" aria-label="' + esc(t('filter_agent_type')) + '">' + esc(agentTypeLabel) + ' <svg viewBox="0 0 24 24" style="width:12px;height:12px"><polyline points="6 9 12 15 18 9"/></svg></button>';
2394
+ html += '<div class="time-menu agent-type-menu" id="agent-type-menu">';
2395
+ html += '<div class="time-menu-item' + (filterAgentType === 'main' ? ' active' : '') + '" onclick="selectAgentTypeMenu(\\'main\\')"><span class="check">' + (filterAgentType === 'main' ? '✓' : '') + '</span>' + esc(t('main')) + '</div>';
2396
+ html += '<div class="time-menu-item' + (filterAgentType === 'subagent' ? ' active' : '') + '" onclick="selectAgentTypeMenu(\\'subagent\\')"><span class="check">' + (filterAgentType === 'subagent' ? '✓' : '') + '</span>' + esc(t('subagent')) + '</div>';
2397
+ html += '<div class="time-menu-item' + (filterAgentType === 'system' ? ' active' : '') + '" onclick="selectAgentTypeMenu(\\'system\\')"><span class="check">' + (filterAgentType === 'system' ? '✓' : '') + '</span>' + esc(t('system')) + '</div>';
2398
+ html += '<div class="time-menu-item' + (filterAgentType === 'replay' ? ' active' : '') + '" onclick="selectAgentTypeMenu(\\'replay\\')"><span class="check">' + (filterAgentType === 'replay' ? '✓' : '') + '</span>' + esc(t('replay')) + '</div>';
2399
+ html += '<div class="time-menu-item' + (filterAgentType === 'all' ? ' active' : '') + '" onclick="selectAgentTypeMenu(\\'all\\')"><span class="check">' + (filterAgentType === 'all' ? '✓' : '') + '</span>' + esc(t('all')) + '</div>';
2400
+ html += '</div></div>';
1563
2401
  html += '<span class="filter-sep"></span>';
1564
- // Time range dropdown
1565
2402
  html += '<div class="time-dropdown" id="time-dropdown">';
1566
2403
  html += '<button class="time-btn" onclick="toggleTimeMenu(event)">';
1567
- html += '<svg viewBox="0 0 24 24"><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg>';
1568
2404
  html += esc(getTimeLabel());
1569
2405
  html += ' <svg viewBox="0 0 24 24" style="width:12px;height:12px"><polyline points="6 9 12 15 18 9"/></svg>';
1570
2406
  html += '</button>';
@@ -1573,22 +2409,22 @@ async function renderTraceList() {
1573
2409
  var cls = (p.key === filterTimeRange) ? ' active' : '';
1574
2410
  html += '<div class="time-menu-item' + cls + '" onclick="selectTimeRange(\\'' + p.key + '\\')">';
1575
2411
  html += '<span class="check">' + (p.key === filterTimeRange ? '✓' : '') + '</span>';
1576
- html += esc(p.label);
2412
+ html += esc(timePresetLabel(p));
1577
2413
  html += '</div>';
1578
2414
  });
1579
2415
  html += '</div></div>';
1580
- html += '<button class="icon-refresh-btn" onclick="refreshTraceList()" title="Refresh trace list">' + ICON_REFRESH + '</button>';
2416
+ html += '<button class="icon-refresh-btn" onclick="refreshTraceList()" title="' + esc(t('refresh_trace_list')) + '">' + ICON_REFRESH + '</button>';
1581
2417
  if (hasFilter) {
1582
- html += '<button class="btn-clear" onclick="clearFilter()">✕ Clear</button>';
2418
+ html += '<button class="btn-clear" onclick="clearFilter()">✕ ' + esc(t('clear')) + '</button>';
1583
2419
  }
1584
2420
  html += '</div>';
1585
2421
 
1586
2422
  // Session list
1587
- html += '<div class="section-title">Traces <span class="count">' + sessData.total + (hasFilter ? ' matched' : ' total') + '</span></div>';
2423
+ html += '<div class="section-title">' + esc(t('traces')) + ' <span class="count">' + sessData.total + (hasFilter ? (' ' + esc(t('matched'))) : (' ' + esc(t('total'))) ) + '</span></div>';
1588
2424
  html += '<div class="session-list">';
1589
2425
 
1590
2426
  if (sessData.sessions.length === 0) {
1591
- html += '<div class="empty"><div class="icon">📭</div><div class="text">No sessions recorded yet</div></div>';
2427
+ html += '<div class="empty"><div class="icon">📭</div><div class="text">' + esc(t('no_sessions')) + '</div></div>';
1592
2428
  } else {
1593
2429
  var visibleSessions = (sessData.sessions || []).filter(function(s) {
1594
2430
  var sid = String((s && s.session_id) || '').toLowerCase();
@@ -1598,24 +2434,31 @@ async function renderTraceList() {
1598
2434
  var dur = (s.start_time && s.end_time)
1599
2435
  ? fmtDur(new Date(s.end_time).getTime() - new Date(s.start_time).getTime())
1600
2436
  : '-';
2437
+ var parentSessionId = String((s && s.parent_session_id) || '').trim();
2438
+ var userId = String((s && s.user_id) || '');
2439
+ var isSubagent = !!Number((s && s.is_subagent) || 0) || !!parentSessionId || /(^|[:_\\-])subagent([:_\\-]|$)/i.test(userId);
1601
2440
 
1602
2441
  html += '<div class="session-card" onclick="location.hash=\\'#/trace/' + encodeURIComponent(s.session_id) + '\\'">';
1603
2442
  html += '<div class="session-top">';
1604
2443
  html += '<span class="session-id">' + esc(s.session_id) + '</span>';
1605
2444
  html += '<span class="session-model">' + esc(s.model_name || '-') + '</span>';
2445
+ if (isSubagent) {
2446
+ var roleTitle = parentSessionId ? (t('parent') + ': ' + parentSessionId) : t('heuristic_subagent_session');
2447
+ html += '<span class="session-role subagent" title="' + esc(roleTitle) + '">' + esc(t('subagent')) + '</span>';
2448
+ }
1606
2449
  html += '<span class="session-user">🤖 ' + esc(s.user_id || '-') + '</span>';
1607
2450
  if (s.channel_id) html += '<span class="session-channel">📱 ' + esc(s.channel_id) + '</span>';
1608
2451
  html += '<span class="session-time">' + fmtTime(s.start_time) + '</span>';
1609
2452
  html += '</div>';
1610
2453
  html += '<div class="session-bottom">';
1611
- html += '<span class="session-stat"><b>' + s.total_actions + '</b> actions</span>';
1612
- html += '<span class="session-stat"><b>' + fmtNum(s.total_tokens) + '</b> tokens</span>';
2454
+ html += '<span class="session-stat"><b>' + s.total_actions + '</b> ' + esc(t('actions_unit')) + '</span>';
2455
+ html += '<span class="session-stat"><b>' + fmtNum(s.total_tokens) + '</b> ' + esc(t('tokens_unit')) + '</span>';
1613
2456
  html += '<span class="session-stat"><b>' + dur + '</b></span>';
1614
2457
  html += '</div>';
1615
2458
  html += '</div>';
1616
2459
  });
1617
2460
  if (visibleSessions.length === 0) {
1618
- html += '<div class="empty"><div class="icon">📭</div><div class="text">No sessions recorded yet</div></div>';
2461
+ html += '<div class="empty"><div class="icon">📭</div><div class="text">' + esc(t('no_sessions')) + '</div></div>';
1619
2462
  }
1620
2463
  }
1621
2464
 
@@ -1624,16 +2467,16 @@ async function renderTraceList() {
1624
2467
  // Pagination
1625
2468
  var totalPages = Math.ceil(sessData.total / 20) || 1;
1626
2469
  html += '<div class="pagination">';
1627
- html += '<button onclick="goPage(' + (currentPage-1) + ')" ' + (currentPage<=1?'disabled':'') + '>&laquo; Prev</button>';
1628
- html += '<span class="page-info">Page ' + currentPage + ' / ' + totalPages + '</span>';
1629
- html += '<button onclick="goPage(' + (currentPage+1) + ')" ' + (currentPage>=totalPages?'disabled':'') + '>Next &raquo;</button>';
2470
+ html += '<button onclick="goPage(' + (currentPage-1) + ')" ' + (currentPage<=1?'disabled':'') + '>&laquo; ' + esc(t('prev')) + '</button>';
2471
+ html += '<span class="page-info">' + esc(t('page')) + ' ' + currentPage + ' / ' + totalPages + '</span>';
2472
+ html += '<button onclick="goPage(' + (currentPage+1) + ')" ' + (currentPage>=totalPages?'disabled':'') + '>' + esc(t('next')) + ' &raquo;</button>';
1630
2473
  html += '</div>';
1631
2474
 
1632
2475
  app.innerHTML = renderLayout('traces', html);
1633
2476
 
1634
2477
  } catch(err) {
1635
2478
  app.innerHTML = renderLayout('traces',
1636
- '<div class="empty"><div class="icon">⚠️</div><div class="text">Failed to load: ' + esc(String(err)) + '</div></div>');
2479
+ '<div class="empty"><div class="icon">⚠️</div><div class="text">' + esc(t('failed_load')) + esc(String(err)) + '</div></div>');
1637
2480
  }
1638
2481
  }
1639
2482
 
@@ -1657,6 +2500,9 @@ window.applyFilter = function() {
1657
2500
  window.clearFilter = function() {
1658
2501
  filterSearch = '';
1659
2502
  filterTimeRange = '';
2503
+ filterTimeFrom = '';
2504
+ filterTimeTo = '';
2505
+ filterAgentType = 'main';
1660
2506
  currentPage = 1;
1661
2507
  renderTraceList();
1662
2508
  };
@@ -1673,16 +2519,100 @@ window.toggleTimeMenu = function(e) {
1673
2519
 
1674
2520
  window.selectTimeRange = function(key) {
1675
2521
  filterTimeRange = key;
2522
+ var nowIso = new Date().toISOString();
2523
+ if (!key) {
2524
+ filterTimeFrom = '';
2525
+ filterTimeTo = '';
2526
+ } else {
2527
+ var preset = TIME_PRESETS.find(function(p) { return p.key === key; });
2528
+ if (preset && preset.ms) {
2529
+ filterTimeFrom = new Date(Date.now() - preset.ms).toISOString();
2530
+ filterTimeTo = nowIso;
2531
+ }
2532
+ }
1676
2533
  currentPage = 1;
1677
2534
  var menu = document.getElementById('time-menu');
1678
2535
  if (menu) menu.classList.remove('open');
1679
2536
  renderTraceList();
1680
2537
  };
1681
2538
 
2539
+ window.selectAgentType = function(value) {
2540
+ filterAgentType = (value === 'subagent' || value === 'system' || value === 'replay' || value === 'all') ? value : 'main';
2541
+ currentPage = 1;
2542
+ renderTraceList();
2543
+ };
2544
+
2545
+ window.toggleAgentTypeMenu = function(e) {
2546
+ e.stopPropagation();
2547
+ var menu = document.getElementById('agent-type-menu');
2548
+ if (!menu) return;
2549
+ var isOpen = menu.classList.contains('open');
2550
+ menu.classList.remove('open');
2551
+ if (!isOpen) menu.classList.add('open');
2552
+ };
2553
+
2554
+ window.selectAgentTypeMenu = function(value) {
2555
+ var menu = document.getElementById('agent-type-menu');
2556
+ if (menu) menu.classList.remove('open');
2557
+ window.selectAgentType(value);
2558
+ };
2559
+
2560
+ window.selectTenantScope = function(scopeId) {
2561
+ selectedScopeId = String(scopeId || 'local');
2562
+ saveScopeId(selectedScopeId);
2563
+ currentPage = 1;
2564
+ router();
2565
+ };
2566
+
2567
+ function closeTenantMenus() {
2568
+ ['tenant-project-menu'].forEach(function(id) {
2569
+ var el = document.getElementById(id);
2570
+ if (el) el.classList.remove('open');
2571
+ });
2572
+ }
2573
+ function closeLanguageMenu() {
2574
+ var el = document.getElementById('lang-menu');
2575
+ if (el) el.classList.remove('open');
2576
+ }
2577
+
2578
+ window.toggleTenantMenu = function(e, key) {
2579
+ e.stopPropagation();
2580
+ var id = key === 'project' ? 'tenant-project-menu' : '';
2581
+ var menu = document.getElementById(id);
2582
+ closeTenantMenus();
2583
+ if (menu) menu.classList.toggle('open');
2584
+ };
2585
+
2586
+ window.selectTenantScopeMenu = function(value) {
2587
+ closeTenantMenus();
2588
+ window.selectTenantScope(value);
2589
+ };
2590
+
2591
+ window.toggleLanguageMenu = function(e) {
2592
+ e.stopPropagation();
2593
+ var menu = document.getElementById('lang-menu');
2594
+ closeLanguageMenu();
2595
+ if (menu) menu.classList.toggle('open');
2596
+ };
2597
+ window.selectLanguageMenu = function(value) {
2598
+ closeLanguageMenu();
2599
+ currentLang = normalizeLang(value);
2600
+ try { window.localStorage.setItem(LANG_STORAGE_KEY, currentLang); } catch (_) {}
2601
+ router();
2602
+ };
2603
+
1682
2604
  // Click anywhere on page to close dropdowns
1683
2605
  document.addEventListener('click', function() {
1684
2606
  var menu = document.getElementById('time-menu');
1685
2607
  if (menu) menu.classList.remove('open');
2608
+ var metricsMenu = document.getElementById('metrics-time-menu');
2609
+ if (metricsMenu) metricsMenu.classList.remove('open');
2610
+ var skillsMenu = document.getElementById('skills-time-menu');
2611
+ if (skillsMenu) skillsMenu.classList.remove('open');
2612
+ var agentMenu = document.getElementById('agent-type-menu');
2613
+ if (agentMenu) agentMenu.classList.remove('open');
2614
+ closeTenantMenus();
2615
+ closeLanguageMenu();
1686
2616
  // Close analytics dropdowns too
1687
2617
  var anMenu = document.getElementById('an-time-menu');
1688
2618
  if (anMenu) anMenu.classList.remove('open');
@@ -1696,6 +2626,12 @@ var anTimeRange = '24h';
1696
2626
  var anMetricTab = 'sessions'; // sessions | tokens
1697
2627
  var __dashboardCache = {};
1698
2628
 
2629
+ var skillsTimeRange = '24h';
2630
+ var skillsLimit = 200;
2631
+ var skillsSelected = '';
2632
+ var skillsOpenEncoded = '';
2633
+ var skillsDetailCache = {};
2634
+
1699
2635
  function anGetTimeFromISO() {
1700
2636
  if (!anTimeRange) return '';
1701
2637
  var preset = TIME_PRESETS.find(function(p){ return p.key === anTimeRange; });
@@ -1704,9 +2640,9 @@ function anGetTimeFromISO() {
1704
2640
  }
1705
2641
 
1706
2642
  function anGetTimeLabel() {
1707
- if (!anTimeRange) return 'All time';
2643
+ if (!anTimeRange) return currentLang === 'zh' ? '全部时间' : 'All time';
1708
2644
  var preset = TIME_PRESETS.find(function(p){ return p.key === anTimeRange; });
1709
- return preset ? preset.label : 'All time';
2645
+ return preset ? timePresetLabel(preset) : (currentLang === 'zh' ? '全部时间' : 'All time');
1710
2646
  }
1711
2647
 
1712
2648
  async function renderDashboard(opts) {
@@ -1714,7 +2650,7 @@ async function renderDashboard(opts) {
1714
2650
  var cacheKey = anTimeRange || 'all';
1715
2651
  var cached = __dashboardCache[cacheKey];
1716
2652
  if (!cached || opts.forceReload) {
1717
- app.innerHTML = renderLayout('dashboard', '<div class="loading">Loading dashboard...</div>');
2653
+ app.innerHTML = renderLayout('dashboard', '<div class="loading">' + esc(t('loading_dashboard')) + '</div>');
1718
2654
  }
1719
2655
 
1720
2656
  try {
@@ -1749,7 +2685,7 @@ async function renderDashboard(opts) {
1749
2685
  var cls = (p.key === anTimeRange) ? ' active' : '';
1750
2686
  html += '<div class="time-menu-item' + cls + '" onclick="anSelectTime(\\'' + p.key + '\\')">';
1751
2687
  html += '<span class="check">' + (p.key === anTimeRange ? '✓' : '') + '</span>';
1752
- html += esc(p.label);
2688
+ html += esc(timePresetLabel(p));
1753
2689
  html += '</div>';
1754
2690
  });
1755
2691
  html += '</div></div>';
@@ -1758,19 +2694,19 @@ async function renderDashboard(opts) {
1758
2694
  // --- KPI stat cards ---
1759
2695
  var inpPct = ov.totalTokens > 0 ? Math.round(ov.inputTokens / ov.totalTokens * 100) : 50;
1760
2696
  html += '<div class="stat-grid" style="grid-template-columns:repeat(auto-fit,minmax(140px,1fr))">';
1761
- html += statCard('Sessions', fmtNum(ov.totalSessions));
2697
+ html += statCard(tr('Sessions', '会话数'), fmtNum(ov.totalSessions));
1762
2698
 
1763
2699
  // Tokens with input/output split
1764
- html += '<div class="stat"><div class="stat-label">Total Tokens</div>';
2700
+ html += '<div class="stat"><div class="stat-label">' + esc(tr('Total Tokens', '总 Token')) + '</div>';
1765
2701
  html += '<div class="stat-value">' + fmtNum(ov.totalTokens) + '</div>';
1766
2702
  html += '<div class="token-split"><div class="inp" style="width:' + inpPct + '%"></div><div class="outp" style="width:' + (100 - inpPct) + '%"></div></div>';
1767
- html += '<div class="kpi-sub"><span class="inp-color">⬤</span> ' + fmtNum(ov.inputTokens) + ' in <span class="outp-color">⬤</span> ' + fmtNum(ov.outputTokens) + ' out</div>';
2703
+ html += '<div class="kpi-sub"><span class="inp-color">⬤</span> ' + fmtNum(ov.inputTokens) + ' ' + esc(tr('in', '输入')) + ' <span class="outp-color">⬤</span> ' + fmtNum(ov.outputTokens) + ' ' + esc(tr('out', '输出')) + '</div>';
1768
2704
  html += '</div>';
1769
2705
 
1770
- html += statCard('Actions', fmtNum(ov.totalActions));
1771
- html += statCard('Avg Latency', fmtDur(ov.avgLatencyMs));
1772
- html += statCard('Models', String(ov.activeModels));
1773
- html += statCard('Alerts', String(ov.securityAlerts));
2706
+ html += statCard(tr('Actions', '动作数'), fmtNum(ov.totalActions));
2707
+ html += statCard(tr('Avg Latency', '平均耗时'), fmtDur(ov.avgLatencyMs));
2708
+ html += statCard(tr('Models', '模型数'), String(ov.activeModels));
2709
+ html += statCard(tr('Alerts', '告警数'), String(ov.securityAlerts));
1774
2710
  html += '</div>';
1775
2711
 
1776
2712
  // --- Row 1: Traces by time + Token usage by time ---
@@ -1780,15 +2716,15 @@ async function renderDashboard(opts) {
1780
2716
  html += '<div class="an-card">';
1781
2717
  var gran = data.granularity || 'day';
1782
2718
  var granLabel = gran === 'hour' ? anGetTimeLabel() : (ts.length + ' days');
1783
- html += '<h3><span class="icon">📊</span> Activity Over Time';
2719
+ html += '<h3><span class="icon">📊</span> ' + esc(tr('Activity Over Time', '时间趋势')) ;
1784
2720
  html += '<span class="sub">' + granLabel + '</span></h3>';
1785
2721
  html += buildTimeSeriesChart(ts, anMetricTab, gran);
1786
2722
  html += '</div>';
1787
2723
 
1788
2724
  // Model Usage (horizontal bars)
1789
2725
  html += '<div class="an-card">';
1790
- html += '<h3><span class="icon">🤖</span> Model Usage';
1791
- html += '<span class="sub">' + mu.length + ' models</span></h3>';
2726
+ html += '<h3><span class="icon">🤖</span> ' + esc(tr('Model Usage', '模型使用')) ;
2727
+ html += '<span class="sub">' + mu.length + ' ' + esc(tr('models', '个模型')) + '</span></h3>';
1792
2728
  html += buildModelUsageChart(mu, tbm);
1793
2729
  html += '</div>';
1794
2730
 
@@ -1799,14 +2735,14 @@ async function renderDashboard(opts) {
1799
2735
 
1800
2736
  // Action type distribution (donut + legend)
1801
2737
  html += '<div class="an-card">';
1802
- html += '<h3><span class="icon">🎯</span> Action Distribution</h3>';
2738
+ html += '<h3><span class="icon">🎯</span> ' + esc(tr('Action Distribution', '动作分布')) + '</h3>';
1803
2739
  html += buildActionDistribution(ad);
1804
2740
  html += '</div>';
1805
2741
 
1806
2742
  // Top agents table
1807
2743
  html += '<div class="an-card">';
1808
- html += '<h3><span class="icon">👤</span> Top Agents';
1809
- html += '<span class="sub">' + ta.length + ' agents</span></h3>';
2744
+ html += '<h3><span class="icon">👤</span> ' + esc(tr('Top Agents', '高频代理')) ;
2745
+ html += '<span class="sub">' + ta.length + ' ' + esc(tr('agents', '个代理')) + '</span></h3>';
1810
2746
  html += buildAgentsTable(ta);
1811
2747
  html += '</div>';
1812
2748
 
@@ -1816,59 +2752,367 @@ async function renderDashboard(opts) {
1816
2752
 
1817
2753
  } catch(err) {
1818
2754
  app.innerHTML = renderLayout('dashboard',
1819
- '<div class="empty"><div class="icon">⚠️</div><div class="text">Failed to load dashboard: ' + esc(String(err)) + '</div></div>');
2755
+ '<div class="empty"><div class="icon">⚠️</div><div class="text">' + esc(t('failed_load')) + esc(String(err)) + '</div></div>');
1820
2756
  }
1821
2757
  }
1822
2758
 
1823
- /* ================================================================ */
1824
- /* Metrics (direct scrape from OpenClaw metrics endpoint) */
1825
- /* ================================================================ */
1826
-
1827
- function metricsRangeChange(value) {
1828
- var n = Number(value);
1829
- metricsRangeMinutes = Number.isFinite(n) && n > 0 ? n : 60;
1830
- renderMetrics();
2759
+ function skillsGetTimeFromISO() {
2760
+ if (!skillsTimeRange) return '';
2761
+ var preset = TIME_PRESETS.find(function(p){ return p.key === skillsTimeRange; });
2762
+ if (!preset || !preset.ms) return '';
2763
+ return new Date(Date.now() - preset.ms).toISOString();
1831
2764
  }
1832
2765
 
1833
- function metricsStepChange(value) {
1834
- var n = Number(value);
1835
- metricsStepSec = Number.isFinite(n) && n >= 5 && n <= 600 ? n : 30;
1836
- renderMetrics();
2766
+ function skillsGetTimeToISO() {
2767
+ if (!skillsTimeRange) return '';
2768
+ return new Date().toISOString();
1837
2769
  }
1838
2770
 
1839
- function metricsSortChange(value) {
1840
- metricsTableSort = value || 'name';
1841
- renderMetrics();
2771
+ function skillsGetTimeLabel() {
2772
+ if (!skillsTimeRange) return currentLang === 'zh' ? '全部时间' : 'All time';
2773
+ var preset = TIME_PRESETS.find(function(p){ return p.key === skillsTimeRange; });
2774
+ return preset ? timePresetLabel(preset) : (currentLang === 'zh' ? '全部时间' : 'All time');
1842
2775
  }
1843
2776
 
1844
- function metricsRowClick(row) {
1845
- if (!row) return;
1846
- if (hasActiveTextSelection()) return;
1847
- var enc = row.getAttribute('data-m');
1848
- if (!enc) return;
2777
+ window.skillsToggleTimeMenu = function(e) {
2778
+ e.stopPropagation();
2779
+ var menu = document.getElementById('skills-time-menu');
2780
+ if (!menu) return;
2781
+ var isOpen = menu.classList.contains('open');
2782
+ menu.classList.remove('open');
2783
+ if (!isOpen) menu.classList.add('open');
2784
+ };
2785
+
2786
+ window.skillsSelectTime = function(key) {
2787
+ skillsTimeRange = key;
2788
+ var menu = document.getElementById('skills-time-menu');
2789
+ if (menu) menu.classList.remove('open');
2790
+ renderSkills();
2791
+ };
2792
+
2793
+ window.skillsSetLimit = function(v) {
2794
+ var n = Number(v);
2795
+ skillsLimit = Number.isFinite(n) && n > 0 ? Math.max(10, Math.min(500, Math.floor(n))) : 200;
2796
+ renderSkills();
2797
+ };
2798
+
2799
+ window.refreshSkills = function() {
2800
+ renderSkills();
2801
+ };
2802
+
2803
+ window.skillsOpenDetail = function(name) {
2804
+ skillsSelected = String(name || '').trim();
2805
+ skillsOpenEncoded = encodeURIComponent(skillsSelected);
2806
+ renderSkills();
2807
+ };
2808
+
2809
+ window.skillsOpenDetailByEncoded = function(encoded) {
1849
2810
  try {
1850
- metricsSelected = decodeURIComponent(enc);
1851
- } catch (e) {
1852
- return;
2811
+ skillsSelected = decodeURIComponent(String(encoded || ''));
2812
+ } catch (_) {
2813
+ skillsSelected = String(encoded || '');
1853
2814
  }
1854
- renderMetrics().then(function() {
1855
- requestAnimationFrame(function() {
1856
- var el = document.querySelector('[data-spark-metric="' + enc + '"]');
1857
- if (el) el.scrollIntoView({ behavior: 'smooth', block: 'start' });
2815
+ skillsSelected = String(skillsSelected || '').trim();
2816
+ skillsOpenEncoded = encodeURIComponent(skillsSelected);
2817
+ renderSkills();
2818
+ };
2819
+
2820
+ function skillsBuildDetailHtml(detail, detailError) {
2821
+ if (!detail || !detail.skill) {
2822
+ return '<div class="empty"><div class="icon">⚠️</div><div class="text">' + esc(t('failed_load')) + esc(detailError || 'detail unavailable') + '</div></div>';
2823
+ }
2824
+ var calls = Array.isArray(detail.recentCalls) ? detail.recentCalls : [];
2825
+ var html = '';
2826
+ html += '<div class="kpi-sub" style="margin-bottom:8px"><strong>' + esc(t('skill_description')) + ':</strong> ' + esc(String(detail.description || '-')) + '</div>';
2827
+ html += '<div class="kpi-sub" style="margin-bottom:8px"><strong>' + esc(t('skill_location')) + ':</strong> <span class="mono">' + esc(String(detail.location || '-')) + '</span></div>';
2828
+ html += '<div class="kpi-sub" style="margin-bottom:8px"><strong>Source:</strong> <span class="mono">' + esc(String(detail.source || '-')) + '</span></div>';
2829
+ html += '<div style="margin:8px 0 6px;font-weight:600">' + esc(t('skill_content')) + '</div>';
2830
+ if (detail.contentLoaded && typeof detail.content === 'string' && detail.content.length > 0) {
2831
+ html += '<pre class="json-view" style="max-height:260px;overflow:auto">' + esc(String(detail.content)) + '</pre>';
2832
+ } else {
2833
+ html += '<div class="kpi-sub">-</div>';
2834
+ }
2835
+ html += '<div style="margin:12px 0 6px;font-weight:600">' + esc(t('recent_calls')) + '</div>';
2836
+ if (!calls.length) {
2837
+ html += '<div class="kpi-sub">' + esc(t('no_recent_calls')) + '</div>';
2838
+ } else {
2839
+ html += '<div class="an-table-scroll"><table class="an-table"><thead><tr>';
2840
+ html += '<th>' + esc(t('time')) + '</th><th>Session</th><th>' + esc(t('status')) + '</th><th>ToolCall</th><th>Source</th>';
2841
+ html += '</tr></thead><tbody>';
2842
+ calls.forEach(function(c) {
2843
+ var ok = c.ok === true;
2844
+ var fail = c.ok === false;
2845
+ var traceLink = '#/trace/' + encodeURIComponent(String(c.sessionId || ''));
2846
+ if (detail.skill) {
2847
+ traceLink += '?action=' + encodeURIComponent('skill_call:' + String(detail.skill));
2848
+ }
2849
+ if (c.createdAt) {
2850
+ traceLink += (traceLink.indexOf('?') >= 0 ? '&' : '?') + 't=' + encodeURIComponent(String(c.createdAt));
2851
+ }
2852
+ html += '<tr>';
2853
+ html += '<td class="mono">' + esc(c.createdAt ? fmtTime(c.createdAt) : '-') + '</td>';
2854
+ html += '<td class="mono">' + (c.sessionId ? '<a href="' + esc(traceLink) + '">' + esc(String(c.sessionId || '-')) + '</a>' : '-') + '</td>';
2855
+ html += '<td>' + (ok ? '<span style="color:var(--ok)">' + esc(t('success_short')) + '</span>' : (fail ? '<span style="color:var(--danger)">' + esc(t('fail_short')) + '</span>' : '-')) + '</td>';
2856
+ html += '<td class="mono">' + esc(String(c.toolCallId || '-')) + '</td>';
2857
+ html += '<td class="mono">' + esc(String(c.skillSource || '-')) + '</td>';
2858
+ html += '</tr>';
1858
2859
  });
1859
- });
2860
+ html += '</tbody></table></div>';
2861
+ }
2862
+ return html;
1860
2863
  }
1861
2864
 
1862
- function metricsTableFilterInput(value) {
1863
- metricsSnapshotFilter = value == null ? '' : String(value);
1864
- var tbody = document.getElementById('metrics-snapshot-tbody');
1865
- if (!tbody) return;
1866
- var needle = metricsSnapshotFilter.trim().toLowerCase();
1867
- var rows = tbody.querySelectorAll('tr');
1868
- for (var i = 0; i < rows.length; i++) {
1869
- var tr = rows[i];
1870
- var raw = tr.getAttribute('data-m') || '';
1871
- var name = '';
2865
+ window.skillsToggleDetailByEncoded = async function(encoded, forceOpen) {
2866
+ var name = '';
2867
+ try {
2868
+ name = decodeURIComponent(String(encoded || ''));
2869
+ } catch (_) {
2870
+ name = String(encoded || '');
2871
+ }
2872
+ name = String(name || '').trim();
2873
+ if (!name) return;
2874
+ var enc = encodeURIComponent(name);
2875
+ function closeCard(targetEnc) {
2876
+ var card = document.getElementById('skills-card-' + targetEnc);
2877
+ var panel = document.getElementById('skills-expand-' + targetEnc);
2878
+ if (card) card.classList.remove('is-active');
2879
+ if (panel) {
2880
+ panel.style.display = 'none';
2881
+ panel.innerHTML = '';
2882
+ }
2883
+ }
2884
+
2885
+ if (skillsOpenEncoded && skillsOpenEncoded !== enc) {
2886
+ closeCard(skillsOpenEncoded);
2887
+ }
2888
+
2889
+ if (!forceOpen && skillsOpenEncoded === enc) {
2890
+ closeCard(enc);
2891
+ skillsOpenEncoded = '';
2892
+ skillsSelected = '';
2893
+ return;
2894
+ }
2895
+
2896
+ var cardEl = document.getElementById('skills-card-' + enc);
2897
+ var panelEl = document.getElementById('skills-expand-' + enc);
2898
+ if (!cardEl || !panelEl) {
2899
+ skillsSelected = name;
2900
+ skillsOpenEncoded = enc;
2901
+ renderSkills();
2902
+ return;
2903
+ }
2904
+
2905
+ cardEl.classList.add('is-active');
2906
+ panelEl.style.display = 'block';
2907
+ panelEl.innerHTML = '<div class="loading">' + esc(t('loading')) + '</div>';
2908
+ skillsOpenEncoded = enc;
2909
+ skillsSelected = name;
2910
+
2911
+ var tf = skillsGetTimeFromISO();
2912
+ var tt = skillsGetTimeToISO();
2913
+ var cacheKey = enc + '|' + (tf || '') + '|' + (tt || '');
2914
+ if (skillsDetailCache[cacheKey]) {
2915
+ panelEl.innerHTML = skillsBuildDetailHtml(skillsDetailCache[cacheKey], '');
2916
+ return;
2917
+ }
2918
+ try {
2919
+ var detail = await fetchApi('/skills/detail?skill=' + encodeURIComponent(name) + '&limit=30' + (tf ? '&timeFrom=' + encodeURIComponent(tf) : '') + (tt ? '&timeTo=' + encodeURIComponent(tt) : ''));
2920
+ skillsDetailCache[cacheKey] = detail;
2921
+ panelEl.innerHTML = skillsBuildDetailHtml(detail, '');
2922
+ } catch (e) {
2923
+ panelEl.innerHTML = skillsBuildDetailHtml(null, String(e || ''));
2924
+ }
2925
+ };
2926
+
2927
+ window.skillsCloseDetail = function() {
2928
+ skillsSelected = '';
2929
+ if (skillsOpenEncoded) {
2930
+ var card = document.getElementById('skills-card-' + skillsOpenEncoded);
2931
+ var panel = document.getElementById('skills-expand-' + skillsOpenEncoded);
2932
+ if (card) card.classList.remove('is-active');
2933
+ if (panel) { panel.style.display = 'none'; panel.innerHTML = ''; }
2934
+ skillsOpenEncoded = '';
2935
+ return;
2936
+ }
2937
+ renderSkills();
2938
+ };
2939
+
2940
+ async function renderSkills() {
2941
+ app.innerHTML = renderLayout('skills', '<div class="loading">' + esc(t('loading_skills')) + '</div>');
2942
+ try {
2943
+ var tf = skillsGetTimeFromISO();
2944
+ var tt = skillsGetTimeToISO();
2945
+ var qs = '?limit=' + encodeURIComponent(String(skillsLimit));
2946
+ if (tf) qs += '&timeFrom=' + encodeURIComponent(tf);
2947
+ if (tt) qs += '&timeTo=' + encodeURIComponent(tt);
2948
+ var data = await fetchApi('/skills/overview' + qs);
2949
+ var items = Array.isArray(data && data.items) ? data.items : [];
2950
+
2951
+ var html = '';
2952
+ html += '<div class="filter-bar" style="margin-bottom:20px">';
2953
+ html += '<div class="time-dropdown">';
2954
+ html += '<button class="time-btn" onclick="skillsToggleTimeMenu(event)">';
2955
+ html += esc(skillsGetTimeLabel());
2956
+ html += ' <svg viewBox="0 0 24 24" style="width:12px;height:12px"><polyline points="6 9 12 15 18 9"/></svg>';
2957
+ html += '</button>';
2958
+ html += '<div class="time-menu" id="skills-time-menu">';
2959
+ TIME_PRESETS.forEach(function(p) {
2960
+ var cls = (p.key === skillsTimeRange) ? ' active' : '';
2961
+ html += '<div class="time-menu-item' + cls + '" onclick="skillsSelectTime(\\'' + p.key + '\\')">';
2962
+ html += '<span class="check">' + (p.key === skillsTimeRange ? '✓' : '') + '</span>';
2963
+ html += esc(timePresetLabel(p));
2964
+ html += '</div>';
2965
+ });
2966
+ html += '</div></div>';
2967
+ html += '<select class="filter-select" onchange="skillsSetLimit(this.value)">';
2968
+ [50, 100, 200, 500].forEach(function(n) {
2969
+ html += '<option value="' + n + '"' + (n === skillsLimit ? ' selected' : '') + '>Top ' + n + '</option>';
2970
+ });
2971
+ html += '</select>';
2972
+ html += '<button class="icon-refresh-btn" onclick="refreshSkills()" title="' + esc(t('refresh_trace_list')) + '">' + ICON_REFRESH + '</button>';
2973
+ html += '</div>';
2974
+
2975
+ html += '<div class="stat-grid" style="grid-template-columns:repeat(auto-fit,minmax(160px,1fr))">';
2976
+ html += statCard(t('skills_title'), fmtNum(Number(data.totalSkills || 0)));
2977
+ html += statCard(t('call_count'), fmtNum(Number(data.totalCalls || 0)));
2978
+ html += '</div>';
2979
+
2980
+ html += '<div class="section-title">' + esc(t('skills_title')) + ' <span class="count">' + fmtNum(items.length) + '</span></div>';
2981
+ if (!items.length) {
2982
+ html += '<div class="empty"><div class="icon">🧩</div><div class="text">' + esc(t('no_skills')) + '</div></div>';
2983
+ } else {
2984
+ html += '<div class="session-list skills-list" id="skills-list">';
2985
+ items.forEach(function(it) {
2986
+ var skillName = String(it.skill || '-');
2987
+ var active = skillsSelected === skillName;
2988
+ var encSkill = encodeURIComponent(skillName);
2989
+ html += '<div class="session-card skills-card' + (active ? ' is-active' : '') + '" id="skills-card-' + encSkill + '" onclick="skillsToggleDetailByEncoded(\\'' + encSkill + '\\')">';
2990
+ html += '<div class="session-top">';
2991
+ html += '<span class="session-id">' + esc(skillName) + '</span>';
2992
+ html += '<span class="session-model">' + esc(String(it.source || '-')) + '</span>';
2993
+ html += '<span class="session-model">' + fmtNum(Number(it.callCount || 0)) + ' ' + esc(t('call_count')) + '</span>';
2994
+ html += '<span class="session-time">' + esc(it.lastSeen ? fmtTime(it.lastSeen) : '-') + '</span>';
2995
+ html += '</div>';
2996
+ html += '<div class="skills-expand" id="skills-expand-' + encSkill + '" onclick="event.stopPropagation()"></div>';
2997
+ html += '</div>';
2998
+ });
2999
+ html += '</div>';
3000
+ }
3001
+
3002
+ app.innerHTML = renderLayout('skills', html);
3003
+ if (skillsSelected) {
3004
+ var reopen = encodeURIComponent(skillsSelected);
3005
+ setTimeout(function() { window.skillsToggleDetailByEncoded(reopen, true); }, 0);
3006
+ }
3007
+ } catch (err) {
3008
+ app.innerHTML = renderLayout('skills',
3009
+ '<div class="empty"><div class="icon">⚠️</div><div class="text">' + esc(t('failed_load')) + esc(String(err)) + '</div></div>');
3010
+ }
3011
+ }
3012
+
3013
+ /* ================================================================ */
3014
+ /* Metrics (direct scrape from OpenClaw metrics endpoint) */
3015
+ /* ================================================================ */
3016
+
3017
+ function metricsRangeChange(value) {
3018
+ var n = Number(value);
3019
+ metricsRangeMinutes = Number.isFinite(n) && n > 0 ? n : 60;
3020
+ var now = Date.now();
3021
+ metricsTimeTo = new Date(now).toISOString();
3022
+ metricsTimeFrom = new Date(now - metricsRangeMinutes * 60 * 1000).toISOString();
3023
+ renderMetrics();
3024
+ }
3025
+
3026
+ window.metricsToggleRangeMenu = function(e) {
3027
+ e.stopPropagation();
3028
+ var menu = document.getElementById('metrics-time-menu');
3029
+ if (!menu) return;
3030
+ var isOpen = menu.classList.contains('open');
3031
+ menu.classList.remove('open');
3032
+ if (!isOpen) menu.classList.add('open');
3033
+ };
3034
+
3035
+ window.metricsSelectRange = function(value) {
3036
+ var menu = document.getElementById('metrics-time-menu');
3037
+ if (menu) menu.classList.remove('open');
3038
+ metricsRangeChange(value);
3039
+ };
3040
+
3041
+ function metricsStepChange(value) {
3042
+ var n = Number(value);
3043
+ metricsStepSec = Number.isFinite(n) && n >= 5 && n <= 600 ? n : 30;
3044
+ renderMetrics();
3045
+ }
3046
+
3047
+ function ensureMetricsTimeRangeDefaults() {
3048
+ if (metricsTimeFrom && metricsTimeTo) return;
3049
+ var now = Date.now();
3050
+ metricsTimeTo = new Date(now).toISOString();
3051
+ metricsTimeFrom = new Date(now - metricsRangeMinutes * 60 * 1000).toISOString();
3052
+ }
3053
+
3054
+ function computeAutoStepSec(startMs, endMs) {
3055
+ var s = Number(startMs || 0);
3056
+ var e = Number(endMs || 0);
3057
+ if (!Number.isFinite(s) || !Number.isFinite(e) || e <= s) return 30;
3058
+ var spanSec = Math.max(1, Math.floor((e - s) / 1000));
3059
+ var targetBuckets = 120;
3060
+ var raw = Math.ceil(spanSec / targetBuckets);
3061
+ var choices = [5, 10, 15, 30, 60, 120, 300, 600];
3062
+ for (var i = 0; i < choices.length; i++) {
3063
+ if (choices[i] >= raw) return choices[i];
3064
+ }
3065
+ return 600;
3066
+ }
3067
+
3068
+ function getMetricsTimeFilter() {
3069
+ return {
3070
+ timeFrom: metricsTimeFrom || '',
3071
+ timeTo: metricsTimeTo || '',
3072
+ };
3073
+ }
3074
+
3075
+ window.metricsApplyTimeInputs = function() {
3076
+ var fromEl = document.getElementById('m-time-from');
3077
+ var toEl = document.getElementById('m-time-to');
3078
+ metricsTimeFrom = toISOFromDateTimeInputValue(fromEl && fromEl.value ? String(fromEl.value) : '');
3079
+ metricsTimeTo = toISOFromDateTimeInputValue(toEl && toEl.value ? String(toEl.value) : '');
3080
+ renderMetrics();
3081
+ };
3082
+
3083
+ function metricsSortChange(value) {
3084
+ metricsTableSort = value || 'name';
3085
+ renderMetrics();
3086
+ }
3087
+
3088
+ function metricsRowClick(row) {
3089
+ if (!row) return;
3090
+ if (hasActiveTextSelection()) return;
3091
+ var enc = row.getAttribute('data-m');
3092
+ if (!enc) return;
3093
+ try {
3094
+ metricsSelected = decodeURIComponent(enc);
3095
+ } catch (e) {
3096
+ return;
3097
+ }
3098
+ renderMetrics().then(function() {
3099
+ requestAnimationFrame(function() {
3100
+ var el = document.querySelector('[data-spark-metric="' + enc + '"]');
3101
+ if (el) el.scrollIntoView({ behavior: 'smooth', block: 'start' });
3102
+ });
3103
+ });
3104
+ }
3105
+
3106
+ function metricsTableFilterInput(value) {
3107
+ metricsSnapshotFilter = value == null ? '' : String(value);
3108
+ var tbody = document.getElementById('metrics-snapshot-tbody');
3109
+ if (!tbody) return;
3110
+ var needle = metricsSnapshotFilter.trim().toLowerCase();
3111
+ var rows = tbody.querySelectorAll('tr');
3112
+ for (var i = 0; i < rows.length; i++) {
3113
+ var tr = rows[i];
3114
+ var raw = tr.getAttribute('data-m') || '';
3115
+ var name = '';
1872
3116
  try {
1873
3117
  name = decodeURIComponent(raw).toLowerCase();
1874
3118
  } catch (e) {
@@ -1901,10 +3145,14 @@ function pickMetricByKeyword(items, includeKeywords, excludeKeywords) {
1901
3145
  return '';
1902
3146
  }
1903
3147
 
1904
- async function fetchMetricSeriesSafe(metricName, minutes, stepSec) {
3148
+ async function fetchMetricSeriesSafe(metricName, minutes, stepSec, aggregate, timeFrom, timeTo) {
1905
3149
  if (!metricName) return { metricName: '', metricType: 'untyped', points: [], series: [] };
1906
3150
  try {
1907
- return await fetchApi('/metrics/series?metric=' + encodeURIComponent(metricName) + '&minutes=' + minutes + '&stepSec=' + stepSec + '&aggregate=1');
3151
+ var agg = aggregate === false ? '0' : '1';
3152
+ var q = '/metrics/series?metric=' + encodeURIComponent(metricName) + '&minutes=' + minutes + '&stepSec=' + stepSec + '&aggregate=' + agg;
3153
+ if (timeFrom) q += '&timeFrom=' + encodeURIComponent(String(timeFrom));
3154
+ if (timeTo) q += '&timeTo=' + encodeURIComponent(String(timeTo));
3155
+ return await fetchApi(q);
1908
3156
  } catch (_) {
1909
3157
  return { metricName: metricName, metricType: 'untyped', points: [], series: [] };
1910
3158
  }
@@ -1957,10 +3205,205 @@ function metricSeriesRenderable(points) {
1957
3205
  return Array.isArray(points) && points.length > 0;
1958
3206
  }
1959
3207
 
3208
+ function metricSeriesLabel(s, idx) {
3209
+ if (s && s.labels && typeof s.labels === 'object') {
3210
+ var keys = Object.keys(s.labels);
3211
+ if (keys.length > 0) {
3212
+ var k = keys[0];
3213
+ return String(s.labels[k] || k || ('series_' + (idx + 1)));
3214
+ }
3215
+ }
3216
+ return 'series_' + (idx + 1);
3217
+ }
3218
+
3219
+ function formatPanelTimeLabel(ts) {
3220
+ if (!ts) return '-';
3221
+ try {
3222
+ var d = new Date(Number(ts));
3223
+ return d.toLocaleTimeString();
3224
+ } catch (_) {
3225
+ return '-';
3226
+ }
3227
+ }
3228
+
3229
+ function inferSeriesDomain(points, fallbackDomain, stepSec) {
3230
+ var fallbackStart = fallbackDomain && Number.isFinite(fallbackDomain.startMs) ? Number(fallbackDomain.startMs) : (Date.now() - 60 * 60 * 1000);
3231
+ var fallbackEnd = fallbackDomain && Number.isFinite(fallbackDomain.endMs) ? Number(fallbackDomain.endMs) : Date.now();
3232
+ if (!Array.isArray(points) || points.length === 0) {
3233
+ return { startMs: fallbackStart, endMs: fallbackEnd };
3234
+ }
3235
+ var minTs = Infinity;
3236
+ var maxTs = -Infinity;
3237
+ for (var i = 0; i < points.length; i++) {
3238
+ var ts = Number(points[i] && points[i].timestampMs || 0);
3239
+ if (!Number.isFinite(ts)) continue;
3240
+ if (ts < minTs) minTs = ts;
3241
+ if (ts > maxTs) maxTs = ts;
3242
+ }
3243
+ if (!Number.isFinite(minTs) || !Number.isFinite(maxTs)) {
3244
+ return { startMs: fallbackStart, endMs: fallbackEnd };
3245
+ }
3246
+ var stepMs = Math.max(5, Number(stepSec || 30)) * 1000;
3247
+ var pad = Math.max(stepMs * 2, 30 * 1000);
3248
+ var start = minTs - pad;
3249
+ var end = maxTs + pad;
3250
+ if (end <= start) end = start + stepMs;
3251
+ return { startMs: start, endMs: end };
3252
+ }
3253
+
3254
+ function collectAllPoints(series) {
3255
+ var out = [];
3256
+ if (!series || !Array.isArray(series)) return out;
3257
+ for (var i = 0; i < series.length; i++) {
3258
+ var pts = series[i] && series[i].points;
3259
+ if (!Array.isArray(pts)) continue;
3260
+ for (var j = 0; j < pts.length; j++) out.push(pts[j]);
3261
+ }
3262
+ return out;
3263
+ }
3264
+
3265
+ function buildMetricsPanel(metricName, detailed, timeDomain) {
3266
+ var palette = ['#5b8ff9', '#5ad8a6', '#f6bd16', '#e8684a', '#6dc8ec', '#9270ca', '#ff9d4d', '#269a99'];
3267
+ var series = (detailed && Array.isArray(detailed.series)) ? detailed.series.filter(function(s) {
3268
+ return s && Array.isArray(s.points) && s.points.length > 0;
3269
+ }) : [];
3270
+ if (!series.length && detailed && Array.isArray(detailed.points) && detailed.points.length > 0) {
3271
+ series = [{ seriesId: 'aggregate', labels: {}, points: detailed.points }];
3272
+ }
3273
+ if (!series.length) return '';
3274
+
3275
+ var width = 860;
3276
+ var height = 260;
3277
+ var padLeft = 52;
3278
+ var padRight = 16;
3279
+ var padTop = 12;
3280
+ var padBottom = 32;
3281
+ var allPoints = [];
3282
+ series.forEach(function(s) {
3283
+ (s.points || []).forEach(function(p) { allPoints.push(p); });
3284
+ });
3285
+ var minY = Math.min.apply(null, allPoints.map(function(p) { return Number(p.value || 0); }));
3286
+ var maxY = Math.max.apply(null, allPoints.map(function(p) { return Number(p.value || 0); }));
3287
+ if (!Number.isFinite(minY)) minY = 0;
3288
+ if (!Number.isFinite(maxY)) maxY = 1;
3289
+ minY = 0;
3290
+ if (minY === maxY) maxY = minY + 1;
3291
+ var plotW = width - padLeft - padRight;
3292
+ var plotH = height - padTop - padBottom;
3293
+ var startMs = timeDomain && timeDomain.startMs != null ? Number(timeDomain.startMs) : NaN;
3294
+ var endMs = timeDomain && timeDomain.endMs != null ? Number(timeDomain.endMs) : NaN;
3295
+ var useTimeX = Number.isFinite(startMs) && Number.isFinite(endMs) && endMs > startMs;
3296
+ var yTickCount = 4;
3297
+ var xTickCount = 4;
3298
+ function xFor(tsRaw, index, len) {
3299
+ if (!useTimeX) return padLeft + (index / Math.max(len - 1, 1)) * plotW;
3300
+ var ts = Number(tsRaw || 0);
3301
+ if (ts < startMs) ts = startMs;
3302
+ if (ts > endMs) ts = endMs;
3303
+ return padLeft + ((ts - startMs) / (endMs - startMs)) * plotW;
3304
+ }
3305
+ function yFor(vRaw) {
3306
+ var v = Number(vRaw || 0);
3307
+ return padTop + (1 - ((v - minY) / (maxY - minY))) * plotH;
3308
+ }
3309
+
3310
+ var normalizedSeries = series.slice(0, 6).map(function(s, idx) {
3311
+ var name = metricSeriesLabel(s, idx);
3312
+ var color = palette[idx % palette.length];
3313
+ var points = (s.points || []).map(function(p, i) {
3314
+ var ts = Number(p.timestampMs || 0);
3315
+ var value = Number(p.value || 0);
3316
+ return { timestampMs: ts, value: value, x: xFor(ts, i, (s.points || []).length), y: yFor(value) };
3317
+ });
3318
+ var byTs = {};
3319
+ points.forEach(function(p) { byTs[String(p.timestampMs)] = p.value; });
3320
+ return { name: name, color: color, points: points, byTs: byTs };
3321
+ });
3322
+
3323
+ var tsMap = {};
3324
+ normalizedSeries.forEach(function(s) {
3325
+ s.points.forEach(function(p) { tsMap[String(p.timestampMs)] = true; });
3326
+ });
3327
+ var timeline = Object.keys(tsMap).map(function(k) { return Number(k); }).filter(function(v) { return Number.isFinite(v); }).sort(function(a, b) { return a - b; });
3328
+ var rows = timeline.map(function(ts) {
3329
+ return {
3330
+ timestampMs: ts,
3331
+ x: xFor(ts, 0, 1),
3332
+ values: normalizedSeries.map(function(s) {
3333
+ var has = Object.prototype.hasOwnProperty.call(s.byTs, String(ts));
3334
+ return { name: s.name, color: s.color, value: has ? Number(s.byTs[String(ts)]) : null };
3335
+ })
3336
+ };
3337
+ });
3338
+
3339
+ var panelId = 'panel-' + (++metricsPanelSeq);
3340
+ metricsPanelStore[panelId] = {
3341
+ width: width,
3342
+ height: height,
3343
+ padLeft: padLeft,
3344
+ padRight: padRight,
3345
+ padTop: padTop,
3346
+ padBottom: padBottom,
3347
+ plotW: plotW,
3348
+ plotH: plotH,
3349
+ rows: rows,
3350
+ minY: minY,
3351
+ maxY: maxY
3352
+ };
3353
+
3354
+ var h = '';
3355
+ h += '<div class="metrics-panel">';
3356
+ h += '<div class="metrics-panel-head">';
3357
+ h += '<div class="metrics-panel-title">' + esc(metricName) + '</div>';
3358
+ h += '<div class="metrics-panel-sub">' + normalizedSeries.length + ' series</div>';
3359
+ h += '</div>';
3360
+ h += '<div class="metrics-panel-chart" data-panel-id="' + panelId + '">';
3361
+ h += '<svg class="metrics-panel-svg" viewBox="0 0 ' + width + ' ' + height + '">';
3362
+ for (var yi = 0; yi <= yTickCount; yi++) {
3363
+ var ratioY = yi / yTickCount;
3364
+ var y = padTop + ratioY * plotH;
3365
+ var yVal = maxY - ratioY * (maxY - minY);
3366
+ h += '<line x1="' + padLeft + '" y1="' + y.toFixed(1) + '" x2="' + (width - padRight) + '" y2="' + y.toFixed(1) + '" stroke="var(--border)" stroke-width="1" opacity="' + (yi === yTickCount ? '1' : '.55') + '" />';
3367
+ h += '<text x="' + (padLeft - 8) + '" y="' + (y + 4).toFixed(1) + '" text-anchor="end" font-size="10" fill="var(--muted)">' + esc(fmtNum(yVal)) + '</text>';
3368
+ }
3369
+ for (var xi = 0; xi <= xTickCount; xi++) {
3370
+ var ratioX = xi / xTickCount;
3371
+ var x = padLeft + ratioX * plotW;
3372
+ var tickTs = useTimeX ? Math.round(startMs + ratioX * (endMs - startMs)) : 0;
3373
+ h += '<line x1="' + x.toFixed(1) + '" y1="' + padTop + '" x2="' + x.toFixed(1) + '" y2="' + (height - padBottom) + '" stroke="var(--border)" stroke-width="1" opacity=".35" />';
3374
+ h += '<text x="' + x.toFixed(1) + '" y="' + (height - 8) + '" text-anchor="middle" font-size="10" fill="var(--muted)">' + esc(useTimeX ? formatPanelTimeLabel(tickTs) : '') + '</text>';
3375
+ }
3376
+ h += '<line x1="' + padLeft + '" y1="' + (height - padBottom) + '" x2="' + (width - padRight) + '" y2="' + (height - padBottom) + '" stroke="var(--border)" stroke-width="1" />';
3377
+ h += '<line x1="' + padLeft + '" y1="' + padTop + '" x2="' + padLeft + '" y2="' + (height - padBottom) + '" stroke="var(--border)" stroke-width="1" />';
3378
+
3379
+ normalizedSeries.forEach(function(s) {
3380
+ var pts = (s.points || []).map(function(p) {
3381
+ return p.x.toFixed(1) + ',' + p.y.toFixed(1);
3382
+ }).join(' ');
3383
+ if (!pts) return;
3384
+ h += '<polyline fill="none" stroke="' + s.color + '" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" points="' + pts + '"></polyline>';
3385
+ });
3386
+ h += '</svg>';
3387
+ h += '<div class="metrics-panel-overlay" aria-hidden="true"></div>';
3388
+ h += '<div class="metrics-panel-marker"></div>';
3389
+ normalizedSeries.forEach(function(s, idx) {
3390
+ h += '<div class="metrics-panel-point" data-i="' + idx + '" style="background:' + s.color + ';"></div>';
3391
+ });
3392
+ h += '<div class="metrics-panel-tip"></div>';
3393
+ h += '</div>';
3394
+ h += '<div class="metrics-panel-legend">';
3395
+ normalizedSeries.forEach(function(s) {
3396
+ h += '<div class="metrics-panel-legend-item"><span class="metrics-panel-dot" style="background:' + s.color + '"></span>' + esc(s.name) + '</div>';
3397
+ });
3398
+ h += '</div>';
3399
+ h += '</div>';
3400
+ return h;
3401
+ }
3402
+
1960
3403
  function buildMetricCard(title, subtitle, metricName, series, unit, timeDomain) {
1961
3404
  var points = (series && series.points) || [];
1962
3405
  var latest = points.length ? Number(points[points.length - 1].value || 0) : 0;
1963
- var metricLabel = metricName ? ('Metric: ' + esc(metricName)) : 'Metric: unavailable';
3406
+ var metricLabel = metricName ? (esc(tr('Metric', '指标')) + ': ' + esc(metricName)) : (esc(tr('Metric', '指标')) + ': ' + esc(tr('unavailable', '不可用')));
1964
3407
  var valueLabel = points.length ? (fmtNum(latest) + (unit || '')) : '-';
1965
3408
  var h = '<div class="an-card">';
1966
3409
  h += '<h3><span class="icon">📊</span> ' + esc(title) + '<span class="sub">' + esc(valueLabel) + '</span></h3>';
@@ -1976,7 +3419,7 @@ function buildMetricCard(title, subtitle, metricName, series, unit, timeDomain)
1976
3419
 
1977
3420
  function buildMetricSparkline(points, timeDomain) {
1978
3421
  if (!points || !points.length) {
1979
- return '<div class="empty" style="padding:16px"><div class="text">No points</div></div>';
3422
+ return '<div class="empty" style="padding:16px"><div class="text">' + esc(tr('No points', '暂无数据点')) + '</div></div>';
1980
3423
  }
1981
3424
  var width = 860;
1982
3425
  var height = 220;
@@ -1986,6 +3429,7 @@ function buildMetricSparkline(points, timeDomain) {
1986
3429
  var maxY = Math.max.apply(null, points.map(function(p) { return Number(p.value || 0); }));
1987
3430
  if (!Number.isFinite(minY)) minY = 0;
1988
3431
  if (!Number.isFinite(maxY)) maxY = 1;
3432
+ minY = 0;
1989
3433
  if (minY === maxY) maxY = minY + 1;
1990
3434
  var plotW = width - padX * 2;
1991
3435
  var plotH = height - padY * 2;
@@ -2046,7 +3490,7 @@ function buildMetricSparkline(points, timeDomain) {
2046
3490
  html += '<div class="spark-marker"></div>';
2047
3491
  html += '<div class="spark-dot"></div>';
2048
3492
  html += '<div class="spark-tip"></div>';
2049
- html += '<div class="kpi-sub">Latest point at ' + esc(latestLabel) + ' • Range: ' + fmtNum(minY) + ' ~ ' + fmtNum(maxY) + '</div>';
3493
+ html += '<div class="kpi-sub">' + esc(tr('Latest point at', '最新点时间')) + ' ' + esc(latestLabel) + ' • ' + esc(tr('Range', '范围')) + ': ' + fmtNum(minY) + ' ~ ' + fmtNum(maxY) + '</div>';
2050
3494
  html += '</div>';
2051
3495
  return html;
2052
3496
  }
@@ -2112,7 +3556,7 @@ function sparklineMove(ev, overlay) {
2112
3556
  dot.style.top = (10 + (point.y / data.height) * rect.height) + 'px';
2113
3557
 
2114
3558
  var t = point.timestampMs ? new Date(point.timestampMs).toLocaleString() : '-';
2115
- tip.innerHTML = '<div><b>Time:</b> ' + esc(t) + '</div><div><b>Value:</b> ' + fmtNum(point.value) + '</div>';
3559
+ tip.innerHTML = '<div><b>' + esc(t('time')) + ':</b> ' + esc(t) + '</div><div><b>' + esc(tr('Value', '值')) + ':</b> ' + fmtNum(point.value) + '</div>';
2116
3560
  tip.style.display = 'block';
2117
3561
  var tipLeft = 10 + (point.x / data.width) * rect.width - 120;
2118
3562
  tipLeft = Math.max(10, Math.min(rect.width - 250, tipLeft));
@@ -2131,11 +3575,114 @@ function sparklineLeave(overlay) {
2131
3575
  if (tip) tip.style.display = 'none';
2132
3576
  }
2133
3577
 
3578
+ function metricsPanelMove(ev, overlay) {
3579
+ if (!overlay) return;
3580
+ var chart = overlay.closest('.metrics-panel-chart');
3581
+ if (!chart) return;
3582
+ var panelId = chart.getAttribute('data-panel-id') || '';
3583
+ var data = metricsPanelStore[panelId];
3584
+ if (!data || !data.rows || !data.rows.length) return;
3585
+
3586
+ var rect = overlay.getBoundingClientRect();
3587
+ if (rect.width < 2 || rect.height < 2) return;
3588
+
3589
+ var clientX = 0;
3590
+ var clientY = 0;
3591
+ if (ev && ev.touches && ev.touches.length) {
3592
+ clientX = ev.touches[0].clientX;
3593
+ clientY = ev.touches[0].clientY;
3594
+ } else if (ev && ev.clientX != null) {
3595
+ clientX = ev.clientX;
3596
+ clientY = ev.clientY != null ? ev.clientY : 0;
3597
+ } else {
3598
+ return;
3599
+ }
3600
+ var edge = 2;
3601
+ if (clientX < rect.left - edge || clientX > rect.right + edge || clientY < rect.top - edge || clientY > rect.bottom + edge) {
3602
+ metricsPanelLeave(overlay);
3603
+ return;
3604
+ }
3605
+
3606
+ var localX = clientX - rect.left;
3607
+ var chartX = Math.max(data.padLeft, Math.min(data.width - data.padRight, localX * (data.width / Math.max(rect.width, 1))));
3608
+
3609
+ var row = null;
3610
+ var bestD = Infinity;
3611
+ for (var i = 0; i < data.rows.length; i++) {
3612
+ var d = Math.abs(data.rows[i].x - chartX);
3613
+ if (d < bestD) {
3614
+ bestD = d;
3615
+ row = data.rows[i];
3616
+ }
3617
+ }
3618
+ if (!row) return;
3619
+
3620
+ var marker = chart.querySelector('.metrics-panel-marker');
3621
+ var tip = chart.querySelector('.metrics-panel-tip');
3622
+ if (!marker || !tip) return;
3623
+
3624
+ var markerX = 8 + (row.x / data.width) * rect.width;
3625
+ marker.style.display = 'block';
3626
+ marker.style.left = markerX + 'px';
3627
+
3628
+ var lines = ['<div><b>Time:</b> ' + esc(formatPanelTimeLabel(row.timestampMs)) + '</div>'];
3629
+ var points = chart.querySelectorAll('.metrics-panel-point');
3630
+ for (var j = 0; j < points.length; j++) {
3631
+ var node = points[j];
3632
+ var v = row.values[j];
3633
+ if (!v || v.value == null) {
3634
+ node.style.display = 'none';
3635
+ continue;
3636
+ }
3637
+ var yRatio = (v.value - data.minY) / Math.max((data.maxY - data.minY), 1e-9);
3638
+ var y = data.padTop + (1 - yRatio) * data.plotH;
3639
+ node.style.display = 'block';
3640
+ node.style.left = (8 + (row.x / data.width) * rect.width) + 'px';
3641
+ node.style.top = (8 + (y / data.height) * rect.height) + 'px';
3642
+ lines.push('<div><span style="display:inline-block;width:8px;height:8px;border-radius:999px;background:' + esc(v.color) + ';margin-right:6px"></span>' + esc(v.name) + ': <b>' + esc(fmtNum(v.value)) + '</b></div>');
3643
+ }
3644
+
3645
+ tip.innerHTML = lines.join('');
3646
+ tip.style.display = 'block';
3647
+ var tipLeft = markerX + 10;
3648
+ var maxLeft = Math.max(8, rect.width - 300);
3649
+ if (tipLeft > maxLeft) tipLeft = markerX - 270;
3650
+ tip.style.left = Math.max(8, tipLeft) + 'px';
3651
+ }
3652
+
3653
+ function metricsPanelLeave(overlay) {
3654
+ if (!overlay) return;
3655
+ var chart = overlay.closest('.metrics-panel-chart');
3656
+ if (!chart) return;
3657
+ var marker = chart.querySelector('.metrics-panel-marker');
3658
+ var tip = chart.querySelector('.metrics-panel-tip');
3659
+ if (marker) marker.style.display = 'none';
3660
+ if (tip) tip.style.display = 'none';
3661
+ var points = chart.querySelectorAll('.metrics-panel-point');
3662
+ for (var i = 0; i < points.length; i++) {
3663
+ points[i].style.display = 'none';
3664
+ }
3665
+ }
3666
+
2134
3667
  async function renderMetrics() {
2135
- app.innerHTML = renderLayout('metrics', '<div class="loading">Loading metrics...</div>');
3668
+ app.innerHTML = renderLayout('metrics', '<div class="loading">' + esc(t('loading_metrics')) + '</div>');
2136
3669
 
2137
3670
  try {
2138
- var overview = await fetchApi('/metrics/overview?minutes=' + metricsRangeMinutes + '&limit=200');
3671
+ ensureMetricsTimeRangeDefaults();
3672
+ metricsPanelSeq = 0;
3673
+ metricsPanelStore = {};
3674
+ var mtf = getMetricsTimeFilter();
3675
+ var startMsForStep = mtf.timeFrom ? Date.parse(mtf.timeFrom) : (Date.now() - metricsRangeMinutes * 60 * 1000);
3676
+ var endMsForStep = mtf.timeTo ? Date.parse(mtf.timeTo) : Date.now();
3677
+ if (!Number.isFinite(startMsForStep)) startMsForStep = Date.now() - metricsRangeMinutes * 60 * 1000;
3678
+ if (!Number.isFinite(endMsForStep)) endMsForStep = Date.now();
3679
+ if (endMsForStep <= startMsForStep) endMsForStep = startMsForStep + 60 * 1000;
3680
+ var effectiveStepSec = computeAutoStepSec(startMsForStep, endMsForStep);
3681
+ metricsStepSec = effectiveStepSec;
3682
+ var overviewQs = '/metrics/overview?minutes=' + metricsRangeMinutes + '&limit=200';
3683
+ if (mtf.timeFrom) overviewQs += '&timeFrom=' + encodeURIComponent(mtf.timeFrom);
3684
+ if (mtf.timeTo) overviewQs += '&timeTo=' + encodeURIComponent(mtf.timeTo);
3685
+ var overview = await fetchApi(overviewQs);
2139
3686
 
2140
3687
  var rawItems = overview && overview.items != null ? overview.items : [];
2141
3688
  var items = Array.isArray(rawItems) ? rawItems.slice() : [];
@@ -2147,7 +3694,7 @@ async function renderMetrics() {
2147
3694
  if (overview && overview.enabled === false && !hasCatalogSignal) {
2148
3695
  var otlp = overview.otlpMetricsEndpoint || (overview.otlpPath ? overview.otlpPath + '/v1/metrics' : '-');
2149
3696
  var disabledHtml = '';
2150
- disabledHtml += '<div class="section-title">Metrics <span class="count">Disabled</span></div>';
3697
+ disabledHtml += '<div class="section-title">' + esc(t('nav_metrics')) + ' <span class="count">' + esc(t('metrics_disabled')) + '</span></div>';
2151
3698
  disabledHtml += '<div class="metrics-callout">';
2152
3699
  disabledHtml += '<div class="metrics-callout-title">OTLP metrics ingest is off</div>';
2153
3700
  disabledHtml += '<div class="metrics-callout-body">Enable metrics in the plugin config and point your OpenTelemetry exporter at the OTLP HTTP endpoint below.</div>';
@@ -2193,14 +3740,14 @@ async function renderMetrics() {
2193
3740
  for (var fi = 0; fi < metricNeedFetch.length; fi += FETCH_CHUNK) {
2194
3741
  var nameChunk = metricNeedFetch.slice(fi, fi + FETCH_CHUNK);
2195
3742
  await Promise.all(nameChunk.map(async function(name) {
2196
- metricSeriesMap[name] = await fetchMetricSeriesSafe(name, metricsRangeMinutes, metricsStepSec);
3743
+ metricSeriesMap[name] = await fetchMetricSeriesSafe(name, metricsRangeMinutes, effectiveStepSec, true, mtf.timeFrom, mtf.timeTo);
2197
3744
  }));
2198
3745
  }
2199
3746
 
2200
- var msgProcessedRate = deriveCounterRateSeries((metricSeriesMap[important.messageProcessed] || { points: [] }).points, metricsStepSec, 60);
2201
- var tokenRate = deriveCounterRateSeries((metricSeriesMap[important.tokenTotal] || { points: [] }).points, metricsStepSec, 60);
2202
- var outputTokenRate = deriveCounterRateSeries((metricSeriesMap[important.tokenOutput] || { points: [] }).points, metricsStepSec, 60);
2203
- var costRate = deriveCounterRateSeries((metricSeriesMap[important.costUsd] || { points: [] }).points, metricsStepSec, 60);
3747
+ var msgProcessedRate = deriveCounterRateSeries((metricSeriesMap[important.messageProcessed] || { points: [] }).points, effectiveStepSec, 60);
3748
+ var tokenRate = deriveCounterRateSeries((metricSeriesMap[important.tokenTotal] || { points: [] }).points, effectiveStepSec, 60);
3749
+ var outputTokenRate = deriveCounterRateSeries((metricSeriesMap[important.tokenOutput] || { points: [] }).points, effectiveStepSec, 60);
3750
+ var costRate = deriveCounterRateSeries((metricSeriesMap[important.costUsd] || { points: [] }).points, effectiveStepSec, 60);
2204
3751
  var latencyAvg = deriveMeanFromCounterSeries(
2205
3752
  (metricSeriesMap[important.durationSum] || { points: [] }).points,
2206
3753
  (metricSeriesMap[important.durationCount] || { points: [] }).points
@@ -2220,110 +3767,545 @@ async function renderMetrics() {
2220
3767
  itemsDisplay.sort(function(a, b) { return String(a.metricName || '').localeCompare(String(b.metricName || '')); });
2221
3768
  }
2222
3769
 
2223
- var html = '';
2224
- html += '<div class="section-title">Metrics <span class="count">' + fmtNum(overview.totalMetrics || 0) + ' metrics</span></div>';
3770
+ var html = '';
3771
+ html += '<div class="section-title">' + esc(t('nav_metrics')) + ' <span class="count">' + fmtNum(overview.totalMetrics || 0) + ' ' + esc(tr('metrics', '个指标')) + '</span></div>';
3772
+
3773
+ if (!items.length && !(overview.totalSamples > 0)) {
3774
+ html += '<div class="metrics-hint">' + esc(tr('No samples in the selected window. After OTLP metrics are ingested, use Refresh or widen the time range.', '当前时间范围无样本。待 OTLP 指标上报后,可点击刷新或扩大时间范围。')) + '</div>';
3775
+ }
3776
+
3777
+ if (overview.lastError) {
3778
+ html += '<div class="empty" style="margin-bottom:12px"><div class="text">' + esc(tr('Ingest warning', '写入告警')) + ': ' + esc(String(overview.lastError)) + '</div></div>';
3779
+ }
3780
+
3781
+ html += '<div class="metrics-toolbar">';
3782
+ html += '<div class="toolbar-field"><span class="toolbar-label">Time window</span>';
3783
+ html += '<div class="time-dropdown">';
3784
+ html += '<button class="time-btn" onclick="metricsToggleRangeMenu(event)" aria-label="Metrics time window">';
3785
+ html += esc(getMetricsWindowLabel());
3786
+ html += ' <svg viewBox="0 0 24 24" style="width:12px;height:12px"><polyline points="6 9 12 15 18 9"/></svg>';
3787
+ html += '</button>';
3788
+ html += '<div class="time-menu" id="metrics-time-menu">';
3789
+ METRICS_WINDOW_OPTIONS.forEach(function(m) {
3790
+ var cls = (metricsRangeMinutes === m) ? ' active' : '';
3791
+ html += '<div class="time-menu-item' + cls + '" onclick="metricsSelectRange(\\'' + m + '\\')"><span class="check">' + (metricsRangeMinutes===m ? '✓' : '') + '</span>' + m + ' min</div>';
3792
+ });
3793
+ html += '</div></div></div>';
3794
+ html += '<div class="toolbar-field"><span class="toolbar-label">' + esc(t('start')) + '</span><input class="time-input" type="datetime-local" id="m-time-from" value="' + esc(toLocalDateTimeInputValue(metricsTimeFrom)) + '"></div>';
3795
+ html += '<div class="toolbar-field"><span class="toolbar-label">' + esc(t('end')) + '</span><input class="time-input" type="datetime-local" id="m-time-to" value="' + esc(toLocalDateTimeInputValue(metricsTimeTo)) + '"></div>';
3796
+ html += '<button type="button" class="btn-apply" onclick="metricsApplyTimeInputs()" title="' + esc(t('apply_time_range')) + '">' + ICON_CHECK + '</button>';
3797
+ html += '<button type="button" class="icon-refresh-btn" onclick="renderMetrics()" title="' + esc(t('refresh_metrics')) + '">' + ICON_REFRESH + '</button>';
3798
+ html += '</div>';
3799
+
3800
+ var chartNowMs = Date.now();
3801
+ var startMs = mtf.timeFrom ? Date.parse(mtf.timeFrom) : (chartNowMs - metricsRangeMinutes * 60 * 1000);
3802
+ var endMs = mtf.timeTo ? Date.parse(mtf.timeTo) : chartNowMs;
3803
+ if (!Number.isFinite(startMs)) startMs = chartNowMs - metricsRangeMinutes * 60 * 1000;
3804
+ if (!Number.isFinite(endMs)) endMs = chartNowMs;
3805
+ if (endMs <= startMs) endMs = startMs + Math.max(effectiveStepSec, 30) * 1000;
3806
+ var metricsChartDomain = { startMs: startMs, endMs: endMs };
3807
+ var featuredNames = itemsDisplay.slice(0, 4).map(function(m) { return String(m.metricName || ''); }).filter(Boolean);
3808
+ var featuredDetailMap = {};
3809
+ if (featuredNames.length > 0) {
3810
+ await Promise.all(featuredNames.map(async function(name) {
3811
+ featuredDetailMap[name] = await fetchMetricSeriesSafe(name, metricsRangeMinutes, effectiveStepSec, false, mtf.timeFrom, mtf.timeTo);
3812
+ }));
3813
+ }
3814
+
3815
+ var panelBlocks = featuredNames.map(function(name) {
3816
+ var detail = featuredDetailMap[name];
3817
+ return buildMetricsPanel(name, detail, metricsChartDomain);
3818
+ }).filter(function(s) { return !!s; });
3819
+ if (panelBlocks.length > 0) {
3820
+ html += '<div class="metrics-panel-grid">';
3821
+ panelBlocks.forEach(function(block) { html += block; });
3822
+ html += '</div>';
3823
+ }
3824
+
3825
+ /* KPI charts: importance order; omit cards with no drawable series */
3826
+ var kpiCards = [];
3827
+ if (important.tokenTotal && metricSeriesRenderable(tokenRate)) {
3828
+ kpiCards.push(buildMetricCard('Tokens / min', 'Total token throughput', important.tokenTotal, { points: tokenRate }, ' /min', metricsChartDomain));
3829
+ }
3830
+ if (important.tokenOutput && metricSeriesRenderable(outputTokenRate)) {
3831
+ kpiCards.push(buildMetricCard('Output Tokens / min', 'Assistant output rate', important.tokenOutput, { points: outputTokenRate }, ' /min', metricsChartDomain));
3832
+ }
3833
+ if (important.messageProcessed && metricSeriesRenderable(msgProcessedRate)) {
3834
+ kpiCards.push(buildMetricCard('Messages / min', 'Processed throughput', important.messageProcessed, { points: msgProcessedRate }, ' /min', metricsChartDomain));
3835
+ }
3836
+ if (important.costUsd && metricSeriesRenderable(costRate)) {
3837
+ kpiCards.push(buildMetricCard('Cost / min', 'USD burn rate', important.costUsd, { points: costRate }, ' USD/min', metricsChartDomain));
3838
+ }
3839
+ if (important.durationSum && important.durationCount && metricSeriesRenderable(latencyAvg)) {
3840
+ kpiCards.push(buildMetricCard('Avg Message Duration', 'From histogram sum ÷ count', important.durationSum + ' + ' + important.durationCount, { points: latencyAvg }, ' ms', metricsChartDomain));
3841
+ }
3842
+ if (important.queueDepth && metricSeriesRenderable(queueDepthSeries)) {
3843
+ kpiCards.push(buildMetricCard('Queue Depth', 'Current queue pressure', important.queueDepth, { points: queueDepthSeries }, '', metricsChartDomain));
3844
+ }
3845
+ if (important.contextSum && important.contextCount && metricSeriesRenderable(contextAvg)) {
3846
+ kpiCards.push(buildMetricCard('Context Tokens / msg', 'Average context usage', important.contextSum + ' + ' + important.contextCount, { points: contextAvg }, '', metricsChartDomain));
3847
+ }
3848
+
3849
+ html += '<div class="metrics-kpi-grid">';
3850
+ if (!kpiCards.length) {
3851
+ html += '<div class="an-card"><div class="kpi-sub">' + esc(tr('No KPI charts for the current catalog (missing counters or not enough buckets in this window). See per-metric series and the table below.', '当前指标目录暂无可绘制 KPI(可能缺少 counter 或窗口桶数量不足)。请查看下方按指标时间序列与表格。')) + '</div></div>';
3852
+ } else {
3853
+ kpiCards.forEach(function(block) { html += block; });
3854
+ }
3855
+ html += '</div>';
3856
+
3857
+ html += '<div class="section-title" style="margin-top:8px">' + esc(tr('Time series', '时间序列')) + ' <span class="count">' + fmtNum(itemsDisplay.length) + ' ' + esc(tr('metrics', '个指标')) + '</span></div>';
3858
+ html += '<div class="metrics-series-grid">';
3859
+ itemsDisplay.forEach(function(m) {
3860
+ var nm = m.metricName;
3861
+ var enc = encodeURIComponent(nm);
3862
+ var s = metricSeriesMap[nm] || { points: [], metricType: m.metricType || 'untyped' };
3863
+ html += '<div class="an-card" data-spark-metric="' + enc + '">';
3864
+ html += '<h3><span class="icon">📈</span> ' + esc(nm) + '<span class="sub">' + esc(s.metricType || m.metricType || 'untyped') + ' · ' + effectiveStepSec + 's buckets</span></h3>';
3865
+ html += buildMetricSparkline((s && s.points) || [], metricsChartDomain);
3866
+ html += '</div>';
3867
+ });
3868
+ html += '</div>';
3869
+
3870
+ html += '<div class="an-card">';
3871
+ html += '<h3><span class="icon">🧾</span> ' + esc(t('latest_metrics_snapshot')) + ' <span class="sub">' + esc(tr('sum across labels · click a row to jump to its chart', '按 label 聚合求和 · 点击行可跳转到对应图表')) + '</span></h3>';
3872
+ html += '<div class="metrics-snapshot-tools">';
3873
+ html += '<input type="search" placeholder="' + esc(t('filter_by_metric_name')) + '" aria-label="' + esc(t('filter_by_metric_name')) + '" value="' + esc(metricsSnapshotFilter) + '" oninput="metricsTableFilterInput(this.value)" />';
3874
+ html += '</div>';
3875
+ html += '<div class="an-table-scroll"><table class="an-table" id="metrics-snapshot-table">';
3876
+ html += '<thead><tr><th>Metric</th><th>Type</th><th>Latest Value</th><th>Latest Timestamp</th><th>Samples</th></tr></thead>';
3877
+ html += '<tbody id="metrics-snapshot-tbody">';
3878
+ itemsDisplay.forEach(function(m) {
3879
+ var enc = encodeURIComponent(m.metricName);
3880
+ html += '<tr class="metrics-row' + (m.metricName===metricsSelected?' metrics-row-active':'') + '" data-m="' + enc + '" role="button" tabindex="0" aria-label="Jump to chart for ' + esc(m.metricName) + '" title="Jump to chart (click or Enter)">';
3881
+ html += '<td class="mono">' + esc(m.metricName) + '</td>';
3882
+ html += '<td>' + esc(m.metricType || 'untyped') + '</td>';
3883
+ html += '<td class="mono">' + fmtNum(Number(m.latestValue || 0)) + '</td>';
3884
+ html += '<td class="mono">' + (m.latestTimestampMs ? fmtTime(m.latestTimestampMs) : '-') + '</td>';
3885
+ html += '<td class="mono">' + fmtNum(Number(m.samples || 0)) + '</td>';
3886
+ html += '</tr>';
3887
+ });
3888
+ html += '</tbody></table></div>';
3889
+ html += '</div>';
3890
+
3891
+ app.innerHTML = renderLayout('metrics', html);
3892
+ requestAnimationFrame(function() { metricsTableFilterInput(metricsSnapshotFilter); });
3893
+ } catch(err) {
3894
+ app.innerHTML = renderLayout('metrics',
3895
+ '<div class="empty"><div class="icon">⚠️</div><div class="text">' + esc(t('failed_load')) + esc(String(err)) + '</div></div>');
3896
+ }
3897
+ }
3898
+
3899
+ async function ensureReplayProviders() {
3900
+ if (Array.isArray(replayProvidersCache) && replayProvidersCache.length > 0) return replayProvidersCache;
3901
+ var data = await fetchApi('/llm/providers');
3902
+ var providers = data && Array.isArray(data.providers) ? data.providers : [];
3903
+ replayProvidersCache = providers.slice();
3904
+ if (!replayState.providerId && providers.length > 0) {
3905
+ replayState.providerId = String(providers[0].providerId || '');
3906
+ }
3907
+ if (!replayState.model) {
3908
+ var p0 = providers[0];
3909
+ if (p0 && Array.isArray(p0.models) && p0.models.length > 0) {
3910
+ replayState.model = String(p0.models[0].id || '');
3911
+ }
3912
+ }
3913
+ return replayProvidersCache;
3914
+ }
3915
+
3916
+ function getReplayProviderModels(providerId) {
3917
+ var providers = Array.isArray(replayProvidersCache) ? replayProvidersCache : [];
3918
+ var provider = providers.find(function(p) { return String(p.providerId || '') === String(providerId || ''); });
3919
+ if (!provider || !Array.isArray(provider.models)) return [];
3920
+ return provider.models.slice();
3921
+ }
3922
+
3923
+ function replayExtractText(output) {
3924
+ if (!output || typeof output !== 'object') return '';
3925
+ if (typeof output.text === 'string' && output.text.trim()) return output.text.trim();
3926
+ if (Array.isArray(output.assistantTexts)) {
3927
+ var list = output.assistantTexts.filter(function(x){ return typeof x === 'string' && x.trim(); });
3928
+ if (list.length) return list.join('\\n\\n');
3929
+ }
3930
+ if (Array.isArray(output.content)) {
3931
+ var c = output.content
3932
+ .map(function(p){
3933
+ if (typeof p === 'string') return p;
3934
+ if (!p || typeof p !== 'object') return '';
3935
+ if (typeof p.text === 'string') return p.text;
3936
+ return '';
3937
+ })
3938
+ .filter(Boolean);
3939
+ if (c.length) return c.join('\\n\\n');
3940
+ }
3941
+ return '';
3942
+ }
3943
+
3944
+ function replayExtractTextFromContent(content) {
3945
+ if (typeof content === 'string') return content;
3946
+ if (!Array.isArray(content)) return '';
3947
+ var out = [];
3948
+ for (var i = 0; i < content.length; i++) {
3949
+ var part = content[i];
3950
+ if (typeof part === 'string') {
3951
+ if (part.trim()) out.push(part.trim());
3952
+ continue;
3953
+ }
3954
+ if (!part || typeof part !== 'object') continue;
3955
+ var t = String(part.type || '');
3956
+ var txt = typeof part.text === 'string' ? part.text : '';
3957
+ if ((t === 'text' || t === 'input_text' || t === 'output_text') && txt.trim()) {
3958
+ out.push(txt.trim());
3959
+ }
3960
+ }
3961
+ return out.join('\\n\\n').trim();
3962
+ }
3963
+
3964
+ function replayNormalizePrompt(raw) {
3965
+ var s = String(raw || '');
3966
+ if (!s) return '';
3967
+ s = s.replace(/^\\s*Sender \\(untrusted metadata\\):\\s*(?:\\x60\\x60\\x60json[\\s\\S]*?\\x60\\x60\\x60)\\s*/i, '');
3968
+ s = s.replace(/^\\s*\\[[^\\]]+\\]\\s*/i, '');
3969
+ s = s.trim();
3970
+ if (/^A new session was started via \\/new or \\/reset\\./i.test(s)) return '';
3971
+ return s;
3972
+ }
3973
+
3974
+ function replayExtractUserPromptFromInput(input) {
3975
+ if (!input || typeof input !== 'object') return '';
3976
+ var direct = '';
3977
+ if (typeof input.userMessage === 'string') direct = input.userMessage;
3978
+ else if (typeof input.prompt === 'string') direct = input.prompt;
3979
+ direct = replayNormalizePrompt(direct);
3980
+ if (direct) return direct;
3981
+
3982
+ var hist = input.historyMessages;
3983
+ if (Array.isArray(hist)) {
3984
+ for (var i = hist.length - 1; i >= 0; i--) {
3985
+ var msg = hist[i];
3986
+ if (!msg || typeof msg !== 'object') continue;
3987
+ var role = String(msg.role || '').toLowerCase();
3988
+ if (role !== 'user') continue;
3989
+ var txt = replayExtractTextFromContent(msg.content);
3990
+ txt = replayNormalizePrompt(txt);
3991
+ if (txt) return txt;
3992
+ }
3993
+ }
3994
+ return '';
3995
+ }
3996
+
3997
+ function isReplayableAction(a) {
3998
+ if (!a || typeof a !== 'object') return false;
3999
+ var actionType = String(a.action_type || '');
4000
+ var actionName = String(a.action_name || '');
4001
+ return (
4002
+ (actionType === 'message' && actionName.indexOf('llm_call:') === 0)
4003
+ || (actionType === 'replay' && actionName.indexOf('replay_call:') === 0)
4004
+ );
4005
+ }
4006
+
4007
+ window.openReplayFromAction = function(fi) {
4008
+ var spans = window.__traceSpans || [];
4009
+ if (fi == null || fi < 0 || fi >= spans.length) return;
4010
+ var span = spans[fi];
4011
+ if (!span || !span.action) return;
4012
+ var a = span.action;
4013
+ if (!isReplayableAction(a)) return;
4014
+ var input = parseJson(a.input_params);
4015
+ var output = parseJson(a.output_result);
4016
+ var modelName = String(a.model_name || '');
4017
+ var providerId = '';
4018
+ var model = modelName;
4019
+ if (modelName.indexOf('/') > 0) {
4020
+ providerId = modelName.split('/')[0];
4021
+ model = modelName.slice(providerId.length + 1);
4022
+ }
4023
+ replayState.providerId = providerId || replayState.providerId || '';
4024
+ replayState.model = String(model || replayState.model || '').trim();
4025
+ replayState.systemPrompt = input && typeof input.systemPrompt === 'string' ? input.systemPrompt : '';
4026
+ replayState.userPrompt = replayExtractUserPromptFromInput(input);
4027
+ replayState.replayInput = (input && typeof input === 'object') ? input : null;
4028
+ replayState.replayInputText = replayState.replayInput ? JSON.stringify(replayState.replayInput, null, 2) : '';
4029
+ replayState.replayInputError = '';
4030
+ replayState.error = '';
4031
+ replayState.result = null;
4032
+ replayState.sourceSessionId = String(a.session_id || currentTraceSessionId || '');
4033
+ replayState.sourceObservationId = String(a.__observation_id || a.id || '');
4034
+ replayState.sourceActionName = String(a.action_name || '');
4035
+ replayState.sourceCreatedAt = String(a.created_at || '');
4036
+ replayState.baselineText = replayExtractText(output);
4037
+ replayState.baselinePromptTokens = a.prompt_tokens != null ? Number(a.prompt_tokens) : (output && output.usage ? Number(output.usage.promptTokens || 0) : null);
4038
+ replayState.baselineCompletionTokens = a.completion_tokens != null ? Number(a.completion_tokens) : (output && output.usage ? Number(output.usage.completionTokens || 0) : null);
4039
+ replayState.baselineLatencyMs = a.duration_ms != null ? Number(a.duration_ms) : null;
4040
+ location.hash = '#/replay';
4041
+ };
4042
+
4043
+ window.replayChangeProvider = function(value) {
4044
+ replayState.providerId = String(value || '');
4045
+ var models = getReplayProviderModels(replayState.providerId);
4046
+ if (!models.some(function(m) { return String(m.id || '') === String(replayState.model || ''); })) {
4047
+ replayState.model = models.length > 0 ? String(models[0].id || '') : '';
4048
+ }
4049
+ replayState.error = '';
4050
+ replayState.result = null;
4051
+ renderReplay();
4052
+ };
4053
+
4054
+ window.replayChangeModel = function(value) {
4055
+ replayState.model = String(value || '').trim();
4056
+ };
4057
+
4058
+ function getReplayInputObject() {
4059
+ if (replayState.replayInput && typeof replayState.replayInput === 'object' && !Array.isArray(replayState.replayInput)) {
4060
+ return replayState.replayInput;
4061
+ }
4062
+ var raw = String(replayState.replayInputText || '').trim();
4063
+ if (!raw) return {};
4064
+ try {
4065
+ var parsed = JSON.parse(raw);
4066
+ if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) return parsed;
4067
+ } catch {}
4068
+ return {};
4069
+ }
4070
+
4071
+ function syncReplayInputObject(nextObj) {
4072
+ replayState.replayInput = nextObj;
4073
+ replayState.replayInputText = JSON.stringify(nextObj, null, 2);
4074
+ replayState.replayInputError = '';
4075
+ }
4076
+
4077
+ window.replayEditTopLevelValue = function(index, kind, value) {
4078
+ var keys = Array.isArray(window.__replayTopLevelKeys) ? window.__replayTopLevelKeys : [];
4079
+ var i = Number(index);
4080
+ if (!Number.isInteger(i) || i < 0 || i >= keys.length) return;
4081
+ var key = keys[i];
4082
+ if (typeof key !== 'string' || !key) return;
4083
+
4084
+ var obj = getReplayInputObject();
4085
+ try {
4086
+ if (kind === 'string') {
4087
+ obj[key] = String(value || '');
4088
+ } else if (kind === 'number') {
4089
+ var n = Number(value);
4090
+ if (!Number.isFinite(n)) throw new Error('invalid number');
4091
+ obj[key] = n;
4092
+ } else if (kind === 'boolean') {
4093
+ obj[key] = !!value;
4094
+ } else if (kind === 'json') {
4095
+ var parsed = JSON.parse(String(value || ''));
4096
+ obj[key] = parsed;
4097
+ } else if (kind === 'null') {
4098
+ obj[key] = null;
4099
+ }
4100
+ } catch (err) {
4101
+ replayState.replayInputError = String(err && err.message ? err.message : err);
4102
+ return;
4103
+ }
4104
+
4105
+ syncReplayInputObject(obj);
4106
+ if (key === 'systemPrompt' || key === 'extraSystemPrompt') {
4107
+ replayState.systemPrompt = typeof obj.systemPrompt === 'string'
4108
+ ? obj.systemPrompt
4109
+ : (typeof obj.extraSystemPrompt === 'string' ? obj.extraSystemPrompt : replayState.systemPrompt);
4110
+ }
4111
+ if (key === 'userMessage' || key === 'prompt' || key === 'historyMessages' || key === 'messages') {
4112
+ replayState.userPrompt = replayExtractUserPromptFromInput(obj);
4113
+ }
4114
+ };
4115
+
4116
+ window.replayChangeReplayInput = function(value) {
4117
+ replayState.replayInputText = String(value || '');
4118
+ replayState.replayInputError = '';
4119
+ var raw = replayState.replayInputText.trim();
4120
+ if (!raw) {
4121
+ replayState.replayInput = null;
4122
+ return;
4123
+ }
4124
+ try {
4125
+ var parsed = JSON.parse(raw);
4126
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
4127
+ replayState.replayInputError = tr('Replay input must be a JSON object', '回放参数必须是 JSON 对象');
4128
+ return;
4129
+ }
4130
+ replayState.replayInput = parsed;
4131
+ } catch (err) {
4132
+ replayState.replayInputError = String(err && err.message ? err.message : err);
4133
+ }
4134
+ };
4135
+
4136
+ window.replayChangeNumber = function(field, value) {
4137
+ if (field === 'temperature') replayState.temperature = String(value || '');
4138
+ if (field === 'maxTokens') replayState.maxTokens = String(value || '');
4139
+ };
2225
4140
 
2226
- if (!items.length && !(overview.totalSamples > 0)) {
2227
- html += '<div class="metrics-hint">No samples in the selected window. After OTLP metrics are ingested, use <strong>Refresh</strong> or widen the time range.</div>';
2228
- }
4141
+ window.replayRun = async function() {
4142
+ if (replayState.running) return;
4143
+ replayState.error = '';
4144
+ replayState.result = null;
4145
+ var model = String(replayState.model || '').trim();
4146
+ var replayInputObj = (replayState.replayInput && typeof replayState.replayInput === 'object')
4147
+ ? replayState.replayInput
4148
+ : null;
4149
+ var userPrompt = replayInputObj
4150
+ ? String(replayExtractUserPromptFromInput(replayInputObj) || '').trim()
4151
+ : String(replayState.userPrompt || '').trim();
4152
+ var systemPrompt = replayInputObj
4153
+ ? (typeof replayInputObj.systemPrompt === 'string'
4154
+ ? replayInputObj.systemPrompt
4155
+ : (typeof replayInputObj.extraSystemPrompt === 'string' ? replayInputObj.extraSystemPrompt : replayState.systemPrompt))
4156
+ : replayState.systemPrompt;
4157
+ if (!model) {
4158
+ replayState.error = tr('Model is required', '模型不能为空');
4159
+ renderReplay();
4160
+ return;
4161
+ }
4162
+ if (!userPrompt) {
4163
+ replayState.error = tr('Prompt is required', 'Prompt 不能为空');
4164
+ renderReplay();
4165
+ return;
4166
+ }
4167
+ if (replayState.replayInputError) {
4168
+ replayState.error = t('replay_full_input_invalid') + ': ' + replayState.replayInputError;
4169
+ renderReplay();
4170
+ return;
4171
+ }
4172
+ replayState.running = true;
4173
+ renderReplay();
4174
+ try {
4175
+ var payload = {
4176
+ providerId: replayState.providerId || undefined,
4177
+ model: model,
4178
+ systemPrompt: systemPrompt || undefined,
4179
+ userPrompt: userPrompt,
4180
+ replayInput: replayState.replayInput && typeof replayState.replayInput === 'object' ? replayState.replayInput : undefined,
4181
+ temperature: replayState.temperature === '' ? undefined : Number(replayState.temperature),
4182
+ maxTokens: replayState.maxTokens === '' ? undefined : Number(replayState.maxTokens),
4183
+ };
4184
+ replayState.result = await postApi('/llm/replay', payload);
4185
+ } catch (err) {
4186
+ replayState.error = String(err && err.message ? err.message : err);
4187
+ } finally {
4188
+ replayState.running = false;
4189
+ renderReplay();
4190
+ }
4191
+ };
2229
4192
 
2230
- if (overview.lastError) {
2231
- html += '<div class="empty" style="margin-bottom:12px"><div class="text">Ingest warning: ' + esc(String(overview.lastError)) + '</div></div>';
4193
+ async function renderReplay() {
4194
+ app.innerHTML = renderLayout('replay', '<div class="loading">' + esc(t('loading_replay')) + '</div>');
4195
+ try {
4196
+ await ensureReplayProviders();
4197
+ var providers = Array.isArray(replayProvidersCache) ? replayProvidersCache : [];
4198
+ var models = getReplayProviderModels(replayState.providerId);
4199
+ var html = '';
4200
+ html += '<div class="section-title">' + esc(t('replay_title')) + ' <span class="count">' + esc(t('replay_subtitle')) + '</span></div>';
4201
+ html += '<div class="metrics-callout">';
4202
+ html += '<div class="metrics-callout-title">' + esc(tr('Run one-shot prompt replay inside plugin', '在插件内执行一次性 Prompt 回放')) + '</div>';
4203
+ html += '<div class="metrics-callout-body">' + esc(tr('This call is isolated and will not write back to active chat sessions. Useful for prompt iteration and side-by-side comparison.', '此调用是隔离执行,不会写回正在进行中的会话。适合做 Prompt 迭代与效果对比。')) + '</div>';
4204
+ if (replayState.sourceSessionId) {
4205
+ var traceLink = '#/trace/' + encodeURIComponent(replayState.sourceSessionId);
4206
+ html += '<div class="metrics-callout-body" style="margin-top:8px">' + esc(tr('Source trace', '来源链路')) + ': ';
4207
+ html += '<a href="' + traceLink + '" style="color:var(--accent);text-decoration:none">' + esc(String(replayState.sourceSessionId)) + '</a>';
4208
+ if (replayState.sourceCreatedAt) {
4209
+ html += ' · ' + esc(tr('Captured at', '采集时间')) + ': ' + esc(fmtTime(replayState.sourceCreatedAt));
4210
+ }
4211
+ html += '</div>';
2232
4212
  }
4213
+ html += '</div>';
2233
4214
 
2234
- html += '<div class="metrics-toolbar">';
2235
- html += '<div class="toolbar-field"><span class="toolbar-label">Time window</span>';
2236
- html += '<select class="metrics-select" aria-label="Metrics time window" onchange="metricsRangeChange(this.value)">';
2237
- [30,60,180,360,720,1440].forEach(function(m) {
2238
- html += '<option value="' + m + '"' + (metricsRangeMinutes===m?' selected':'') + '>' + m + ' min</option>';
4215
+ html += '<div class="an-card">';
4216
+ html += '<div class="replay-toolbar">';
4217
+ html += '<div class="toolbar-field"><span class="toolbar-label">' + esc(tr('Provider', '服务商')) + '</span><select class="replay-input" onchange="replayChangeProvider(this.value)">';
4218
+ providers.forEach(function(p) {
4219
+ var id = String(p.providerId || '');
4220
+ html += '<option value="' + esc(id) + '"' + (id === replayState.providerId ? ' selected' : '') + '>' + esc(id) + '</option>';
2239
4221
  });
2240
4222
  html += '</select></div>';
2241
- html += '<div class="toolbar-field"><span class="toolbar-label">Chart bucket</span>';
2242
- html += '<select class="metrics-select" aria-label="Series bucket size" onchange="metricsStepChange(this.value)">';
2243
- [15,30,60,120,300].forEach(function(s) {
2244
- html += '<option value="' + s + '"' + (metricsStepSec===s?' selected':'') + '>' + s + 's</option>';
4223
+ html += '<div class="toolbar-field"><span class="toolbar-label">' + esc(tr('Model', '模型')) + '</span><input class="replay-input" list="replay-model-list" value="' + esc(replayState.model || '') + '" oninput="replayChangeModel(this.value)" placeholder="qwen3.5-plus"></div>';
4224
+ html += '<datalist id="replay-model-list">';
4225
+ models.forEach(function(m) {
4226
+ html += '<option value="' + esc(String(m.id || '')) + '">' + esc(String(m.name || m.id || '')) + '</option>';
2245
4227
  });
2246
- html += '</select></div>';
2247
- html += '<button type="button" class="icon-refresh-btn" onclick="renderMetrics()" title="Refresh metrics">' + ICON_REFRESH + '</button>';
4228
+ html += '</datalist>';
4229
+ html += '<div class="toolbar-field"><span class="toolbar-label">Temperature</span><input class="replay-input" type="number" step="0.1" min="0" max="2" value="' + esc(replayState.temperature || '') + '" oninput="replayChangeNumber(\\'temperature\\',this.value)" placeholder="' + esc(tr('(optional)', '(可选)')) + '"></div>';
4230
+ html += '<div class="toolbar-field"><span class="toolbar-label">' + esc(tr('Max tokens', '最大 Token')) + '</span><input class="replay-input" type="number" min="1" max="32768" value="' + esc(replayState.maxTokens || '') + '" oninput="replayChangeNumber(\\'maxTokens\\',this.value)" placeholder="' + esc(tr('(optional)', '(可选)')) + '"></div>';
2248
4231
  html += '</div>';
2249
4232
 
2250
- var chartNowMs = Date.now();
2251
- var metricsChartDomain = { startMs: chartNowMs - metricsRangeMinutes * 60 * 1000, endMs: chartNowMs };
2252
-
2253
- /* KPI charts: importance order; omit cards with no drawable series */
2254
- var kpiCards = [];
2255
- if (important.tokenTotal && metricSeriesRenderable(tokenRate)) {
2256
- kpiCards.push(buildMetricCard('Tokens / min', 'Total token throughput', important.tokenTotal, { points: tokenRate }, ' /min', metricsChartDomain));
2257
- }
2258
- if (important.tokenOutput && metricSeriesRenderable(outputTokenRate)) {
2259
- kpiCards.push(buildMetricCard('Output Tokens / min', 'Assistant output rate', important.tokenOutput, { points: outputTokenRate }, ' /min', metricsChartDomain));
2260
- }
2261
- if (important.messageProcessed && metricSeriesRenderable(msgProcessedRate)) {
2262
- kpiCards.push(buildMetricCard('Messages / min', 'Processed throughput', important.messageProcessed, { points: msgProcessedRate }, ' /min', metricsChartDomain));
2263
- }
2264
- if (important.costUsd && metricSeriesRenderable(costRate)) {
2265
- kpiCards.push(buildMetricCard('Cost / min', 'USD burn rate', important.costUsd, { points: costRate }, ' USD/min', metricsChartDomain));
2266
- }
2267
- if (important.durationSum && important.durationCount && metricSeriesRenderable(latencyAvg)) {
2268
- kpiCards.push(buildMetricCard('Avg Message Duration', 'From histogram sum ÷ count', important.durationSum + ' + ' + important.durationCount, { points: latencyAvg }, ' ms', metricsChartDomain));
4233
+ html += '<div class="toolbar-field" style="margin-top:12px">';
4234
+ html += '<span class="toolbar-label">' + esc(t('replay_full_input')) + '</span>';
4235
+ var replayInputObj = getReplayInputObject();
4236
+ var replayTopKeys = Object.keys(replayInputObj || {});
4237
+ window.__replayTopLevelKeys = replayTopKeys;
4238
+ if (replayTopKeys.length > 0) {
4239
+ html += '<div class="replay-kv-list">';
4240
+ replayTopKeys.forEach(function(k, idx) {
4241
+ var v = replayInputObj[k];
4242
+ var type = (v === null) ? 'null' : (Array.isArray(v) ? 'array' : typeof v);
4243
+ html += '<div class="replay-kv-item">';
4244
+ html += '<div class="replay-kv-key">' + esc(String(k)) + '</div>';
4245
+ if (type === 'string') {
4246
+ html += '<textarea class="replay-kv-primitive" style="min-height:80px" oninput="replayEditTopLevelValue(' + idx + ',\\'string\\',this.value)">' + esc(String(v)) + '</textarea>';
4247
+ } else if (type === 'number') {
4248
+ html += '<input class="replay-kv-primitive" type="number" value="' + esc(String(v)) + '" oninput="replayEditTopLevelValue(' + idx + ',\\'number\\',this.value)">';
4249
+ } else if (type === 'boolean') {
4250
+ html += '<label class="replay-kv-summary"><input type="checkbox" ' + (v ? 'checked' : '') + ' onchange="replayEditTopLevelValue(' + idx + ',\\'boolean\\',this.checked)"> ' + esc(tr('Boolean value', '布尔值')) + '</label>';
4251
+ } else if (type === 'null') {
4252
+ html += '<div class="replay-kv-summary">' + esc(tr('null value', 'null 值')) + '</div>';
4253
+ } else {
4254
+ var jsonTxt = '';
4255
+ try { jsonTxt = JSON.stringify(v, null, 2); } catch { jsonTxt = String(v); }
4256
+ html += '<details>';
4257
+ html += '<summary class="replay-kv-summary">' + esc(tr('Show value', '显示值')) + ' (' + esc(String(jsonTxt.length)) + ' chars)</summary>';
4258
+ html += '<textarea class="replay-kv-json" oninput="replayEditTopLevelValue(' + idx + ',\\'json\\',this.value)">' + esc(jsonTxt) + '</textarea>';
4259
+ html += '</details>';
4260
+ }
4261
+ html += '</div>';
4262
+ });
4263
+ html += '</div>';
4264
+ } else {
4265
+ html += '<div class="replay-caption">' + esc(tr('No top-level fields found in replay input.', '回放参数里没有可展示的顶层字段。')) + '</div>';
2269
4266
  }
2270
- if (important.queueDepth && metricSeriesRenderable(queueDepthSeries)) {
2271
- kpiCards.push(buildMetricCard('Queue Depth', 'Current queue pressure', important.queueDepth, { points: queueDepthSeries }, '', metricsChartDomain));
4267
+ html += '<details class="replay-raw-details">';
4268
+ html += '<summary>' + esc(tr('Raw JSON editor', '原始 JSON 编辑器')) + '</summary>';
4269
+ html += '<textarea class="replay-area" style="min-height:220px;font-family:var(--mono);font-size:12px" oninput="replayChangeReplayInput(this.value)" placeholder="{\\n \\"historyMessages\\": []\\n}">' + esc(replayState.replayInputText || '') + '</textarea>';
4270
+ html += '</details>';
4271
+ html += '<div class="replay-caption">' + esc(t('replay_full_input_hint')) + '</div>';
4272
+ if (replayState.replayInputError) {
4273
+ html += '<div class="replay-caption" style="color:var(--danger)">' + esc(t('replay_full_input_invalid')) + ': ' + esc(replayState.replayInputError) + '</div>';
2272
4274
  }
2273
- if (important.contextSum && important.contextCount && metricSeriesRenderable(contextAvg)) {
2274
- kpiCards.push(buildMetricCard('Context Tokens / msg', 'Average context usage', important.contextSum + ' + ' + important.contextCount, { points: contextAvg }, '', metricsChartDomain));
4275
+ html += '</div>';
4276
+ html += '<div class="replay-actions">';
4277
+ html += '<button class="replay-run" onclick="replayRun()" ' + (replayState.running ? 'disabled' : '') + '>' + (replayState.running ? esc(t('replay_running')) : esc(t('run_replay'))) + '</button>';
4278
+ html += '</div>';
4279
+ if (replayState.error) {
4280
+ html += '<div class="empty" style="margin-bottom:10px"><div class="text">' + esc(replayState.error) + '</div></div>';
2275
4281
  }
2276
-
2277
- html += '<div class="metrics-kpi-grid">';
2278
- if (!kpiCards.length) {
2279
- html += '<div class="an-card"><div class="kpi-sub">No KPI charts for the current catalog (missing counters or not enough buckets in this window). See per-metric series and the table below.</div></div>';
4282
+ if (replayState.result) {
4283
+ var r = replayState.result;
4284
+ html += '<div class="replay-meta">' + esc(tr('Provider', '服务商')) + ': ' + esc(String(r.providerId || '-')) + ' · ' + esc(tr('Model', '模型')) + ': ' + esc(String(r.model || '-')) + ' · ' + esc(tr('Latency', '耗时')) + ': ' + fmtDur(Number(r.latencyMs || 0)) + '</div>';
4285
+ html += '<div class="replay-meta" style="margin-top:4px;margin-bottom:8px">' + esc(tr('Tokens', 'Token')) + ': prompt ' + fmtNum(Number((r.usage && r.usage.promptTokens) || 0)) + ' · completion ' + fmtNum(Number((r.usage && r.usage.completionTokens) || 0)) + '</div>';
4286
+ var baselinePrompt = replayState.baselinePromptTokens != null ? Number(replayState.baselinePromptTokens) : 0;
4287
+ var baselineCompletion = replayState.baselineCompletionTokens != null ? Number(replayState.baselineCompletionTokens) : 0;
4288
+ var nowPrompt = Number((r.usage && r.usage.promptTokens) || 0);
4289
+ var nowCompletion = Number((r.usage && r.usage.completionTokens) || 0);
4290
+ var tokenDelta = (nowPrompt + nowCompletion) - (baselinePrompt + baselineCompletion);
4291
+ var latencyDelta = replayState.baselineLatencyMs != null ? (Number(r.latencyMs || 0) - Number(replayState.baselineLatencyMs || 0)) : null;
4292
+ html += '<div class="replay-caption">';
4293
+ html += esc(tr('Delta', '差异')) + ': ';
4294
+ html += esc(tr('tokens', 'Token')) + ' ' + (tokenDelta >= 0 ? '+' : '') + fmtNum(tokenDelta) + ' · ';
4295
+ html += esc(tr('latency', '耗时')) + ' ' + (latencyDelta == null ? '-' : ((latencyDelta >= 0 ? '+' : '') + fmtDur(latencyDelta)));
4296
+ html += '</div>';
4297
+ html += '<div class="replay-compare">';
4298
+ html += '<div><div class="replay-caption">' + esc(tr('Baseline output (from trace)', '基线输出(来自 Trace)')) + '</div><div class="replay-result">' + esc(String(replayState.baselineText || '')) + '</div></div>';
4299
+ html += '<div><div class="replay-caption">' + esc(tr('Replay output (current config)', '回放输出(当前配置)')) + '</div><div class="replay-result">' + esc(String(r.text || '')) + '</div></div>';
4300
+ html += '</div>';
2280
4301
  } else {
2281
- kpiCards.forEach(function(block) { html += block; });
4302
+ html += '<div class="replay-result" style="color:var(--muted)">' + esc(t('no_replay_result')) + '</div>';
2282
4303
  }
2283
4304
  html += '</div>';
2284
-
2285
- html += '<div class="section-title" style="margin-top:8px">Time series <span class="count">' + fmtNum(itemsDisplay.length) + ' metrics</span></div>';
2286
- itemsDisplay.forEach(function(m) {
2287
- var nm = m.metricName;
2288
- var enc = encodeURIComponent(nm);
2289
- var s = metricSeriesMap[nm] || { points: [], metricType: m.metricType || 'untyped' };
2290
- html += '<div class="an-card" style="margin-bottom:16px" data-spark-metric="' + enc + '">';
2291
- html += '<h3><span class="icon">📈</span> ' + esc(nm) + '<span class="sub">' + esc(s.metricType || m.metricType || 'untyped') + ' · ' + metricsStepSec + 's buckets</span></h3>';
2292
- html += buildMetricSparkline((s && s.points) || [], metricsChartDomain);
2293
- html += '</div>';
2294
- });
2295
-
2296
- html += '<div class="an-card">';
2297
- html += '<h3><span class="icon">🧾</span> Latest Metrics Snapshot <span class="sub">sum across labels · click a row to jump to its chart</span></h3>';
2298
- html += '<div class="metrics-snapshot-tools">';
2299
- html += '<input type="search" placeholder="Filter by metric name…" aria-label="Filter metrics table" value="' + esc(metricsSnapshotFilter) + '" oninput="metricsTableFilterInput(this.value)" />';
2300
- html += '<div class="toolbar-field"><span class="toolbar-label">Sort</span>';
2301
- html += '<select class="metrics-select" aria-label="Sort metrics table" onchange="metricsSortChange(this.value)">';
2302
- html += '<option value="name"' + (metricsTableSort==='name'?' selected':'') + '>Name (A–Z)</option>';
2303
- html += '<option value="samples"' + (metricsTableSort==='samples'?' selected':'') + '>Samples (high → low)</option>';
2304
- html += '<option value="recent"' + (metricsTableSort==='recent'?' selected':'') + '>Latest time</option>';
2305
- html += '</select></div></div>';
2306
- html += '<div class="an-table-scroll"><table class="an-table" id="metrics-snapshot-table">';
2307
- html += '<thead><tr><th>Metric</th><th>Type</th><th>Latest Value</th><th>Latest Timestamp</th><th>Samples</th></tr></thead>';
2308
- html += '<tbody id="metrics-snapshot-tbody">';
2309
- itemsDisplay.forEach(function(m) {
2310
- var enc = encodeURIComponent(m.metricName);
2311
- html += '<tr class="metrics-row' + (m.metricName===metricsSelected?' metrics-row-active':'') + '" data-m="' + enc + '" role="button" tabindex="0" aria-label="Jump to chart for ' + esc(m.metricName) + '" title="Jump to chart (click or Enter)">';
2312
- html += '<td class="mono">' + esc(m.metricName) + '</td>';
2313
- html += '<td>' + esc(m.metricType || 'untyped') + '</td>';
2314
- html += '<td class="mono">' + fmtNum(Number(m.latestValue || 0)) + '</td>';
2315
- html += '<td class="mono">' + (m.latestTimestampMs ? fmtTime(m.latestTimestampMs) : '-') + '</td>';
2316
- html += '<td class="mono">' + fmtNum(Number(m.samples || 0)) + '</td>';
2317
- html += '</tr>';
2318
- });
2319
- html += '</tbody></table></div>';
2320
- html += '</div>';
2321
-
2322
- app.innerHTML = renderLayout('metrics', html);
2323
- requestAnimationFrame(function() { metricsTableFilterInput(metricsSnapshotFilter); });
2324
- } catch(err) {
2325
- app.innerHTML = renderLayout('metrics',
2326
- '<div class="empty"><div class="icon">⚠️</div><div class="text">Failed to load metrics: ' + esc(String(err)) + '</div></div>');
4305
+ app.innerHTML = renderLayout('replay', html);
4306
+ } catch (err) {
4307
+ app.innerHTML = renderLayout('replay',
4308
+ '<div class="empty"><div class="icon">⚠️</div><div class="text">' + esc(t('failed_load')) + esc(String(err)) + '</div></div>');
2327
4309
  }
2328
4310
  }
2329
4311
 
@@ -2332,14 +4314,14 @@ async function renderMetrics() {
2332
4314
  function buildTimeSeriesChart(ts, metric, gran) {
2333
4315
  gran = gran || 'day';
2334
4316
  if (!ts || ts.length === 0) {
2335
- return '<div class="empty" style="padding:24px"><div class="text">No data in selected range</div></div>';
4317
+ return '<div class="empty" style="padding:24px"><div class="text">' + esc(tr('No data in selected range', '当前时间范围无数据')) + '</div></div>';
2336
4318
  }
2337
4319
 
2338
4320
  // Metric tab switcher
2339
4321
  var h = '<div class="metric-tabs">';
2340
- h += '<div class="metric-tab' + (metric==='sessions'?' active':'') + '" onclick="anSwitchMetric(\\'sessions\\')">Sessions</div>';
2341
- h += '<div class="metric-tab' + (metric==='tokens'?' active':'') + '" onclick="anSwitchMetric(\\'tokens\\')">Tokens</div>';
2342
- h += '<div class="metric-tab' + (metric==='actions'?' active':'') + '" onclick="anSwitchMetric(\\'actions\\')">Actions</div>';
4322
+ h += '<div class="metric-tab' + (metric==='sessions'?' active':'') + '" onclick="anSwitchMetric(\\'sessions\\')">' + esc(tr('Sessions', '会话')) + '</div>';
4323
+ h += '<div class="metric-tab' + (metric==='tokens'?' active':'') + '" onclick="anSwitchMetric(\\'tokens\\')">' + esc(tr('Tokens', 'Token')) + '</div>';
4324
+ h += '<div class="metric-tab' + (metric==='actions'?' active':'') + '" onclick="anSwitchMetric(\\'actions\\')">' + esc(tr('Actions', '动作')) + '</div>';
2343
4325
  h += '</div>';
2344
4326
 
2345
4327
  // Determine values and max
@@ -2398,7 +4380,7 @@ function buildTimeSeriesChart(ts, metric, gran) {
2398
4380
  var pct = Math.max((v.total / maxVal) * 100, 1);
2399
4381
  var fullLabel = formatFullTimeLabel(v.label);
2400
4382
  h += '<div class="chart-bar-col">';
2401
- h += '<div class="chart-tooltip"><div><b>Time:</b> ' + esc(fullLabel) + '</div><div><b>Value:</b> ' + fmtNum(v.total);
4383
+ h += '<div class="chart-tooltip"><div><b>' + esc(t('time')) + ':</b> ' + esc(fullLabel) + '</div><div><b>' + esc(tr('Value', '值')) + ':</b> ' + fmtNum(v.total);
2402
4384
  if (showTokenSplit) h += ' (in:' + fmtNum(v.v1) + ' out:' + fmtNum(v.v2) + ')';
2403
4385
  h += '</div></div>';
2404
4386
 
@@ -2430,8 +4412,8 @@ function buildTimeSeriesChart(ts, metric, gran) {
2430
4412
  // Legend for token split
2431
4413
  if (showTokenSplit) {
2432
4414
  h += '<div class="chart-legend">';
2433
- h += '<div class="chart-legend-item"><div class="chart-legend-dot" style="background:#8b5cf6"></div>Input tokens</div>';
2434
- h += '<div class="chart-legend-item"><div class="chart-legend-dot" style="background:#f59e0b"></div>Output tokens</div>';
4415
+ h += '<div class="chart-legend-item"><div class="chart-legend-dot" style="background:#8b5cf6"></div>' + esc(tr('Input tokens', '输入 Token')) + '</div>';
4416
+ h += '<div class="chart-legend-item"><div class="chart-legend-dot" style="background:#f59e0b"></div>' + esc(tr('Output tokens', '输出 Token')) + '</div>';
2435
4417
  h += '</div>';
2436
4418
  }
2437
4419
 
@@ -2440,7 +4422,7 @@ function buildTimeSeriesChart(ts, metric, gran) {
2440
4422
 
2441
4423
  function buildModelUsageChart(mu, tbm) {
2442
4424
  if (!mu || mu.length === 0) {
2443
- return '<div class="empty" style="padding:24px"><div class="text">No model data</div></div>';
4425
+ return '<div class="empty" style="padding:24px"><div class="text">' + esc(tr('No model data', '暂无模型数据')) + '</div></div>';
2444
4426
  }
2445
4427
 
2446
4428
  var maxTokens = Math.max.apply(null, mu.map(function(m){ return m.inputTokens + m.outputTokens; }));
@@ -2465,13 +4447,13 @@ function buildModelUsageChart(mu, tbm) {
2465
4447
  h += '</div>';
2466
4448
 
2467
4449
  h += '<div class="chart-legend" style="margin-top:16px">';
2468
- h += '<div class="chart-legend-item"><div class="chart-legend-dot" style="background:#8b5cf6"></div>Input</div>';
2469
- h += '<div class="chart-legend-item"><div class="chart-legend-dot" style="background:#f59e0b"></div>Output</div>';
4450
+ h += '<div class="chart-legend-item"><div class="chart-legend-dot" style="background:#8b5cf6"></div>' + esc(tr('Input', '输入')) + '</div>';
4451
+ h += '<div class="chart-legend-item"><div class="chart-legend-dot" style="background:#f59e0b"></div>' + esc(tr('Output', '输出')) + '</div>';
2470
4452
  h += '</div>';
2471
4453
 
2472
4454
  // Model details table
2473
4455
  h += '<table class="an-table" style="margin-top:16px">';
2474
- h += '<tr><th>Model</th><th>Calls</th><th>Avg Latency</th><th>Tokens</th></tr>';
4456
+ h += '<tr><th>' + esc(tr('Model', '模型')) + '</th><th>' + esc(tr('Calls', '调用次数')) + '</th><th>' + esc(tr('Avg Latency', '平均耗时')) + '</th><th>' + esc(tr('Tokens', 'Token')) + '</th></tr>';
2475
4457
  mu.forEach(function(m) {
2476
4458
  var shortModel = m.model.length > 20 ? m.model.slice(m.model.indexOf('/') + 1) : m.model;
2477
4459
  h += '<tr>';
@@ -2489,7 +4471,7 @@ function buildModelUsageChart(mu, tbm) {
2489
4471
  function buildActionDistribution(ad) {
2490
4472
  var keys = Object.keys(ad);
2491
4473
  if (keys.length === 0) {
2492
- return '<div class="empty" style="padding:24px"><div class="text">No action data</div></div>';
4474
+ return '<div class="empty" style="padding:24px"><div class="text">' + esc(tr('No action data', '暂无动作数据')) + '</div></div>';
2493
4475
  }
2494
4476
 
2495
4477
  var total = keys.reduce(function(s, k) { return s + ad[k]; }, 0);
@@ -2548,11 +4530,11 @@ function buildAgentsTable(ta) {
2548
4530
  });
2549
4531
 
2550
4532
  if (!rows || rows.length === 0) {
2551
- return '<div class="empty" style="padding:24px"><div class="text">No agent data</div></div>';
4533
+ return '<div class="empty" style="padding:24px"><div class="text">' + esc(tr('No agent data', '暂无代理数据')) + '</div></div>';
2552
4534
  }
2553
4535
 
2554
4536
  var h = '<table class="an-table">';
2555
- h += '<tr><th>Agent</th><th>Sessions</th><th>Actions</th><th>Tokens</th></tr>';
4537
+ h += '<tr><th>' + esc(tr('Agent', '代理')) + '</th><th>' + esc(tr('Sessions', '会话')) + '</th><th>' + esc(tr('Actions', '动作')) + '</th><th>' + esc(tr('Tokens', 'Token')) + '</th></tr>';
2556
4538
  rows.forEach(function(a) {
2557
4539
  h += '<tr>';
2558
4540
  h += '<td>🤖 ' + esc(a.agent) + '</td>';
@@ -2635,17 +4617,48 @@ var secActiveRuleKey = 'secretLeakage';
2635
4617
  /* ---- severity helpers ---- */
2636
4618
  var SEV_ICON = {critical:'🔴',warn:'🟡',info:'ℹ️'};
2637
4619
  var SEV_LABEL = {critical:'CRITICAL',warn:'WARNING',info:'INFO'};
2638
- var CAT_LABEL = {
2639
- secret_leakage:'Secret Leakage',high_risk_operation:'High Risk Operation',
2640
- data_exfiltration:'Data Exfiltration',prompt_injection:'Prompt Injection',
2641
- custom_regex:'Custom Regex',
2642
- skill_anomaly:'Skill Anomaly'
2643
- };
2644
- var STATUS_LABEL = {open:'Open',acknowledged:'Acknowledged',resolved:'Resolved'};
2645
-
2646
- var SEV_OPTIONS = [{k:'',l:'All Severity'},{k:'critical',l:'CRITICAL'},{k:'warn',l:'WARNING'},{k:'info',l:'INFO'}];
2647
- var CAT_OPTIONS = [{k:'',l:'All Category'},{k:'secret_leakage',l:'Secret Leakage'},{k:'high_risk_operation',l:'High Risk Op'},{k:'data_exfiltration',l:'Data Exfiltration'},{k:'prompt_injection',l:'Prompt Injection'},{k:'custom_regex',l:'Custom Regex'},{k:'skill_anomaly',l:'Skill Anomaly'}];
2648
- var STA_OPTIONS = [{k:'',l:'All Status'},{k:'open',l:'Open'},{k:'acknowledged',l:'Acknowledged'},{k:'resolved',l:'Resolved'}];
4620
+ function secCategoryLabel(cat) {
4621
+ if (cat === 'secret_leakage') return tr('Secret Leakage', '密钥泄露');
4622
+ if (cat === 'high_risk_operation') return tr('High Risk Operation', '高风险操作');
4623
+ if (cat === 'data_exfiltration') return tr('Data Exfiltration', '数据外传');
4624
+ if (cat === 'prompt_injection') return tr('Prompt Injection', '提示词注入');
4625
+ if (cat === 'custom_regex') return tr('Custom Regex', '自定义规则');
4626
+ if (cat === 'skill_anomaly') return tr('Skill Anomaly', '技能异常');
4627
+ return String(cat || '-');
4628
+ }
4629
+ function secStatusLabel(status) {
4630
+ if (status === 'open') return tr('Open', '待处理');
4631
+ if (status === 'acknowledged') return tr('Acknowledged', '已确认');
4632
+ if (status === 'resolved') return tr('Resolved', '已解决');
4633
+ return String(status || '-');
4634
+ }
4635
+ function secSeverityOptions() {
4636
+ return [
4637
+ {k:'', l: tr('All Severity', '全部严重级别')},
4638
+ {k:'critical', l:'CRITICAL'},
4639
+ {k:'warn', l:'WARNING'},
4640
+ {k:'info', l:'INFO'}
4641
+ ];
4642
+ }
4643
+ function secCategoryOptions() {
4644
+ return [
4645
+ {k:'', l: tr('All Category', '全部类别')},
4646
+ {k:'secret_leakage', l: secCategoryLabel('secret_leakage')},
4647
+ {k:'high_risk_operation', l: secCategoryLabel('high_risk_operation')},
4648
+ {k:'data_exfiltration', l: secCategoryLabel('data_exfiltration')},
4649
+ {k:'prompt_injection', l: secCategoryLabel('prompt_injection')},
4650
+ {k:'custom_regex', l: secCategoryLabel('custom_regex')},
4651
+ {k:'skill_anomaly', l: secCategoryLabel('skill_anomaly')}
4652
+ ];
4653
+ }
4654
+ function secStatusOptions() {
4655
+ return [
4656
+ {k:'', l: tr('All Status', '全部状态')},
4657
+ {k:'open', l: secStatusLabel('open')},
4658
+ {k:'acknowledged', l: secStatusLabel('acknowledged')},
4659
+ {k:'resolved', l: secStatusLabel('resolved')}
4660
+ ];
4661
+ }
2649
4662
  var SEC_RULE_META = {
2650
4663
  secretLeakage: {
2651
4664
  key: 'secretLeakage',
@@ -2713,9 +4726,9 @@ function secGetTimeFromISO() {
2713
4726
  }
2714
4727
 
2715
4728
  function secGetTimeLabel() {
2716
- if (!secFilterTimeRange) return 'All time';
4729
+ if (!secFilterTimeRange) return currentLang === 'zh' ? '全部时间' : 'All time';
2717
4730
  var preset = TIME_PRESETS.find(function(p){ return p.key === secFilterTimeRange; });
2718
- return preset ? preset.label : 'All time';
4731
+ return preset ? timePresetLabel(preset) : (currentLang === 'zh' ? '全部时间' : 'All time');
2719
4732
  }
2720
4733
 
2721
4734
  function secSelectedLabel(opts, curKey) {
@@ -2733,6 +4746,80 @@ function secSafeActiveRuleKey() {
2733
4746
  if (SEC_RULE_ORDER.indexOf(secActiveRuleKey) >= 0) return secActiveRuleKey;
2734
4747
  return 'secretLeakage';
2735
4748
  }
4749
+ function secRuleCopy(ruleKey, meta) {
4750
+ if (ruleKey === 'secretLeakage') {
4751
+ return {
4752
+ title: tr('Secret Leakage', '密钥泄露'),
4753
+ brief: tr('Detect exposed AK/SK, token, password and key materials', '检测 AK/SK、Token、密码与密钥材料外泄'),
4754
+ description: tr('Scans tool input/output and model responses for secret patterns, including keys, tokens and sensitive credential strings.', '扫描工具输入输出与模型响应中的密钥模式,包括 key、token 与敏感凭证字符串。'),
4755
+ detects: [
4756
+ tr('Hardcoded access key or token values', '硬编码访问密钥或 Token'),
4757
+ tr('Credential output copied from files or env blocks', '从文件或环境变量中泄露的凭证内容')
4758
+ ]
4759
+ };
4760
+ }
4761
+ if (ruleKey === 'highRiskOps') {
4762
+ return {
4763
+ title: tr('High Risk Ops', '高风险操作'),
4764
+ brief: tr('Detect dangerous operations such as delete/reset/wipe', '检测删除/重置/清空等危险操作'),
4765
+ description: tr('Flags risky tool operations that may alter or destroy data, especially shell and file-system actions.', '标记可能导致数据被修改或删除的高风险工具操作,重点覆盖 shell 与文件系统动作。'),
4766
+ detects: [
4767
+ tr('Destructive shell commands', '破坏性 shell 命令'),
4768
+ tr('Irreversible write/remove actions', '不可逆写入/删除动作')
4769
+ ]
4770
+ };
4771
+ }
4772
+ if (ruleKey === 'dataExfiltration') {
4773
+ return {
4774
+ title: tr('External Access', '外部访问'),
4775
+ brief: tr('Detect outbound requests and potential data exfiltration', '检测外发请求与潜在数据外传'),
4776
+ description: tr('Monitors requests to external websites or domains and raises alerts when outbound access may carry sensitive data.', '监控对外部域名的访问,在可能携带敏感数据时触发告警。'),
4777
+ detects: [
4778
+ tr('Tool calls to non-whitelisted external domains', '访问非白名单外部域名的工具调用'),
4779
+ tr('Potential outbound data transfer patterns', '疑似数据外传行为模式')
4780
+ ]
4781
+ };
4782
+ }
4783
+ if (ruleKey === 'customRegex') {
4784
+ return {
4785
+ title: tr('Custom Regex', '自定义正则'),
4786
+ brief: tr('Customer-defined regex rules with live matching', '支持用户自定义正则并实时匹配'),
4787
+ description: tr('Create your own regex rules for sensitive keywords, IDs, or organization-specific policy patterns.', '可针对敏感关键词、ID 或组织内部策略模式配置自定义规则。'),
4788
+ detects: [
4789
+ tr('Custom compliance vocabulary', '自定义合规词汇'),
4790
+ tr('Business-specific sensitive identifiers', '业务特有敏感标识')
4791
+ ]
4792
+ };
4793
+ }
4794
+ if (ruleKey === 'promptInjection') {
4795
+ return {
4796
+ title: tr('Prompt Injection', '提示词注入'),
4797
+ brief: tr('Detect jailbreak or instruction override attempts', '检测越狱与指令覆盖尝试'),
4798
+ description: tr('Finds suspicious prompts attempting to override system policy, bypass guardrails or exfiltrate hidden context.', '识别尝试覆盖系统策略、绕过护栏或套取隐藏上下文的可疑提示词。'),
4799
+ detects: [
4800
+ tr('Ignore previous instructions patterns', '忽略先前指令类模式'),
4801
+ tr('Role hijacking and policy bypass phrasing', '角色劫持与策略绕过语句')
4802
+ ]
4803
+ };
4804
+ }
4805
+ if (ruleKey === 'chainDetection') {
4806
+ return {
4807
+ title: tr('Chain Detection', '链路组合检测'),
4808
+ brief: tr('Detect suspicious multi-step attack chains', '检测可疑多步骤攻击链'),
4809
+ description: tr('Correlates multiple actions in a session and raises alerts for risky sequences that are mild alone but dangerous in combination.', '关联会话内多步动作,对单步风险低但组合后危险的序列进行告警。'),
4810
+ detects: [
4811
+ tr('Recon + secret read + external write pattern', '侦察 + 读取敏感信息 + 外部写出模式'),
4812
+ tr('Repeated escalations over short window', '短时间内重复升级操作')
4813
+ ]
4814
+ };
4815
+ }
4816
+ return {
4817
+ title: String(meta && meta.title || ruleKey),
4818
+ brief: String(meta && meta.brief || ''),
4819
+ description: String(meta && meta.description || ''),
4820
+ detects: Array.isArray(meta && meta.detects) ? meta.detects.slice() : []
4821
+ };
4822
+ }
2736
4823
 
2737
4824
  /* build one custom dropdown (same look as Dashboard time picker) */
2738
4825
  function secDropdown(id, icon, opts, curKey) {
@@ -2761,6 +4848,7 @@ function buildSecurityRuntimeHtml() {
2761
4848
 
2762
4849
  var activeRuleKey = secSafeActiveRuleKey();
2763
4850
  var activeMeta = SEC_RULE_META[activeRuleKey] || SEC_RULE_META.secretLeakage;
4851
+ var activeCopy = secRuleCopy(activeRuleKey, activeMeta);
2764
4852
  var activeOn = secRuleEnabled(activeRuleKey);
2765
4853
  var enabledRules = SEC_RULE_ORDER.filter(function(k){
2766
4854
  return secRuleEnabled(k);
@@ -2771,9 +4859,9 @@ function buildSecurityRuntimeHtml() {
2771
4859
  html += '<div class="sec-runtime">';
2772
4860
  html += '<div class="sec-runtime-head">';
2773
4861
  html += '<div>';
2774
- html += '<div class="sec-runtime-title">Runtime Security Rules</div>';
4862
+ html += '<div class="sec-runtime-title">' + esc(tr('Runtime Security Rules', '运行时安全规则')) + '</div>';
2775
4863
  html += '</div>';
2776
- html += '<div class="sec-runtime-summary">' + enabledRules + '/' + SEC_RULE_ORDER.length + ' rule(s) enabled</div>';
4864
+ html += '<div class="sec-runtime-summary">' + enabledRules + '/' + SEC_RULE_ORDER.length + ' ' + esc(tr('rule(s) enabled', '条规则已启用')) + '</div>';
2777
4865
  html += '</div>';
2778
4866
 
2779
4867
  html += '<div class="sec-runtime-grid">';
@@ -2781,14 +4869,15 @@ function buildSecurityRuntimeHtml() {
2781
4869
  SEC_RULE_ORDER.forEach(function(ruleKey) {
2782
4870
  var meta = SEC_RULE_META[ruleKey];
2783
4871
  if (!meta) return;
4872
+ var copy = secRuleCopy(ruleKey, meta);
2784
4873
  var on = secRuleEnabled(ruleKey);
2785
4874
  html += '<div class="sec-rule-item' + (ruleKey === activeRuleKey ? ' active' : '') + '" onclick="secSelectRule(event,\\'' + ruleKey + '\\')">';
2786
4875
  html += '<div class="sec-rule-copy">';
2787
- html += '<div class="sec-rule-name">' + esc(meta.title) + '</div>';
2788
- html += '<div class="sec-rule-brief">' + esc(meta.brief) + '</div>';
4876
+ html += '<div class="sec-rule-name">' + esc(copy.title) + '</div>';
4877
+ html += '<div class="sec-rule-brief">' + esc(copy.brief) + '</div>';
2789
4878
  html += '</div>';
2790
- html += '<span class="sec-state-pill ' + (on ? 'on' : 'off') + '">' + (on ? 'ON' : 'OFF') + '</span>';
2791
- html += '<button class="sec-switch ' + (on ? 'on' : 'off') + '" onclick="event.stopPropagation();secToggleRuntime(\\'' + ruleKey + '\\')" aria-label="Toggle ' + esc(meta.title) + '">';
4879
+ html += '<span class="sec-state-pill ' + (on ? 'on' : 'off') + '">' + (on ? tr('ON', '开') : tr('OFF', '关')) + '</span>';
4880
+ html += '<button class="sec-switch ' + (on ? 'on' : 'off') + '" onclick="event.stopPropagation();secToggleRuntime(\\'' + ruleKey + '\\')" aria-label="' + esc(tr('Toggle', '切换')) + ' ' + esc(copy.title) + '">';
2792
4881
  html += '<span class="sec-switch-track"></span><span class="sec-switch-thumb"></span>';
2793
4882
  html += '</button>';
2794
4883
  html += '</div>';
@@ -2797,20 +4886,20 @@ function buildSecurityRuntimeHtml() {
2797
4886
 
2798
4887
  html += '<div class="sec-rule-detail">';
2799
4888
  html += '<div class="sec-rule-detail-title">';
2800
- html += '<h4>' + esc(activeMeta.title) + '</h4>';
2801
- html += '<span class="sec-state-pill ' + (activeOn ? 'on' : 'off') + '">' + (activeOn ? 'ON' : 'OFF') + '</span>';
4889
+ html += '<h4>' + esc(activeCopy.title) + '</h4>';
4890
+ html += '<span class="sec-state-pill ' + (activeOn ? 'on' : 'off') + '">' + (activeOn ? tr('ON', '开') : tr('OFF', '关')) + '</span>';
2802
4891
  html += '</div>';
2803
4892
  html += '<div class="sec-rule-detail-meta">';
2804
- html += '<span class="sec-meta-chip level-' + esc(activeMeta.level) + '">Risk ' + esc(activeMeta.level || 'unknown') + '</span>';
4893
+ html += '<span class="sec-meta-chip level-' + esc(activeMeta.level) + '">' + esc(tr('Risk', '风险')) + ' ' + esc(activeMeta.level || tr('unknown', '未知')) + '</span>';
2805
4894
  if (activeRuleKey === 'customRegex') {
2806
- html += '<span class="sec-meta-chip">Severity Fixed: CRITICAL</span>';
4895
+ html += '<span class="sec-meta-chip">' + esc(tr('Severity Fixed: CRITICAL', '严重级别固定:CRITICAL')) + '</span>';
2807
4896
  }
2808
4897
  html += '</div>';
2809
4898
  if (activeRuleKey !== 'customRegex') {
2810
- html += '<div class="sec-rule-explain"><div class="sec-rule-explain-text">' + esc(activeMeta.description || 'No description') + '</div></div>';
4899
+ html += '<div class="sec-rule-explain"><div class="sec-rule-explain-text">' + esc(activeCopy.description || tr('No description', '暂无说明')) + '</div></div>';
2811
4900
  html += '<div class="sec-rule-detail-list">';
2812
- html += '<div class="sec-rule-detail-list-title">Coverage examples</div>';
2813
- (activeMeta.detects || []).forEach(function(item){
4901
+ html += '<div class="sec-rule-detail-list-title">' + esc(tr('Coverage examples', '检测示例')) + '</div>';
4902
+ (activeCopy.detects || []).forEach(function(item){
2814
4903
  html += '<div class="sec-rule-detail-list-item">* ' + esc(item) + '</div>';
2815
4904
  });
2816
4905
  html += '</div>';
@@ -2818,22 +4907,22 @@ function buildSecurityRuntimeHtml() {
2818
4907
 
2819
4908
  if (activeRuleKey === 'customRegex') {
2820
4909
  html += '<div class="sec-custom-editor">';
2821
- html += '<div class="sec-rule-detail-list-title">Add custom regex rule</div>';
4910
+ html += '<div class="sec-rule-detail-list-title">' + esc(tr('Add custom regex rule', '新增自定义正则规则')) + '</div>';
2822
4911
  html += '<div class="sec-custom-form">';
2823
4912
  html += '<div class="sec-custom-field">';
2824
- html += '<div class="sec-custom-field-label">Rule Name</div>';
2825
- html += '<input id="sec-cr-name" type="text" placeholder="e.g. Internal Ticket ID">';
4913
+ html += '<div class="sec-custom-field-label">' + esc(tr('Rule Name', '规则名称')) + '</div>';
4914
+ html += '<input id="sec-cr-name" type="text" placeholder="' + esc(tr('e.g. Internal Ticket ID', '例如:内部工单 ID')) + '">';
2826
4915
  html += '</div>';
2827
4916
  html += '<div class="sec-custom-field">';
2828
- html += '<div class="sec-custom-field-label">Regex Pattern</div>';
2829
- html += '<input id="sec-cr-pattern" type="text" placeholder="e.g. TKT-[0-9]{6}">';
4917
+ html += '<div class="sec-custom-field-label">' + esc(tr('Regex Pattern', '正则表达式')) + '</div>';
4918
+ html += '<input id="sec-cr-pattern" type="text" placeholder="' + esc(tr('e.g. TKT-[0-9]{6}', '例如:TKT-[0-9]{6}')) + '">';
2830
4919
  html += '</div>';
2831
- html += '<button onclick="secAddCustomRegex()">+ Create Rule</button>';
4920
+ html += '<button onclick="secAddCustomRegex()">+ ' + esc(tr('Create Rule', '创建规则')) + '</button>';
2832
4921
  html += '</div>';
2833
4922
 
2834
4923
  html += '<div class="sec-custom-list">';
2835
4924
  if (customRegexRules.length === 0) {
2836
- html += '<div class="sec-rule-detail-list-item">No custom regex rules yet.</div>';
4925
+ html += '<div class="sec-rule-detail-list-item">' + esc(tr('No custom regex rules yet.', '暂无自定义正则规则。')) + '</div>';
2837
4926
  } else {
2838
4927
  customRegexRules.forEach(function(rule, idx){
2839
4928
  var on = rule.enabled !== false;
@@ -2845,7 +4934,7 @@ function buildSecurityRuntimeHtml() {
2845
4934
  html += '</div>';
2846
4935
  html += '<div class="sec-custom-item-actions">';
2847
4936
  html += '<button class="sec-switch ' + (on ? 'on' : 'off') + '" onclick="secToggleCustomRegexRule(' + idx + ')"><span class="sec-switch-track"></span><span class="sec-switch-thumb"></span></button>';
2848
- html += '<button class="sec-custom-item-del" title="Delete rule" onclick="secDeleteCustomRegexRule(' + idx + ')">Delete</button>';
4937
+ html += '<button class="sec-custom-item-del" title="' + esc(tr('Delete rule', '删除规则')) + '" onclick="secDeleteCustomRegexRule(' + idx + ')">' + esc(tr('Delete', '删除')) + '</button>';
2849
4938
  html += '</div>';
2850
4939
  html += '</div>';
2851
4940
  });
@@ -2875,7 +4964,7 @@ function renderSecurityRuntimeOnly() {
2875
4964
  }
2876
4965
 
2877
4966
  async function renderSecurity() {
2878
- app.innerHTML = renderLayout('security', '<div class="loading">Loading security alerts...</div>');
4967
+ app.innerHTML = renderLayout('security', '<div class="loading">' + esc(tr('Loading security alerts...', '加载安全告警中...')) + '</div>');
2879
4968
 
2880
4969
  try {
2881
4970
  var qs = 'page=' + secPage + '&limit=20';
@@ -2907,11 +4996,11 @@ async function renderSecurity() {
2907
4996
  var totalAlerts = alertStats.total || 0;
2908
4997
 
2909
4998
  html += '<div class="stat-grid">';
2910
- html += statCard('Total Alerts', String(totalAlerts));
4999
+ html += statCard(tr('Total Alerts', '告警总数'), String(totalAlerts));
2911
5000
  html += '<div class="stat"><div class="stat-label">Critical</div><div class="stat-value ' + (critCount>0?'val-critical':'') + '">' + critCount + '</div></div>';
2912
- html += '<div class="stat"><div class="stat-label">Warnings</div><div class="stat-value ' + (warnCount>0?'val-warn':'') + '">' + warnCount + '</div></div>';
2913
- html += '<div class="stat"><div class="stat-label">Open</div><div class="stat-value ' + (openCount>0?'val-critical':'val-ok') + '">' + openCount + '</div></div>';
2914
- html += statCard('Last 24h', String(alertStats.recent24h || 0));
5001
+ html += '<div class="stat"><div class="stat-label">' + esc(tr('Warnings', '警告')) + '</div><div class="stat-value ' + (warnCount>0?'val-warn':'') + '">' + warnCount + '</div></div>';
5002
+ html += '<div class="stat"><div class="stat-label">' + esc(secStatusLabel('open')) + '</div><div class="stat-value ' + (openCount>0?'val-critical':'val-ok') + '" id="sec-open-count">' + openCount + '</div></div>';
5003
+ html += statCard(tr('Last 24h', '近 24 小时'), String(alertStats.recent24h || 0));
2915
5004
  html += '</div>';
2916
5005
 
2917
5006
  // --- Runtime security rule toggles ---
@@ -2920,20 +5009,20 @@ async function renderSecurity() {
2920
5009
  // --- Filter bar (same .filter-bar as Dashboard) ---
2921
5010
  var hasFilter = secFilterSearch || secFilterSeverity || secFilterCategory || secFilterStatus || secFilterTimeRange;
2922
5011
  html += '<div class="filter-bar">';
2923
- html += '<input type="text" id="sec-f-search" placeholder="Search rule, finding, session..." value="' + esc(secFilterSearch) + '" onkeydown="if(event.key===\\'Enter\\')secApplyFilter()">';
5012
+ html += '<input type="text" id="sec-f-search" placeholder="' + esc(tr('Search rule, finding, session...', '搜索规则、命中内容、会话...')) + '" value="' + esc(secFilterSearch) + '" onkeydown="if(event.key===\\'Enter\\')secApplyFilter()">';
2924
5013
  html += '<span class="filter-sep"></span>';
2925
5014
 
2926
5015
  // Severity dropdown
2927
5016
  var sevIcon = '<svg viewBox="0 0 24 24" style="width:14px;height:14px;stroke:currentColor;fill:none;stroke-width:1.5"><path d="M10.29 3.86L1.82 18a2 2 0 001.71 3h16.94a2 2 0 001.71-3L13.71 3.86a2 2 0 00-3.42 0z"/></svg> ';
2928
- html += secDropdown('severity', sevIcon, SEV_OPTIONS, secFilterSeverity);
5017
+ html += secDropdown('severity', sevIcon, secSeverityOptions(), secFilterSeverity);
2929
5018
 
2930
5019
  // Category dropdown
2931
5020
  var catIcon = '<svg viewBox="0 0 24 24" style="width:14px;height:14px;stroke:currentColor;fill:none;stroke-width:1.5"><path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/></svg> ';
2932
- html += secDropdown('category', catIcon, CAT_OPTIONS, secFilterCategory);
5021
+ html += secDropdown('category', catIcon, secCategoryOptions(), secFilterCategory);
2933
5022
 
2934
5023
  // Status dropdown
2935
5024
  var staIcon = '<svg viewBox="0 0 24 24" style="width:14px;height:14px;stroke:currentColor;fill:none;stroke-width:1.5"><path d="M22 11.08V12a10 10 0 11-5.93-9.14"/><polyline points="22 4 12 14.01 9 11.01"/></svg> ';
2936
- html += secDropdown('status', staIcon, STA_OPTIONS, secFilterStatus);
5025
+ html += secDropdown('status', staIcon, secStatusOptions(), secFilterStatus);
2937
5026
 
2938
5027
  // Time range dropdown (reuse TIME_PRESETS)
2939
5028
  html += '<div class="time-dropdown" id="sec-dd-time">';
@@ -2947,13 +5036,13 @@ async function renderSecurity() {
2947
5036
  var cls = (p.key === secFilterTimeRange) ? ' active' : '';
2948
5037
  html += '<div class="time-menu-item' + cls + '" onclick="secSelect(\\'time\\',\\'' + p.key + '\\')">';
2949
5038
  html += '<span class="check">' + (p.key === secFilterTimeRange ? '✓' : '') + '</span>';
2950
- html += esc(p.label);
5039
+ html += esc(timePresetLabel(p));
2951
5040
  html += '</div>';
2952
5041
  });
2953
5042
  html += '</div></div>';
2954
5043
 
2955
5044
  if (hasFilter) {
2956
- html += '<button class="btn-clear" onclick="secClearFilter()">✕ Clear</button>';
5045
+ html += '<button class="btn-clear" onclick="secClearFilter()">✕ ' + esc(t('clear')) + '</button>';
2957
5046
  }
2958
5047
  html += '</div>';
2959
5048
 
@@ -2964,44 +5053,44 @@ async function renderSecurity() {
2964
5053
  });
2965
5054
 
2966
5055
  // --- Alert list ---
2967
- html += '<div class="section-title">Alerts <span class="count">' + alertData.total + (hasFilter ? ' matched' : ' total') + '</span></div>';
5056
+ html += '<div class="section-title">' + esc(tr('Alerts', '告警')) + ' <span class="count">' + alertData.total + (hasFilter ? (' ' + esc(t('matched'))) : (' ' + esc(t('total'))) ) + '</span></div>';
2968
5057
  html += '<div class="alert-list">';
2969
5058
 
2970
5059
  if (sortedAlerts.length === 0) {
2971
- html += '<div class="empty"><div class="icon">✅</div><div class="text">No alerts found</div></div>';
5060
+ html += '<div class="empty"><div class="icon">✅</div><div class="text">' + esc(tr('No alerts found', '未发现告警')) + '</div></div>';
2972
5061
  } else {
2973
5062
  sortedAlerts.forEach(function(a) {
2974
5063
  html += '<div class="alert-card sev-' + a.severity + '" id="alert-card-' + a.alert_id + '" onclick="toggleAlertDetail(event,\\'' + a.alert_id + '\\')">';
2975
5064
  html += '<div class="alert-top">';
2976
5065
  html += '<span class="alert-sev ' + a.severity + '">' + (SEV_ICON[a.severity]||'') + ' ' + (SEV_LABEL[a.severity]||a.severity) + '</span>';
2977
5066
  html += '<span class="alert-rule">' + esc(a.rule_name) + '</span>';
2978
- html += '<span class="alert-cat">' + esc(CAT_LABEL[a.category]||a.category) + '</span>';
2979
- html += '<span class="alert-status ' + a.status + '">' + (STATUS_LABEL[a.status]||a.status) + '</span>';
5067
+ html += '<span class="alert-cat">' + esc(secCategoryLabel(a.category)) + '</span>';
5068
+ html += '<span class="alert-status ' + a.status + '">' + esc(secStatusLabel(a.status)) + '</span>';
2980
5069
  html += '</div>';
2981
5070
  html += '<div class="alert-finding">' + esc(a.finding) + '</div>';
2982
5071
  html += '<div class="alert-meta">';
2983
- html += '<span>Rule: ' + esc(a.rule_id) + '</span>';
5072
+ html += '<span>' + esc(tr('Rule', '规则')) + ': ' + esc(a.rule_id) + '</span>';
2984
5073
  var traceLink = '#/trace/' + encodeURIComponent(a.session_id) + '?action=' + encodeURIComponent(a.action_name) + '&t=' + encodeURIComponent(a.created_at || '');
2985
- html += '<span>Session: <a href="' + traceLink + '">' + esc(a.session_id.substring(0,12)) + '…</a></span>';
2986
- html += '<span>Action: ' + esc(a.action_name) + '</span>';
5074
+ html += '<span>' + esc(tr('Session', '会话')) + ': <a href="' + traceLink + '">' + esc(a.session_id.substring(0,12)) + '…</a></span>';
5075
+ html += '<span>' + esc(tr('Action', '动作')) + ': ' + esc(a.action_name) + '</span>';
2987
5076
  html += '<span>' + fmtTime(a.created_at) + '</span>';
2988
5077
  html += '</div>';
2989
5078
 
2990
5079
  // Expandable detail area
2991
5080
  html += '<div class="alert-detail" id="alert-detail-' + a.alert_id + '" style="display:none;margin-top:12px">';
2992
5081
  html += '<div class="alert-detail-meta">';
2993
- html += '<span><b>Alert ID:</b> ' + esc(a.alert_id) + '</span>';
2994
- html += '<span><b>Agent:</b> ' + esc(a.user_id || '-') + '</span>';
2995
- html += '<span><b>Model:</b> ' + esc(a.model_name || '-') + '</span>';
5082
+ html += '<span><b>' + esc(tr('Alert ID', '告警 ID')) + ':</b> ' + esc(a.alert_id) + '</span>';
5083
+ html += '<span><b>' + esc(t('agent')) + ':</b> ' + esc(a.user_id || '-') + '</span>';
5084
+ html += '<span><b>' + esc(t('model')) + ':</b> ' + esc(a.model_name || '-') + '</span>';
2996
5085
  html += '</div>';
2997
5086
  html += '<div class="context-block">' + esc(buildAlertEvidenceText(a)) + '</div>';
2998
5087
  html += '<div class="actions-row">';
2999
- html += '<a href="' + traceLink + '" onclick="event.stopPropagation()" style="display:inline-flex;align-items:center;gap:4px;padding:6px 14px;border-radius:var(--radius-md);border:1px solid var(--accent);background:var(--accent-subtle);color:var(--accent);font-size:12px;font-weight:600;text-decoration:none">🔍 View in Trace</a>';
5088
+ html += '<a href="' + traceLink + '" onclick="event.stopPropagation()" style="display:inline-flex;align-items:center;gap:4px;padding:6px 14px;border-radius:var(--radius-md);border:1px solid var(--accent);background:var(--accent-subtle);color:var(--accent);font-size:12px;font-weight:600;text-decoration:none">🔍 ' + esc(tr('View in Trace', '在链路中查看')) + '</a>';
3000
5089
  if (a.status === 'open') {
3001
- html += '<button onclick="event.stopPropagation();updateAlertSt(\\'' + a.alert_id + '\\',\\'acknowledged\\')">👁 Acknowledge</button>';
5090
+ html += '<button onclick="event.stopPropagation();updateAlertSt(\\'' + a.alert_id + '\\',\\'acknowledged\\')">👁 ' + esc(tr('Acknowledge', '确认')) + '</button>';
3002
5091
  }
3003
5092
  if (a.status === 'open' || a.status === 'acknowledged') {
3004
- html += '<button class="btn-resolve" onclick="event.stopPropagation();updateAlertSt(\\'' + a.alert_id + '\\',\\'resolved\\')">✓ Resolve</button>';
5093
+ html += '<button class="btn-resolve" onclick="event.stopPropagation();updateAlertSt(\\'' + a.alert_id + '\\',\\'resolved\\')">✓ ' + esc(tr('Resolve', '解决')) + '</button>';
3005
5094
  }
3006
5095
  html += '</div>';
3007
5096
  html += '</div>';
@@ -3014,16 +5103,16 @@ async function renderSecurity() {
3014
5103
  // Pagination
3015
5104
  var totalPages = Math.ceil(alertData.total / 20) || 1;
3016
5105
  html += '<div class="pagination">';
3017
- html += '<button onclick="secGoPage(' + (secPage-1) + ')" ' + (secPage<=1?'disabled':'') + '>&laquo; Prev</button>';
3018
- html += '<span class="page-info">Page ' + secPage + ' / ' + totalPages + '</span>';
3019
- html += '<button onclick="secGoPage(' + (secPage+1) + ')" ' + (secPage>=totalPages?'disabled':'') + '>Next &raquo;</button>';
5106
+ html += '<button onclick="secGoPage(' + (secPage-1) + ')" ' + (secPage<=1?'disabled':'') + '>&laquo; ' + esc(t('prev')) + '</button>';
5107
+ html += '<span class="page-info">' + esc(t('page')) + ' ' + secPage + ' / ' + totalPages + '</span>';
5108
+ html += '<button onclick="secGoPage(' + (secPage+1) + ')" ' + (secPage>=totalPages?'disabled':'') + '>' + esc(t('next')) + ' &raquo;</button>';
3020
5109
  html += '</div>';
3021
5110
 
3022
5111
  app.innerHTML = renderLayout('security', html);
3023
5112
 
3024
5113
  } catch(err) {
3025
5114
  app.innerHTML = renderLayout('security',
3026
- '<div class="empty"><div class="icon">⚠️</div><div class="text">Failed to load: ' + esc(String(err)) + '</div></div>');
5115
+ '<div class="empty"><div class="icon">⚠️</div><div class="text">' + esc(t('failed_load')) + esc(String(err)) + '</div></div>');
3027
5116
  }
3028
5117
  }
3029
5118
 
@@ -3120,14 +5209,14 @@ window.secAddCustomRegex = async function() {
3120
5209
  try {
3121
5210
  new RegExp(pattern, flags || 'i');
3122
5211
  } catch (e) {
3123
- alert('Invalid regex pattern or flags');
5212
+ alert(tr('Invalid regex pattern or flags', '无效的正则表达式或标记'));
3124
5213
  return;
3125
5214
  }
3126
5215
 
3127
5216
  var nextRules = Array.isArray(secRuntimeCfg.customRegexRules) ? secRuntimeCfg.customRegexRules.slice() : [];
3128
5217
  nextRules.push({
3129
5218
  id: 'cr_' + Date.now() + '_' + Math.floor(Math.random() * 10000),
3130
- name: name || 'Custom Regex',
5219
+ name: name || tr('Custom Regex', '自定义规则'),
3131
5220
  pattern: pattern,
3132
5221
  flags: flags || 'i',
3133
5222
  severity: severity,
@@ -3215,17 +5304,10 @@ window.updateAlertSt = async function(alertId, newStatus) {
3215
5304
  window.__alertCount -= 1;
3216
5305
  }
3217
5306
 
3218
- var statNodes = document.querySelectorAll('.stat .stat-label');
3219
- for (var i = 0; i < statNodes.length; i++) {
3220
- var labelNode = statNodes[i];
3221
- if (!labelNode || !labelNode.textContent) continue;
3222
- if (labelNode.textContent.trim().toLowerCase() === 'open') {
3223
- var valueNode = labelNode.parentElement ? labelNode.parentElement.querySelector('.stat-value') : null;
3224
- if (valueNode) {
3225
- var cur = parseInt((valueNode.textContent || '0').trim(), 10);
3226
- if (!isNaN(cur) && cur > 0) valueNode.textContent = String(cur - 1);
3227
- }
3228
- }
5307
+ var openNode = document.getElementById('sec-open-count');
5308
+ if (openNode) {
5309
+ var cur = parseInt((openNode.textContent || '0').trim(), 10);
5310
+ if (!isNaN(cur) && cur > 0) openNode.textContent = String(cur - 1);
3229
5311
  }
3230
5312
  } catch(e) {
3231
5313
  console.error('Failed to update alert:', e);
@@ -3322,6 +5404,7 @@ function observationsToActions(sessionId, observations) {
3322
5404
  id: obs.observationId,
3323
5405
  __observation_id: obs.observationId,
3324
5406
  __parent_observation_id: obs.parentObservationId || null,
5407
+ parent_run_id: obs.parentRunId || null,
3325
5408
  __start_at: startAt,
3326
5409
  __end_at: endAt,
3327
5410
  session_id: sessionId,
@@ -3379,6 +5462,38 @@ function parseActionFieldJson(raw) {
3379
5462
  try { return JSON.parse(raw); } catch (_) { return null; }
3380
5463
  }
3381
5464
 
5465
+ function filterInvalidTranscriptThinking(actions) {
5466
+ var list = ensureArray(actions);
5467
+ if (!list.length) return list;
5468
+ var runToolCallIds = {};
5469
+ list.forEach(function(a) {
5470
+ if (!a) return;
5471
+ var runId = getActionRunId(a) || '';
5472
+ if (!runId) return;
5473
+ if (a.action_type !== 'tool_call' && a.action_type !== 'tool_update') return;
5474
+ var tcid = getToolCallId(a) || '';
5475
+ if (!tcid) return;
5476
+ if (!runToolCallIds[runId]) runToolCallIds[runId] = {};
5477
+ runToolCallIds[runId][tcid] = true;
5478
+ });
5479
+ return list.filter(function(a) {
5480
+ if (!a || a.action_type !== 'thinking') return true;
5481
+ var input = parseActionFieldJson(a.input_params);
5482
+ if (!input || typeof input !== 'object') return true;
5483
+ if (String(input.source || '') !== 'transcript_message') return true;
5484
+ if (!input.synthetic) return true;
5485
+ var toolCallIds = Array.isArray(input.toolCallIds) ? input.toolCallIds.map(function(v){ return String(v || ''); }).filter(Boolean) : [];
5486
+ if (!toolCallIds.length) return true;
5487
+ var runId = String(input.runId || getActionRunId(a) || '');
5488
+ if (!runId) return true;
5489
+ var known = runToolCallIds[runId] || {};
5490
+ for (var i = 0; i < toolCallIds.length; i++) {
5491
+ if (known[toolCallIds[i]]) return true;
5492
+ }
5493
+ return false;
5494
+ });
5495
+ }
5496
+
3382
5497
  async function renderTraceDetail(sessionId, highlightAction, highlightTime, opts) {
3383
5498
  opts = opts || {};
3384
5499
  selectedActionIdx = -1;
@@ -3389,7 +5504,7 @@ async function renderTraceDetail(sessionId, highlightAction, highlightTime, opts
3389
5504
  var shouldUseCache = !!opts.useCache && !!cachedTrace;
3390
5505
  if (!shouldUseCache) {
3391
5506
  app.innerHTML = renderLayout('trace',
3392
- '<div class="trace-header"><div class="loading">Loading trace...</div></div>');
5507
+ '<div class="trace-header"><div class="loading">' + esc(t('trace_detail_loading')) + '</div></div>');
3393
5508
  }
3394
5509
 
3395
5510
  try {
@@ -3416,12 +5531,28 @@ async function renderTraceDetail(sessionId, highlightAction, highlightTime, opts
3416
5531
  var sessionSnapshots = ensureArray(actions).filter(function(a) {
3417
5532
  return a && a.action_type === 'session_snapshot';
3418
5533
  });
5534
+ var agentEndActions = ensureArray(actions).filter(function(a) {
5535
+ return a && a.action_type === 'agent_end';
5536
+ });
5537
+ var latestAgentEnd = agentEndActions.length ? agentEndActions[agentEndActions.length - 1] : null;
5538
+ var traceRunState = 'running';
5539
+ if (latestAgentEnd) {
5540
+ var endPayload = parseActionFieldJson(latestAgentEnd.output_result) || {};
5541
+ traceRunState = endPayload && endPayload.success === false ? 'failed' : 'completed';
5542
+ }
3419
5543
  var timelineActions = ensureArray(actions).filter(function(a) {
3420
- return a && a.action_type !== 'session_snapshot';
5544
+ return a && a.action_type !== 'session_snapshot' && a.action_type !== 'agent_end';
3421
5545
  });
5546
+ timelineActions = filterInvalidTranscriptThinking(timelineActions);
3422
5547
  var latestSessionSnapshot = sessionSnapshots.length
3423
5548
  ? parseActionFieldJson(sessionSnapshots[sessionSnapshots.length - 1].output_result)
3424
5549
  : null;
5550
+ var traceSubagents = [];
5551
+ try {
5552
+ traceSubagents = buildTraceSubagentSummary(timelineActions);
5553
+ } catch (_) {
5554
+ traceSubagents = [];
5555
+ }
3425
5556
 
3426
5557
  if (timelineActions.length === 0) {
3427
5558
  app.innerHTML = renderLayout('trace',
@@ -3434,10 +5565,20 @@ async function renderTraceDetail(sessionId, highlightAction, highlightTime, opts
3434
5565
 
3435
5566
  // Build spans with timing
3436
5567
  var spans = timelineActions.map(function(a, idx) {
5568
+ var hasExplicitStart = !!a.__start_at;
5569
+ var hasExplicitEnd = !!a.__end_at;
3437
5570
  var startMs = new Date(a.__start_at || a.created_at).getTime();
3438
5571
  var endMs = new Date(a.__end_at || a.created_at).getTime();
3439
5572
  if (!Number.isFinite(startMs)) startMs = new Date(a.created_at).getTime();
3440
5573
  if (!Number.isFinite(endMs)) endMs = new Date(a.created_at).getTime();
5574
+ // Legacy/fallback rows (from observation_actions) only carry created_at + duration_ms.
5575
+ // In those cases created_at is effectively end-time; backfill start-time for timeline.
5576
+ if (!hasExplicitStart && !hasExplicitEnd) {
5577
+ var durMs = Number(a.duration_ms);
5578
+ if (Number.isFinite(durMs) && durMs > 0) {
5579
+ startMs = endMs - durMs;
5580
+ }
5581
+ }
3441
5582
  if (endMs < startMs) endMs = startMs;
3442
5583
  if ((a.duration_ms == null || !Number.isFinite(Number(a.duration_ms))) && endMs > startMs) {
3443
5584
  a.duration_ms = endMs - startMs;
@@ -3475,194 +5616,28 @@ async function renderTraceDetail(sessionId, highlightAction, highlightTime, opts
3475
5616
  return lv;
3476
5617
  }
3477
5618
 
3478
- function findOwnerLlmStartMs(thinkingSpan, allSpans) {
3479
- var owner = null;
3480
- for (var i = 0; i < allSpans.length; i++) {
3481
- var span = allSpans[i];
3482
- if (span.action.action_type !== 'message') continue;
3483
- if (!span.action.action_name || span.action.action_name.indexOf('llm_call:') !== 0) continue;
3484
- if (span.startMs <= thinkingSpan.endMs && span.endMs >= thinkingSpan.endMs) {
3485
- if (!owner || span.endMs < owner.endMs) owner = span;
3486
- }
3487
- }
3488
- return owner ? owner.startMs : null;
3489
- }
3490
-
3491
- // For thinking rows without explicit duration, infer a visible span from the
3492
- // previous action end to current thinking timestamp.
3493
- var spansByEndTime = ensureArray(spans).slice().sort(function(a, b) {
3494
- var diff = a.endMs - b.endMs;
3495
- if (diff !== 0) return diff;
3496
- return a.idx - b.idx;
3497
- });
3498
- var prevEndMs = null;
3499
- spansByEndTime.forEach(function(span) {
3500
- if (
3501
- span.action.action_type === 'thinking' &&
3502
- (span.action.duration_ms == null || span.action.duration_ms <= 0) &&
3503
- prevEndMs != null &&
3504
- span.endMs > prevEndMs
3505
- ) {
3506
- var ownerLlmStartMs = findOwnerLlmStartMs(span, spans);
3507
- var inferredStartMs = prevEndMs;
3508
- if (ownerLlmStartMs != null) {
3509
- inferredStartMs = Math.max(inferredStartMs, ownerLlmStartMs);
3510
- }
3511
- span.startMs = inferredStartMs;
3512
- span.displayDurationMs = span.endMs - span.startMs;
3513
- }
3514
- prevEndMs = Math.max(prevEndMs == null ? span.endMs : prevEndMs, span.endMs);
3515
- });
3516
-
3517
5619
  // Determine time range (recomputed again after tree normalization)
3518
5620
  var sessionStart = Math.min.apply(null, spans.map(function(s){ return s.startMs; }));
3519
5621
  var sessionEnd = Math.max.apply(null, spans.map(function(s){ return s.endMs; }));
3520
5622
  var totalDur = sessionEnd - sessionStart || 1;
3521
5623
 
3522
5624
  // ---- Sorting + nesting ----
3523
- // We keep the data in chronological order inside a trace. Structural span
3524
- // priority is only a tie-breaker for near-identical timestamps.
3525
- function typePriority(actionType) {
3526
- if (actionType === 'agent_end') return 0;
3527
- if (actionType === 'message') return 1;
3528
- if (actionType === 'model_resolve') return 2;
3529
- if (actionType === 'prompt_build') return 3;
3530
- if (actionType === 'tool_call') return 2;
3531
- if (actionType === 'assistant_stream') return 4;
3532
- if (actionType === 'thinking') return 5;
3533
- if (actionType === 'tool_update') return 6;
3534
- return 10;
3535
- }
3536
- function canBecomeParent(actionType) {
3537
- return actionType === 'agent_end' || actionType === 'message' || actionType === 'tool_call';
3538
- }
3539
- // Sort by startMs asc; when startMs is close (within 2s), prioritize by semantic priority; then by duration desc
5625
+ // Strict mode: frontend does not infer or rewrite timeline semantics.
5626
+ // Keep only chronological ordering from backend timestamps.
3540
5627
  ensureArray(spans).sort(function(a, b) {
3541
5628
  var startDiff = a.startMs - b.startMs;
3542
5629
  if (startDiff !== 0) return startDiff;
3543
- var priA = typePriority(a.action.action_type);
3544
- var priB = typePriority(b.action.action_type);
3545
- if (priA !== priB) return priA - priB;
3546
5630
  var endDiff = a.endMs - b.endMs;
3547
5631
  if (endDiff !== 0) return endDiff;
3548
5632
  return a.idx - b.idx;
3549
5633
  });
3550
5634
 
3551
- var toolParentsById = {};
3552
- spans.forEach(function(span) {
3553
- if (span.action.action_type === 'tool_call') {
3554
- var toolCallId = getToolCallId(span.action);
3555
- if (!toolCallId) return;
3556
- if (!toolParentsById[toolCallId]) toolParentsById[toolCallId] = [];
3557
- toolParentsById[toolCallId].push(span);
3558
- }
3559
- });
3560
- Object.keys(toolParentsById).forEach(function(id) {
3561
- toolParentsById[id].sort(function(a, b) {
3562
- var diff = a.startMs - b.startMs;
3563
- if (diff !== 0) return diff;
3564
- return a.idx - b.idx;
3565
- });
3566
- });
3567
-
3568
- function resolveExactToolParent(span, toolCallId) {
3569
- var candidates = toolParentsById[toolCallId];
3570
- if (!candidates || candidates.length === 0) return null;
3571
- var containing = null;
3572
- for (var i = 0; i < candidates.length; i++) {
3573
- var parent = candidates[i];
3574
- if (parent.startMs <= span.endMs + 50 && parent.endMs >= span.startMs - 1000) {
3575
- containing = parent;
3576
- }
3577
- }
3578
- if (containing) return containing;
3579
-
3580
- var nearest = null;
3581
- var nearestGap = Infinity;
3582
- for (var j = 0; j < candidates.length; j++) {
3583
- var p = candidates[j];
3584
- var gap = Math.abs(p.endMs - span.endMs);
3585
- if (gap < nearestGap) {
3586
- nearest = p;
3587
- nearestGap = gap;
3588
- }
3589
- }
3590
- return nearest;
3591
- }
3592
-
3593
5635
  var flatSpans = [];
3594
5636
  if (hasTreeParents) {
3595
- function inferDisplayParentId(span) {
3596
- var pid = span.action && span.action.__parent_observation_id ? String(span.action.__parent_observation_id) : '';
3597
- if (pid && spansByObsId[pid]) return pid;
3598
- if (!span.action) return '';
3599
- if (span.action.action_type !== 'tool_update') return pid;
3600
-
3601
- var runId = getActionRunId(span.action) || '';
3602
- var toolCallId = getToolCallId(span.action) || '';
3603
- var toolName = '';
3604
- var actionName = String(span.action.action_name || '');
3605
- var cidx = actionName.indexOf(':');
3606
- if (cidx >= 0 && cidx < actionName.length - 1) {
3607
- toolName = actionName.slice(cidx + 1);
3608
- }
3609
-
3610
- // Prefer exact tool_call parent by (runId, toolCallId)
3611
- if (runId && toolCallId) {
3612
- for (var i = 0; i < spans.length; i++) {
3613
- var p = spans[i];
3614
- if (!p || !p.action) continue;
3615
- if (p.action.action_type !== 'tool_call') continue;
3616
- if ((getActionRunId(p.action) || '') !== runId) continue;
3617
- if ((getToolCallId(p.action) || '') !== toolCallId) continue;
3618
- if (p.action.__observation_id) return String(p.action.__observation_id);
3619
- }
3620
- }
3621
-
3622
- // Fallback: nearest tool_call with same name (and same runId when available)
3623
- var best = null;
3624
- var bestScore = Infinity;
3625
- for (var k = 0; k < spans.length; k++) {
3626
- var tc = spans[k];
3627
- if (!tc || !tc.action || tc.action.action_type !== 'tool_call') continue;
3628
- if (runId && (getActionRunId(tc.action) || '') !== runId) continue;
3629
- if (toolName) {
3630
- var tcName = String(tc.action.action_name || '');
3631
- var tci = tcName.indexOf(':');
3632
- var tcTool = tci >= 0 && tci < tcName.length - 1 ? tcName.slice(tci + 1) : '';
3633
- if (tcTool && tcTool !== toolName) continue;
3634
- }
3635
- var score = Math.abs(span.startMs - tc.endMs);
3636
- if (score < bestScore) {
3637
- bestScore = score;
3638
- best = tc;
3639
- }
3640
- }
3641
- if (best && best.action && best.action.__observation_id) {
3642
- return String(best.action.__observation_id);
3643
- }
3644
-
3645
- // Fallback to run-level llm_call parent
3646
- if (runId) {
3647
- for (var j = 0; j < spans.length; j++) {
3648
- var lp = spans[j];
3649
- if (!lp || !lp.action) continue;
3650
- if (lp.action.action_type !== 'message') continue;
3651
- var an = String(lp.action.action_name || '');
3652
- if (an.indexOf('llm_call:') !== 0) continue;
3653
- if ((getActionRunId(lp.action) || '') !== runId) continue;
3654
- if (lp.action.__observation_id) return String(lp.action.__observation_id);
3655
- }
3656
- }
3657
-
3658
- return pid;
3659
- }
3660
-
3661
5637
  var childrenByParent = {};
3662
5638
  var roots = [];
3663
5639
  spans.forEach(function(span) {
3664
- var oid = span.action && span.action.__observation_id ? String(span.action.__observation_id) : '';
3665
- var pid = inferDisplayParentId(span);
5640
+ var pid = span.action && span.action.__parent_observation_id ? String(span.action.__parent_observation_id) : '';
3666
5641
  if (!pid || !spansByObsId[pid]) {
3667
5642
  roots.push(span);
3668
5643
  return;
@@ -3687,113 +5662,17 @@ async function renderTraceDetail(sessionId, highlightAction, highlightTime, opts
3687
5662
  kids.forEach(function(child) { dfs(child, depth + 1); });
3688
5663
  }
3689
5664
  roots.forEach(function(root) { dfs(root, 0); });
3690
- // Normalize timeline in tree mode:
3691
- // 1) parent covers children window
3692
- // 2) for llm children, thinking ends before assistant_stream starts (display order)
3693
- flatSpans.forEach(function(span) {
3694
- var oid = span.action && span.action.__observation_id ? String(span.action.__observation_id) : '';
3695
- if (!oid) return;
3696
- var kids = childrenByParent[oid] || [];
3697
- if (!kids.length) return;
3698
- var minStart = Math.min.apply(null, kids.map(function(k){ return k.startMs; }));
3699
- var maxEnd = Math.max.apply(null, kids.map(function(k){ return k.endMs; }));
3700
- if (Number.isFinite(minStart)) span.startMs = Math.min(span.startMs, minStart);
3701
- if (Number.isFinite(maxEnd)) span.endMs = Math.max(span.endMs, maxEnd);
3702
- if (span.endMs < span.startMs) span.endMs = span.startMs;
3703
-
3704
- if (span.action && span.action.action_type === 'message' && String(span.action.action_name || '').indexOf('llm_call:') === 0) {
3705
- var thinkingChildren = [];
3706
- var assistantChild = null;
3707
- var maxToolEnd = null;
3708
- kids.forEach(function(k) {
3709
- if (k.action && k.action.action_type === 'thinking') thinkingChildren.push(k);
3710
- if (k.action && k.action.action_type === 'assistant_stream' && !assistantChild) assistantChild = k;
3711
- if (k.action && (k.action.action_type === 'tool_call' || k.action.action_type === 'tool_update')) {
3712
- maxToolEnd = maxToolEnd == null ? k.endMs : Math.max(maxToolEnd, k.endMs);
3713
- }
3714
- });
3715
- var thinkingChild = null;
3716
- if (thinkingChildren.length > 0) {
3717
- if (assistantChild) {
3718
- // Use the latest thinking span before assistant starts.
3719
- // Earlier thinking fragments should keep their own timestamps.
3720
- for (var ti = 0; ti < thinkingChildren.length; ti++) {
3721
- var candidate = thinkingChildren[ti];
3722
- if (candidate.startMs <= assistantChild.startMs + 1) {
3723
- thinkingChild = candidate;
3724
- }
3725
- }
3726
- }
3727
- if (!thinkingChild) {
3728
- thinkingChild = thinkingChildren[thinkingChildren.length - 1];
3729
- }
3730
- }
3731
- if (thinkingChild && assistantChild && assistantChild.startMs < thinkingChild.endMs) {
3732
- thinkingChild.endMs = assistantChild.startMs;
3733
- if (thinkingChild.endMs < thinkingChild.startMs) {
3734
- thinkingChild.startMs = thinkingChild.endMs;
3735
- }
3736
- if (thinkingChild.action) {
3737
- thinkingChild.action.duration_ms = Math.max(0, thinkingChild.endMs - thinkingChild.startMs);
3738
- }
3739
- }
3740
- // If tools happened after thinking but before assistant, keep thinking continuous
3741
- // so timeline does not show an empty gap.
3742
- if (
3743
- thinkingChild &&
3744
- assistantChild &&
3745
- maxToolEnd != null &&
3746
- maxToolEnd >= thinkingChild.endMs - 5 &&
3747
- assistantChild.startMs > thinkingChild.endMs
3748
- ) {
3749
- thinkingChild.endMs = assistantChild.startMs;
3750
- if (thinkingChild.endMs < thinkingChild.startMs) {
3751
- thinkingChild.startMs = thinkingChild.endMs;
3752
- }
3753
- if (thinkingChild.action) {
3754
- thinkingChild.action.duration_ms = Math.max(0, thinkingChild.endMs - thinkingChild.startMs);
3755
- }
3756
- }
3757
- }
3758
- });
3759
5665
  // orphan safety
3760
5666
  spans.forEach(function(span) {
3761
5667
  if (flatSpans.indexOf(span) >= 0) return;
3762
- span.level = computeLevelFromParent(span, {});
5668
+ span.level = 0;
3763
5669
  flatSpans.push(span);
3764
5670
  });
3765
5671
  } else {
3766
- // Fallback to legacy time-based nesting
3767
- var parentStack = []; // { startMs, endMs, level, actionType }
5672
+ // Legacy rows without structured parent links: keep strict chronological list.
3768
5673
  spans.forEach(function(span) {
3769
- var exactToolParent = null;
3770
- var spanToolCallId = getToolCallId(span.action);
3771
- if (span.action.action_type !== 'tool_call' && spanToolCallId) {
3772
- exactToolParent = resolveExactToolParent(span, spanToolCallId);
3773
- }
3774
- while (parentStack.length > 0 &&
3775
- parentStack[parentStack.length - 1].endMs < span.startMs - 1000) {
3776
- parentStack.pop();
3777
- }
3778
- if (exactToolParent) {
3779
- span.level = exactToolParent.level + 1;
3780
- } else if (span.action.action_type === 'tool_call') {
3781
- span.level = parentStack.filter(function(p) { return p.actionType !== 'tool_call'; }).length;
3782
- } else if (span.action.action_type === 'thinking') {
3783
- span.level = parentStack.filter(function(p) { return p.actionType !== 'tool_call'; }).length;
3784
- } else {
3785
- span.level = parentStack.length;
3786
- }
5674
+ span.level = 0;
3787
5675
  flatSpans.push(span);
3788
- var dur = span.endMs - span.startMs;
3789
- if (dur > 100 && canBecomeParent(span.action.action_type)) {
3790
- parentStack.push({
3791
- startMs: span.startMs,
3792
- endMs: span.endMs,
3793
- level: span.level,
3794
- actionType: span.action.action_type
3795
- });
3796
- }
3797
5676
  });
3798
5677
  }
3799
5678
 
@@ -3831,9 +5710,50 @@ async function renderTraceDetail(sessionId, highlightAction, highlightTime, opts
3831
5710
  });
3832
5711
 
3833
5712
  if (runGroups.length > 0) {
3834
- return runGroups
3835
- .map(function(g) { return { runId: g.runId, rootSpan: null, items: g.items }; })
5713
+ var normalized = runGroups
5714
+ .map(function(g) { return { runId: g.runId, parentRunId: '', rootSpan: null, items: g.items.slice(), startMs: g.startMs, endMs: g.endMs, mergedSubagents: [] }; })
3836
5715
  .filter(function(g) { return g.items && g.items.length; });
5716
+
5717
+ // Strict merge rule: only merge when parent_run_id is explicitly present in data.
5718
+ normalized.forEach(function(g) {
5719
+ ensureArray(g.items).forEach(function(span) {
5720
+ if (!span || !span.action || g.parentRunId) return;
5721
+ var explicitParent = getActionParentRunId(span.action);
5722
+ if (explicitParent) g.parentRunId = explicitParent;
5723
+ });
5724
+ });
5725
+
5726
+ var byRunId = {};
5727
+ normalized.forEach(function(g) { byRunId[g.runId] = g; });
5728
+ var removeRun = {};
5729
+ normalized.forEach(function(g) {
5730
+ if (!g || !g.runId) return;
5731
+ var parentRunId = String(g.parentRunId || '');
5732
+ if (!parentRunId || parentRunId === g.runId) return;
5733
+ var parent = byRunId[parentRunId];
5734
+ if (!parent) return;
5735
+
5736
+ var announceInfo = parseAnnounceRunInfo(g.runId);
5737
+ var childRunId = announceInfo ? announceInfo.childRunId : g.runId;
5738
+ var childSessionKey = announceInfo ? announceInfo.childSessionKey : '';
5739
+
5740
+ parent.items = ensureArray(parent.items).concat(ensureArray(g.items));
5741
+ parent.startMs = Math.min(Number(parent.startMs || 0), Number(g.startMs || parent.startMs || 0));
5742
+ parent.endMs = Math.max(Number(parent.endMs || 0), Number(g.endMs || parent.endMs || 0));
5743
+ parent.mergedSubagents = ensureArray(parent.mergedSubagents);
5744
+ var exists = parent.mergedSubagents.some(function(x) { return x && x.childRunId === childRunId; });
5745
+ if (!exists) {
5746
+ parent.mergedSubagents.push({
5747
+ childRunId: childRunId,
5748
+ childSessionKey: childSessionKey,
5749
+ });
5750
+ }
5751
+ removeRun[g.runId] = true;
5752
+ });
5753
+
5754
+ return normalized
5755
+ .filter(function(g) { return !removeRun[g.runId]; })
5756
+ .map(function(g) { return { runId: g.runId, rootSpan: null, items: g.items, mergedSubagents: g.mergedSubagents || [] }; });
3837
5757
  }
3838
5758
 
3839
5759
  var rootSpans = spansInOrder.filter(function(span) {
@@ -3930,27 +5850,64 @@ async function renderTraceDetail(sessionId, highlightAction, highlightTime, opts
3930
5850
  // Trace header
3931
5851
  html += '<div class="trace-header">';
3932
5852
  html += '<div class="trace-header-top">';
3933
- html += '<a href="#/traces" class="trace-back">← Back to Traces</a>';
5853
+ html += '<a href="#/traces" class="trace-back">← ' + esc(t('back_to_traces')) + '</a>';
5854
+ html += '<div class="trace-header-actions">';
5855
+ html += '<span class="trace-run-state ' + traceRunState + '">' + (traceRunState === 'completed' ? t('completed') : (traceRunState === 'failed' ? t('failed') : t('running'))) + '</span>';
5856
+ html += '</div>';
3934
5857
  html += '</div>';
3935
5858
  html += '<div class="trace-title">Session: ' + esc(sessionId) + '</div>';
3936
5859
  html += '<div class="trace-meta">';
3937
5860
  html += '<span class="trace-meta-item"><b>Agent:</b> ' + esc(userId) + '</span>';
3938
5861
  html += '<span class="trace-meta-item"><b>Model:</b> ' + esc(modelName) + '</span>';
3939
- html += '<span class="trace-meta-item"><b>Duration:</b> ' + fmtDur(totalDur) + '</span>';
3940
- html += '<span class="trace-meta-item"><b>Actions:</b> ' + timelineActions.length + '</span>';
5862
+ html += '<span class="trace-meta-item"><b>' + esc(t('duration')) + ':</b> ' + fmtDur(totalDur) + '</span>';
5863
+ html += '<span class="trace-meta-item"><b>' + esc(t('actions')) + ':</b> ' + timelineActions.length + '</span>';
3941
5864
  if (sessionSnapshots.length > 0) {
3942
- html += '<span class="trace-meta-item"><b>Snapshots:</b> ' + sessionSnapshots.length + '</span>';
5865
+ html += '<span class="trace-meta-item"><b>' + esc(t('snapshots')) + ':</b> ' + sessionSnapshots.length + '</span>';
3943
5866
  }
3944
- html += '<span class="trace-meta-item"><b>Time:</b> ' + fmtTime(new Date(firstAction.startMs).toISOString()) + ' → ' + fmtTime(new Date(lastAction.endMs).toISOString()) + '</span>';
5867
+ html += '<span class="trace-meta-item"><b>' + esc(t('time')) + ':</b> ' + fmtTime(new Date(firstAction.startMs).toISOString()) + ' → ' + fmtTime(new Date(lastAction.endMs).toISOString()) + '</span>';
3945
5868
  html += '</div>';
5869
+ if (traceSubagents.length > 0) {
5870
+ html += '<div class="trace-subagents">';
5871
+ traceSubagents.forEach(function(sa) {
5872
+ var statusCls = sa.status === 'done' ? 'done' : (sa.status === 'fail' ? 'fail' : 'running');
5873
+ var statusText = sa.status === 'done' ? 'completed' : (sa.status === 'fail' ? 'failed' : 'running');
5874
+ var title = sa.label || sa.task || 'subagent task';
5875
+ var keyText = shortSessionKey(sa.childSessionKey);
5876
+ html += '<div class="trace-subagent-item">';
5877
+ html += '<div class="trace-subagent-row">';
5878
+ html += '<span class="trace-subagent-name">' + esc(title) + '</span>';
5879
+ html += '<span class="trace-subagent-status ' + statusCls + '">' + statusText + '</span>';
5880
+ html += '</div>';
5881
+ html += '<div class="trace-subagent-meta">';
5882
+ if (sa.runtime) {
5883
+ html += '<div><b>Runtime:</b> ' + esc(sa.runtime) + '</div>';
5884
+ }
5885
+ if (sa.spawnedAt) {
5886
+ html += '<div><b>Spawned:</b> ' + fmtTime(sa.spawnedAt) + '</div>';
5887
+ }
5888
+ if (sa.completedAt) {
5889
+ html += '<div><b>Completed:</b> ' + fmtTime(sa.completedAt) + '</div>';
5890
+ }
5891
+ html += '</div>';
5892
+ html += '<div class="trace-subagent-link">';
5893
+ if (sa.childSessionId) {
5894
+ html += '<a href="#/trace/' + encodeURIComponent(sa.childSessionId) + '">' + esc(keyText) + '</a>';
5895
+ } else {
5896
+ html += esc(keyText);
5897
+ }
5898
+ html += '</div>';
5899
+ html += '</div>';
5900
+ });
5901
+ html += '</div>';
5902
+ }
3946
5903
  html += '<div class="filter-bar trace-search-bar">';
3947
- html += '<input type="text" id="trace-search-input" placeholder="Search this session: action, input, output, model..." value="' + esc(traceSearchQuery) + '" onkeydown="if(event.key===\\'Enter\\')applyTraceSearch()">';
5904
+ html += '<input type="text" id="trace-search-input" placeholder="' + esc(t('search_this_session')) + '" value="' + esc(traceSearchQuery) + '" onkeydown="if(event.key===\\'Enter\\')applyTraceSearch()">';
3948
5905
  html += '<div class="trace-search-nav">';
3949
- html += '<button class="icon-refresh-btn" onclick="refreshCurrentTrace()" title="Refresh current trace">' + ICON_REFRESH + '</button>';
3950
- html += '<button onclick="applyTraceSearch()">Search</button>';
3951
- html += '<button onclick="traceSearchStep(-1)"' + (matchedFlatIdxs.length ? '' : ' disabled') + '>Prev</button>';
3952
- html += '<button onclick="traceSearchStep(1)"' + (matchedFlatIdxs.length ? '' : ' disabled') + '>Next</button>';
3953
- html += '<button class="btn-clear" onclick="clearTraceSearch()">Clear</button>';
5906
+ html += '<button class="icon-refresh-btn" onclick="refreshCurrentTrace()" title="' + esc(t('refresh_current_trace')) + '">' + ICON_REFRESH + '</button>';
5907
+ html += '<button onclick="applyTraceSearch()">' + esc(t('search')) + '</button>';
5908
+ html += '<button onclick="traceSearchStep(-1)"' + (matchedFlatIdxs.length ? '' : ' disabled') + '>' + esc(t('prev')) + '</button>';
5909
+ html += '<button onclick="traceSearchStep(1)"' + (matchedFlatIdxs.length ? '' : ' disabled') + '>' + esc(t('next')) + '</button>';
5910
+ html += '<button class="btn-clear" onclick="clearTraceSearch()">' + esc(t('clear')) + '</button>';
3954
5911
  html += '</div>';
3955
5912
  html += '<div class="trace-search-stats">';
3956
5913
  if (normalizedTraceQuery) {
@@ -3958,7 +5915,7 @@ async function renderTraceDetail(sessionId, highlightAction, highlightTime, opts
3958
5915
  ? ('Matched ' + matchedFlatIdxs.length + ' action' + (matchedFlatIdxs.length > 1 ? 's' : '') + ' in this session')
3959
5916
  : 'No matches in this session';
3960
5917
  } else {
3961
- html += 'Search within this session';
5918
+ html += esc(t('search_within_session'));
3962
5919
  }
3963
5920
  html += '</div>';
3964
5921
  html += '</div>';
@@ -4002,7 +5959,7 @@ async function renderTraceDetail(sessionId, highlightAction, highlightTime, opts
4002
5959
 
4003
5960
  // Waterfall
4004
5961
  if (normalizedTraceQuery && matchedFlatIdxs.length === 0) {
4005
- html += '<div class="trace-search-empty">No action matched "' + esc(traceSearchQuery) + '". Try a tool name, model, prompt fragment, or error text.</div>';
5962
+ html += '<div class="trace-search-empty">' + esc(t('no_action_matched')) + ' "' + esc(traceSearchQuery) + '". ' + esc(t('try_hint')) + '</div>';
4006
5963
  }
4007
5964
  html += '<div class="waterfall">';
4008
5965
  html += '<div class="wf-header"><span>Name</span><span style="text-align:right;padding-right:14px">Duration</span><span>Timeline</span></div>';
@@ -4026,9 +5983,6 @@ async function renderTraceDetail(sessionId, highlightAction, highlightTime, opts
4026
5983
  if (hasTreeParents) return a.flatIdx - b.flatIdx;
4027
5984
  var startDiff = a.startMs - b.startMs;
4028
5985
  if (startDiff !== 0) return startDiff;
4029
- var priA = typePriority(a.action.action_type);
4030
- var priB = typePriority(b.action.action_type);
4031
- if (priA !== priB) return priA - priB;
4032
5986
  var endDiff = a.endMs - b.endMs;
4033
5987
  if (endDiff !== 0) return endDiff;
4034
5988
  return a.idx - b.idx;
@@ -4041,6 +5995,10 @@ async function renderTraceDetail(sessionId, highlightAction, highlightTime, opts
4041
5995
  var shouldCollapseForSearch = normalizedTraceQuery && groupMatchIdxs.length === 0;
4042
5996
  var shouldCollapseForLength = !normalizedTraceQuery && visibleGroupItems.length > TRACE_COLLAPSE_LIMIT;
4043
5997
  var runDuration = group.rootSpan ? group.rootSpan.action.duration_ms : (groupEndMs - groupStartMs);
5998
+ var replaySpan = visibleGroupItems.find(function(span) {
5999
+ return !!(span && span.action && isReplayableAction(span.action));
6000
+ }) || null;
6001
+ var replayActionIdx = replaySpan ? replaySpan.flatIdx : -1;
4044
6002
  var collapsedCls = (shouldCollapseForSearch || shouldCollapseForLength) ? ' collapsed' : '';
4045
6003
  html += '<div class="wf-group-section' + collapsedCls + '" data-group-idx="' + groupIdx + '">';
4046
6004
  html += '<div class="wf-group" onclick="toggleTraceGroup(' + groupIdx + ')">';
@@ -4050,6 +6008,10 @@ async function renderTraceDetail(sessionId, highlightAction, highlightTime, opts
4050
6008
  html += '<div class="wf-group-title-row">';
4051
6009
  var runTitle = 'Trace ' + (groupIdx + 1);
4052
6010
  html += '<span class="wf-group-title">' + runTitle + '</span>';
6011
+ var mergedSubagents = ensureArray(group && group.mergedSubagents);
6012
+ if (mergedSubagents.length > 0) {
6013
+ html += '<span class="wf-group-hit">+ subagent callback</span>';
6014
+ }
4053
6015
  if (runDuration != null) {
4054
6016
  html += '<span class="wf-group-hit">' + fmtDur(runDuration) + '</span>';
4055
6017
  }
@@ -4059,17 +6021,28 @@ async function renderTraceDetail(sessionId, highlightAction, highlightTime, opts
4059
6021
  html += '</div>';
4060
6022
  if (groupSnippet) {
4061
6023
  html += '<div class="wf-group-snippet">' + highlightSearchText(groupSnippet, normalizedTraceQuery) + '</div>';
6024
+ } else if (mergedSubagents.length > 0) {
6025
+ var firstMerged = mergedSubagents[0];
6026
+ var mergedKey = firstMerged && firstMerged.childSessionKey ? shortSessionKey(firstMerged.childSessionKey) : '';
6027
+ html += '<div class="wf-group-snippet">Includes callback for ' + esc(mergedKey || 'subagent run') + '</div>';
4062
6028
  } else if (group.rootSpan) {
4063
6029
  html += '<div class="wf-group-snippet">' + esc(group.rootSpan.action.action_name) + ' completed this run</div>';
4064
6030
  }
4065
6031
  html += '</div>';
4066
6032
  html += '</div>';
6033
+ html += '<div class="wf-group-right">';
6034
+ html += '<button class="wf-group-replay-btn" onclick="event.stopPropagation();openReplayFromAction(' + replayActionIdx + ')"'
6035
+ + (replayActionIdx >= 0 ? '' : ' disabled')
6036
+ + ' title="' + esc(replayActionIdx >= 0 ? tr('Replay this trace', '回放这个 Trace') : tr('No replayable LLM call in this trace', '该 Trace 没有可回放的 LLM 调用')) + '">'
6037
+ + '↻ ' + esc(tr('Replay', '回放')) + '</button>';
4067
6038
  html += '<span class="wf-group-meta">' + fmtTime(new Date(groupStartMs).toISOString()) + ' → ' + fmtTime(new Date(groupEndMs).toISOString()) + ' | ' + visibleGroupItems.length + ' actions</span>';
4068
6039
  html += '</div>';
6040
+ html += '</div>';
4069
6041
 
4070
6042
  visibleGroupItems.forEach(function(span) {
4071
6043
  var a = span.action;
4072
6044
  var color = typeColor(a.action_type);
6045
+ var displayActionName = formatTraceActionName(a);
4073
6046
  var leftPct = ((span.startMs - groupStartMs) / groupTotalDur * 100).toFixed(2);
4074
6047
  var widthPct = Math.max(((span.endMs - span.startMs) / groupTotalDur * 100), 0.3).toFixed(2);
4075
6048
  var indent = span.level * 20;
@@ -4078,7 +6051,7 @@ async function renderTraceDetail(sessionId, highlightAction, highlightTime, opts
4078
6051
  html += '<div class="wf-name">';
4079
6052
  if (indent > 0) html += '<span class="indent" style="width:' + indent + 'px;display:inline-flex;justify-content:center">└</span>';
4080
6053
  html += '<span class="dot" style="background:' + color + '"></span>';
4081
- html += '<span class="text">' + highlightSearchText(a.action_name, normalizedTraceQuery) + '</span>';
6054
+ html += '<span class="text">' + highlightSearchText(displayActionName, normalizedTraceQuery) + '</span>';
4082
6055
  html += '</div>';
4083
6056
  html += '<div class="wf-dur">' + fmtDur(span.displayDurationMs != null ? span.displayDurationMs : a.duration_ms) + '</div>';
4084
6057
  html += '<div class="wf-bar-wrap"><div class="wf-bar" style="left:' + leftPct + '%;width:' + widthPct + '%;background:' + color + '"></div></div>';
@@ -4151,7 +6124,7 @@ async function renderTraceDetail(sessionId, highlightAction, highlightTime, opts
4151
6124
  app.innerHTML = renderLayout('trace',
4152
6125
  '<div class="trace-header">' +
4153
6126
  '<a href="#/traces" class="trace-back">← Back</a>' +
4154
- '<div class="empty"><div class="icon">⚠️</div><div class="text">Failed to load: ' + esc(String(err)) + '</div></div>' +
6127
+ '<div class="empty"><div class="icon">⚠️</div><div class="text">' + esc(t('failed_load')) + esc(String(err)) + '</div></div>' +
4155
6128
  '</div>');
4156
6129
  }
4157
6130
  }
@@ -4293,27 +6266,30 @@ window.selectAction = function(fi, shouldScroll) {
4293
6266
  // Header (sticky)
4294
6267
  html += '<div class="detail-header">';
4295
6268
  html += '<span class="dot" style="background:' + color + '"></span>';
4296
- html += '<span class="name">' + esc(a.action_name) + '</span>';
6269
+ html += '<span class="name">' + esc(formatTraceActionName(a)) + '</span>';
4297
6270
  if (a.duration_ms != null) html += '<span class="dur">' + fmtDur(a.duration_ms) + '</span>';
4298
6271
  html += '</div>';
4299
6272
 
4300
6273
  // Meta
4301
6274
  html += '<div class="detail-meta">';
4302
- html += '<span><b>Type:</b> ' + typeLabel(a.action_type) + '</span>';
4303
- html += '<span><b>Time:</b> ' + fmtTime(a.created_at) + '</span>';
4304
- if (a.model_name) html += '<span><b>Model:</b> ' + esc(a.model_name) + '</span>';
4305
- if (a.user_id) html += '<span><b>Agent:</b> ' + esc(a.user_id) + '</span>';
4306
- if (a.channel_id) html += '<span><b>Channel:</b> ' + esc(a.channel_id) + '</span>';
4307
- if (a.prompt_tokens != null) html += '<span><b>Prompt Tokens:</b> ' + a.prompt_tokens + '</span>';
4308
- if (a.completion_tokens != null) html += '<span><b>Completion Tokens:</b> ' + a.completion_tokens + '</span>';
6275
+ html += '<span><b>' + esc(t('type')) + ':</b> ' + typeLabel(a.action_type) + '</span>';
6276
+ html += '<span><b>' + esc(t('time')) + ':</b> ' + fmtTime(a.created_at) + '</span>';
6277
+ if (a.model_name) html += '<span><b>' + esc(t('model')) + ':</b> ' + esc(a.model_name) + '</span>';
6278
+ if (a.user_id) html += '<span><b>' + esc(t('agent')) + ':</b> ' + esc(a.user_id) + '</span>';
6279
+ if (a.channel_id) html += '<span><b>' + esc(t('channel')) + ':</b> ' + esc(a.channel_id) + '</span>';
6280
+ if (a.prompt_tokens != null) html += '<span><b>' + esc(t('prompt_tokens')) + ':</b> ' + a.prompt_tokens + '</span>';
6281
+ if (a.completion_tokens != null) html += '<span><b>' + esc(t('completion_tokens')) + ':</b> ' + a.completion_tokens + '</span>';
6282
+ var spawnInfo = getSessionsSpawnInfo(a);
6283
+ if (spawnInfo && spawnInfo.childSessionKey) {
6284
+ html += '<span><b>Child:</b> ' + esc(shortSessionKey(spawnInfo.childSessionKey)) + '</span>';
6285
+ }
4309
6286
  html += '</div>';
4310
-
4311
6287
  // Body: Input / Output side by side
4312
6288
  html += '<div class="detail-body">';
4313
6289
  var useStructuredView = !isToolActionType(a.action_type);
4314
6290
 
4315
6291
  html += '<div class="detail-section">';
4316
- html += '<h4>Input</h4>';
6292
+ html += '<h4>' + esc(t('input')) + '</h4>';
4317
6293
  if (input !== null && input !== undefined) {
4318
6294
  if (useStructuredView) {
4319
6295
  html += renderStructuredFields(input);
@@ -4336,7 +6312,7 @@ window.selectAction = function(fi, shouldScroll) {
4336
6312
  html += '</div>';
4337
6313
 
4338
6314
  html += '<div class="detail-section">';
4339
- html += '<h4>Output</h4>';
6315
+ html += '<h4>' + esc(t('output')) + '</h4>';
4340
6316
  if (output !== null && output !== undefined) {
4341
6317
  if (useStructuredView) {
4342
6318
  html += renderStructuredFields(output);
@@ -4388,6 +6364,36 @@ function wireSparklinePointerDelegation() {
4388
6364
  }
4389
6365
  wireSparklinePointerDelegation();
4390
6366
 
6367
+ function wireMetricsPanelPointerDelegation() {
6368
+ function onMove(ev) {
6369
+ var t = ev.target;
6370
+ if (!t || !t.closest) return;
6371
+ var panel = t.closest('.metrics-panel-chart');
6372
+ if (!panel) return;
6373
+ var overlay = panel.querySelector('.metrics-panel-overlay');
6374
+ if (!overlay) return;
6375
+ metricsPanelMove(ev, overlay);
6376
+ }
6377
+ function onLeavePanel(ev) {
6378
+ var t = ev.target;
6379
+ if (!t || !t.closest) return;
6380
+ var panel = t.closest('.metrics-panel-chart');
6381
+ if (!panel) return;
6382
+ var rel = ev.relatedTarget;
6383
+ if (rel && panel.contains(rel)) return;
6384
+ var overlay = panel.querySelector('.metrics-panel-overlay');
6385
+ if (overlay) metricsPanelLeave(overlay);
6386
+ }
6387
+ if (typeof window.PointerEvent === 'function') {
6388
+ document.addEventListener('pointermove', onMove);
6389
+ document.addEventListener('pointerout', onLeavePanel, true);
6390
+ } else {
6391
+ document.addEventListener('mousemove', onMove);
6392
+ document.addEventListener('mouseout', onLeavePanel, true);
6393
+ }
6394
+ }
6395
+ wireMetricsPanelPointerDelegation();
6396
+
4391
6397
  /* ---------- metrics snapshot table: delegated handlers (embed / CSP-safe) ---------- */
4392
6398
  document.addEventListener('click', function(ev) {
4393
6399
  var tr = ev.target && ev.target.closest && ev.target.closest('#metrics-snapshot-tbody tr.metrics-row');
@@ -4404,8 +6410,47 @@ document.addEventListener('keydown', function(ev) {
4404
6410
  metricsRowClick(tr);
4405
6411
  });
4406
6412
 
6413
+ /* ---------- global crash guard (render error instead of blank screen) ---------- */
6414
+ function renderFatalOverlay(kind, message, stack) {
6415
+ var app = document.getElementById('app');
6416
+ if (!app) return;
6417
+ var safeMsg = esc(message || 'unknown error');
6418
+ var safeStack = esc(stack || '');
6419
+ app.innerHTML =
6420
+ '<div style="padding:24px;font-family:ui-monospace,SFMono-Regular,Menlo,monospace;color:#f8fafc;background:#0b1220;min-height:100vh;box-sizing:border-box">' +
6421
+ '<h2 style="margin:0 0 12px 0;font-size:18px">Observability UI crashed</h2>' +
6422
+ '<div style="margin-bottom:10px;opacity:.85">Type: ' + esc(kind || 'error') + '</div>' +
6423
+ '<pre style="white-space:pre-wrap;word-break:break-word;background:#111827;border:1px solid #334155;border-radius:8px;padding:12px;line-height:1.5">' + safeMsg + '</pre>' +
6424
+ (safeStack ? ('<pre style="margin-top:10px;white-space:pre-wrap;word-break:break-word;background:#111827;border:1px solid #334155;border-radius:8px;padding:12px;line-height:1.4;max-height:42vh;overflow:auto">' + safeStack + '</pre>') : '') +
6425
+ '<div style="margin-top:12px;opacity:.75">Please refresh after update. If persists, send this screenshot.</div>' +
6426
+ '</div>';
6427
+ }
6428
+ window.addEventListener('error', function(ev) {
6429
+ var err = ev && ev.error ? ev.error : null;
6430
+ var msg = (ev && ev.message) ? ev.message : (err && err.message ? err.message : 'window error');
6431
+ var stack = err && err.stack ? String(err.stack) : '';
6432
+ renderFatalOverlay('window.error', String(msg || ''), stack);
6433
+ });
6434
+ window.addEventListener('unhandledrejection', function(ev) {
6435
+ var reason = ev ? ev.reason : null;
6436
+ var msg = '';
6437
+ var stack = '';
6438
+ if (reason && typeof reason === 'object') {
6439
+ msg = String(reason.message || reason.toString ? reason.toString() : '[object]');
6440
+ stack = String(reason.stack || '');
6441
+ } else {
6442
+ msg = String(reason || 'unhandled rejection');
6443
+ }
6444
+ renderFatalOverlay('unhandledrejection', msg, stack);
6445
+ });
6446
+
4407
6447
  /* ---------- init ---------- */
4408
- router();
4409
- scheduleAlertBadgeLoad();
6448
+ try {
6449
+ router();
6450
+ scheduleAlertBadgeLoad();
6451
+ } catch (e) {
6452
+ var err = e && typeof e === 'object' ? e : null;
6453
+ renderFatalOverlay('bootstrap', String(err && err.message ? err.message : e), String(err && err.stack ? err.stack : ''));
6454
+ }
4410
6455
  `;
4411
6456
  //# sourceMappingURL=ui.js.map