openclaw-observability 2026.3.21 → 2026.3.23

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 (43) hide show
  1. package/dist/config.d.ts +11 -0
  2. package/dist/config.d.ts.map +1 -1
  3. package/dist/config.js +6 -2
  4. package/dist/config.js.map +1 -1
  5. package/dist/index.d.ts +12 -1
  6. package/dist/index.d.ts.map +1 -1
  7. package/dist/index.js +805 -98
  8. package/dist/index.js.map +1 -1
  9. package/dist/redaction.d.ts.map +1 -1
  10. package/dist/redaction.js +8 -1
  11. package/dist/redaction.js.map +1 -1
  12. package/dist/security/scanner.d.ts +15 -1
  13. package/dist/security/scanner.d.ts.map +1 -1
  14. package/dist/security/scanner.js +148 -11
  15. package/dist/security/scanner.js.map +1 -1
  16. package/dist/security/types.d.ts +1 -0
  17. package/dist/security/types.d.ts.map +1 -1
  18. package/dist/security/types.js +1 -0
  19. package/dist/security/types.js.map +1 -1
  20. package/dist/storage/duckdb-local-writer.d.ts +2 -1
  21. package/dist/storage/duckdb-local-writer.d.ts.map +1 -1
  22. package/dist/storage/duckdb-local-writer.js +20 -7
  23. package/dist/storage/duckdb-local-writer.js.map +1 -1
  24. package/dist/storage/mysql-writer.d.ts +3 -1
  25. package/dist/storage/mysql-writer.d.ts.map +1 -1
  26. package/dist/storage/mysql-writer.js +11 -6
  27. package/dist/storage/mysql-writer.js.map +1 -1
  28. package/dist/types.d.ts +3 -0
  29. package/dist/types.d.ts.map +1 -1
  30. package/dist/types.js +3 -0
  31. package/dist/types.js.map +1 -1
  32. package/dist/web/api.d.ts +2 -2
  33. package/dist/web/api.d.ts.map +1 -1
  34. package/dist/web/api.js +121 -57
  35. package/dist/web/api.js.map +1 -1
  36. package/dist/web/routes.d.ts +7 -3
  37. package/dist/web/routes.d.ts.map +1 -1
  38. package/dist/web/routes.js +176 -25
  39. package/dist/web/routes.js.map +1 -1
  40. package/dist/web/ui.js +1190 -141
  41. package/dist/web/ui.js.map +1 -1
  42. package/openclaw.plugin.json +2 -2
  43. package/package.json +1 -1
package/dist/web/ui.js CHANGED
@@ -113,6 +113,9 @@ a:hover{text-decoration:underline}
113
113
  .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)}
114
114
  .theme-btn:hover{color:var(--text);border-color:var(--border-strong);background:var(--bg-hover)}
115
115
  .theme-btn svg{width:16px;height:16px;stroke:currentColor;fill:none;stroke-width:1.5;stroke-linecap:round;stroke-linejoin:round}
116
+ .icon-refresh-btn{width:32px;height:32px;display:inline-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);padding:0;flex-shrink:0}
117
+ .icon-refresh-btn:hover{color:var(--text);border-color:var(--border-strong);background:var(--bg-hover)}
118
+ .icon-refresh-btn svg{width:14px;height:14px;stroke:currentColor;fill:none;stroke-width:1.8;stroke-linecap:round;stroke-linejoin:round}
116
119
 
117
120
  /* ---- Content ---- */
118
121
  .content{flex:1;padding:16px 20px 32px;min-height:0;overflow-y:auto}
@@ -132,6 +135,59 @@ a:hover{text-decoration:underline}
132
135
  .filter-bar .filter-sep{width:1px;height:24px;background:var(--border);flex-shrink:0}
133
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)}
134
137
  .filter-bar .btn-clear:hover{color:var(--text);border-color:var(--border-strong);background:var(--bg-hover)}
138
+ .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
+ .sec-runtime-head{display:flex;align-items:flex-end;justify-content:space-between;gap:12px;flex-wrap:wrap;margin-bottom:12px}
140
+ .sec-runtime-title{font-size:14px;font-weight:700;color:var(--text-strong);letter-spacing:-.02em}
141
+ .sec-runtime-sub{font-size:12px;color:var(--muted)}
142
+ .sec-runtime-summary{font-size:11px;color:var(--muted);background:var(--secondary);border:1px solid var(--border);border-radius:var(--radius-full);padding:3px 10px}
143
+ .sec-runtime-grid{display:grid;grid-template-columns:minmax(340px,1fr) minmax(300px,1fr);gap:12px;align-items:start}
144
+ .sec-rule-list{border:1px solid var(--border);border-radius:var(--radius-md);background:var(--bg-elevated);overflow:hidden}
145
+ .sec-rule-item{display:flex;align-items:center;gap:10px;padding:11px 12px;border-bottom:1px solid var(--border);cursor:pointer;transition:background var(--duration-fast),border-left-color var(--duration-fast);border-left:3px solid transparent}
146
+ .sec-rule-item:last-child{border-bottom:none}
147
+ .sec-rule-item:hover{background:var(--bg-hover)}
148
+ .sec-rule-item.active{background:var(--accent-subtle);border-left-color:var(--accent)}
149
+ .sec-rule-copy{min-width:0;flex:1}
150
+ .sec-rule-name{font-size:13px;font-weight:600;color:var(--text-strong)}
151
+ .sec-rule-brief{font-size:11px;color:var(--muted);margin-top:1px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
152
+ .sec-state-pill{flex-shrink:0;font-size:10px;font-weight:700;padding:2px 7px;border-radius:var(--radius-full);text-transform:uppercase;letter-spacing:.04em}
153
+ .sec-state-pill.on{color:var(--ok);background:var(--ok-subtle);border:1px solid rgba(34,197,94,.25)}
154
+ .sec-state-pill.off{color:var(--danger);background:var(--danger-subtle);border:1px solid rgba(239,68,68,.25)}
155
+ .sec-switch{position:relative;display:inline-flex;align-items:center;width:38px;height:22px;border:none;background:transparent;padding:0;cursor:pointer;flex-shrink:0}
156
+ .sec-switch-track{position:absolute;inset:0;border-radius:999px;background:var(--muted-strong);opacity:.55;transition:background var(--duration-fast),opacity var(--duration-fast)}
157
+ .sec-switch-thumb{position:absolute;left:2px;top:2px;width:18px;height:18px;border-radius:50%;background:#fff;box-shadow:0 1px 2px rgba(0,0,0,.25);transition:transform var(--duration-fast)}
158
+ .sec-switch.on .sec-switch-track{background:var(--ok);opacity:.92}
159
+ .sec-switch.on .sec-switch-thumb{transform:translateX(16px)}
160
+ .sec-rule-detail{border:1px solid var(--border);border-radius:var(--radius-md);background:var(--bg-elevated);padding:14px 14px 12px;max-height:340px;overflow:auto}
161
+ .sec-rule-detail-title{display:flex;align-items:center;gap:8px;margin-bottom:6px}
162
+ .sec-rule-detail-title h4{font-size:14px;font-weight:700;color:var(--text-strong);letter-spacing:-.02em}
163
+ .sec-rule-detail p{font-size:12px;color:var(--text);line-height:1.6;margin-bottom:10px}
164
+ .sec-rule-detail-meta{display:flex;gap:8px;flex-wrap:wrap;margin-bottom:10px}
165
+ .sec-rule-explain{border:1px solid var(--border);border-radius:var(--radius-sm);background:var(--card);padding:10px 10px 8px;margin-bottom:10px}
166
+ .sec-rule-explain-text{font-size:12px;color:var(--text);line-height:1.6}
167
+ .sec-meta-chip{font-size:10px;padding:2px 8px;border-radius:var(--radius-full);border:1px solid var(--border);background:var(--secondary);color:var(--muted);text-transform:uppercase;letter-spacing:.04em}
168
+ .sec-meta-chip.level-critical{color:var(--danger);border-color:rgba(239,68,68,.35);background:var(--danger-subtle)}
169
+ .sec-meta-chip.level-high{color:#ea580c;border-color:rgba(234,88,12,.35);background:rgba(234,88,12,.1)}
170
+ .sec-meta-chip.level-medium{color:var(--warn);border-color:rgba(245,158,11,.35);background:var(--warn-subtle)}
171
+ .sec-meta-chip.level-low{color:var(--ok);border-color:rgba(34,197,94,.35);background:var(--ok-subtle)}
172
+ .sec-rule-detail-list{border-top:1px dashed var(--border);padding-top:8px}
173
+ .sec-rule-detail-list-title{font-size:11px;font-weight:600;color:var(--muted);margin-bottom:4px;text-transform:uppercase;letter-spacing:.04em}
174
+ .sec-rule-detail-list-item{font-size:12px;color:var(--text);padding:2px 0}
175
+ .sec-custom-editor{margin-top:10px;border-top:1px dashed var(--border);padding-top:10px}
176
+ .sec-custom-form{display:grid;grid-template-columns:1fr 1.8fr auto;gap:8px;align-items:end}
177
+ .sec-custom-field{display:flex;flex-direction:column;gap:4px;min-width:0}
178
+ .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}
180
+ .sec-custom-form input:focus,.sec-custom-form select:focus{border-color:var(--accent);box-shadow:var(--focus-ring)}
181
+ .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
+ .sec-custom-form button:hover{filter:brightness(1.06)}
183
+ .sec-custom-list{margin-top:10px;display:flex;flex-direction:column;gap:6px;max-height:220px;overflow:auto;padding-right:2px}
184
+ .sec-custom-item{display:flex;align-items:flex-start;gap:8px;border:1px solid var(--border);border-radius:var(--radius-sm);padding:7px 8px;background:var(--card)}
185
+ .sec-custom-item-main{min-width:0;flex:1}
186
+ .sec-custom-item-name{font-size:12px;font-weight:600;color:var(--text-strong)}
187
+ .sec-custom-item-pattern{font-size:11px;color:var(--muted);font-family:var(--mono);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
188
+ .sec-custom-item-actions{display:flex;align-items:center;gap:6px;flex-shrink:0}
189
+ .sec-custom-item-del{height:22px;min-width:56px;border:1px solid rgba(239,68,68,.35);border-radius:999px;background:var(--danger-subtle);color:var(--danger);padding:0 10px;font-size:11px;line-height:1;cursor:pointer;font-weight:700}
190
+ .sec-custom-item-del:hover{background:rgba(239,68,68,.2);border-color:rgba(239,68,68,.55)}
135
191
  /* Time range dropdown */
136
192
  .time-dropdown{position:relative}
137
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)}
@@ -174,16 +230,27 @@ a:hover{text-decoration:underline}
174
230
 
175
231
  /* ==================== Trace view (split pane) ==================== */
176
232
  .content--trace{padding:0!important;overflow:hidden!important;flex:1!important;min-height:0!important;display:flex!important;flex-direction:column!important}
177
- .content--trace .content-inner{max-width:1280px;width:100%;margin:0 auto;flex:1;min-height:0;display:flex;flex-direction:column;overflow:hidden}
233
+ .content--trace .content-inner{max-width:none;width:100%;margin:0;flex:1;min-height:0;display:flex;flex-direction:row;overflow:hidden}
178
234
 
179
- .trace-top{flex:1;min-height:120px;overflow-y:scroll;scrollbar-gutter:stable}
180
- .trace-header{background:var(--card);padding:16px 24px;border-bottom:1px solid var(--border);box-shadow:inset 0 1px 0 var(--card-highlight)}
235
+ .trace-top{flex:1;min-width:0;min-height:120px;overflow-y:scroll;scrollbar-gutter:stable;position:relative}
236
+ .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
+ .trace-header-top{display:flex;align-items:center;justify-content:space-between;gap:12px}
181
238
  .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
+ .trace-header-top .trace-back{margin-bottom:0}
182
240
  .trace-back:hover{text-decoration:underline}
183
241
  .trace-title{font-size:16px;font-weight:700;color:var(--text-strong);margin-bottom:4px;word-break:break-all;letter-spacing:-.02em}
184
242
  .trace-meta{display:flex;gap:16px;flex-wrap:wrap}
185
243
  .trace-meta-item{font-size:13px;color:var(--muted)}
186
244
  .trace-meta-item b{color:var(--text-strong);font-weight:600}
245
+ .trace-search-bar{margin-top:14px;margin-bottom:0}
246
+ .trace-search-bar input[type=text]{min-width:280px}
247
+ .trace-search-stats{font-size:12px;color:var(--muted);white-space:nowrap}
248
+ .trace-search-nav{display:flex;align-items:center;gap:6px}
249
+ .trace-search-nav button{display:inline-flex;align-items:center;justify-content:center;padding:6px 10px;border:1px solid var(--border);border-radius:var(--radius-md);background:var(--bg-elevated);color:var(--text);cursor:pointer;font-size:12px;transition:all var(--duration-fast)}
250
+ .trace-search-nav .icon-refresh-btn{padding:0;width:32px;min-width:32px}
251
+ .trace-search-nav button:hover:not(:disabled){background:var(--bg-hover);border-color:var(--border-strong)}
252
+ .trace-search-nav button:disabled{opacity:.45;cursor:default}
253
+ .trace-search-empty{margin:10px 20px 0;padding:10px 14px;background:var(--warn-subtle);border:1px solid rgba(245,158,11,.25);border-radius:var(--radius-md);font-size:12px;color:var(--warn)}
187
254
 
188
255
  /* ---- Waterfall ---- */
189
256
  .waterfall{background:var(--card);border:1px solid var(--border);margin:16px 20px;border-radius:var(--radius-lg);overflow:hidden;box-shadow:inset 0 1px 0 var(--card-highlight)}
@@ -191,7 +258,7 @@ a:hover{text-decoration:underline}
191
258
  .wf-row{display:grid;grid-template-columns:300px 90px 1fr;padding:7px 16px;border-bottom:1px solid var(--border);cursor:pointer;transition:background var(--duration-fast)}
192
259
  .wf-row:hover{background:var(--bg-hover)}
193
260
  .wf-row.selected{background:var(--accent-subtle)}
194
- .wf-row:last-child{border-bottom:none}
261
+ .wf-row.wf-row--match:not(.selected){background:rgba(245,158,11,.08)}
195
262
  .wf-name{font-size:13px;display:flex;align-items:center;gap:6px;overflow:hidden;color:var(--text)}
196
263
  .wf-name .indent{flex-shrink:0;color:var(--muted)}
197
264
  .wf-name .dot{width:8px;height:8px;border-radius:50%;flex-shrink:0}
@@ -200,24 +267,33 @@ a:hover{text-decoration:underline}
200
267
  .wf-bar-wrap{position:relative;height:18px;border-radius:3px}
201
268
  .wf-bar{position:absolute;height:100%;border-radius:3px;min-width:3px;opacity:.75;transition:opacity var(--duration-fast)}
202
269
  .wf-row:hover .wf-bar{opacity:1}
203
- .wf-row--hidden{display:none}
204
- .wf-fold{padding:8px 16px;text-align:center;cursor:pointer;background:var(--bg-accent);border-bottom:1px solid var(--border);color:var(--accent);font-size:13px;font-weight:500;transition:background var(--duration-fast)}
205
- .wf-fold:hover{background:var(--bg-hover)}
206
- .wf-ticks{display:grid;grid-template-columns:300px 90px 1fr;padding:4px 16px 6px;border-top:1px solid var(--border);background:var(--bg-accent)}
207
- .wf-ticks-bar{display:flex;justify-content:space-between}
208
- .wf-tick{font-size:10px;color:var(--muted);font-family:var(--mono)}
270
+ .wf-group-section{position:relative}
271
+ .wf-group-section.collapsed .wf-row{display:none}
272
+ .wf-group-section:last-child .wf-row:last-child{border-bottom:none}
273
+ .wf-group{display:flex;align-items:center;justify-content:space-between;gap:12px;padding:10px 16px;background:var(--bg-elevated);border-top:1px solid var(--border);border-bottom:1px solid var(--border);border-left:3px solid var(--border-strong);cursor:pointer;transition:background var(--duration-fast),border-left-color var(--duration-fast)}
274
+ .wf-group:hover{background:var(--bg-hover);border-left-color:var(--accent)}
275
+ .wf-group:first-of-type{border-top:none}
276
+ .wf-group-main{display:flex;align-items:flex-start;gap:10px;min-width:0;flex:1}
277
+ .wf-group-toggle{font-size:12px;color:var(--muted);font-family:var(--mono);width:12px;text-align:center;flex-shrink:0}
278
+ .wf-group-copy{display:flex;flex-direction:column;gap:4px;min-width:0}
279
+ .wf-group-title-row{display:flex;align-items:center;gap:8px;min-width:0}
280
+ .wf-group-title{font-size:12px;font-weight:700;letter-spacing:.04em;text-transform:uppercase;color:var(--text-strong)}
281
+ .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}
282
+ .wf-group-snippet{font-size:12px;color:var(--muted);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;max-width:100%}
283
+ .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}
284
+ .search-hit{background:rgba(245,158,11,.22);color:var(--text-strong);border-radius:3px;padding:0 2px}
209
285
 
210
286
  /* ---- Resize handle ---- */
211
- .trace-resize{flex:0 0 7px;cursor:row-resize;background:var(--border);position:relative;z-index:10;transition:background var(--duration-fast)}
287
+ .trace-resize{flex:0 0 7px;cursor:col-resize;background:var(--border);position:relative;z-index:10;transition:background var(--duration-fast)}
212
288
  .trace-resize:hover,.trace-resize.active{background:var(--accent)}
213
- .trace-resize::before{content:'';position:absolute;left:0;right:0;top:-4px;bottom:-4px;z-index:1}
214
- .trace-resize::after{content:'';position:absolute;left:50%;top:50%;transform:translate(-50%,-50%);width:40px;height:3px;border-radius:2px;background:var(--muted);opacity:.5}
289
+ .trace-resize::before{content:'';position:absolute;left:-4px;right:-4px;top:0;bottom:0;z-index:1}
290
+ .trace-resize::after{content:'';position:absolute;left:50%;top:50%;transform:translate(-50%,-50%);width:3px;height:40px;border-radius:2px;background:var(--muted);opacity:.5}
215
291
  .trace-resize:hover::after,.trace-resize.active::after{background:var(--accent-foreground);opacity:.8}
216
- body.resizing{cursor:row-resize!important;-webkit-user-select:none!important;user-select:none!important}
217
- body.resizing *{cursor:row-resize!important}
292
+ body.resizing{cursor:col-resize!important;-webkit-user-select:none!important;user-select:none!important}
293
+ body.resizing *{cursor:col-resize!important}
218
294
 
219
- /* ---- Detail panel (bottom pane) ---- */
220
- .trace-bottom{flex:0 0 320px;min-height:140px;max-height:70vh;overflow-y:scroll;scrollbar-gutter:stable;background:var(--bg-accent);border-top:none}
295
+ /* ---- Detail panel (right pane) ---- */
296
+ .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)}
221
297
  .trace-bottom .detail-panel{background:var(--card);overflow:hidden;border:none;border-radius:0;margin:0;width:100%}
222
298
  .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}
223
299
  .detail-header .dot{width:10px;height:10px;border-radius:50%;flex-shrink:0}
@@ -226,11 +302,28 @@ body.resizing *{cursor:row-resize!important}
226
302
  .detail-meta{padding:10px 18px;display:flex;gap:16px;flex-wrap:wrap;font-size:12px;color:var(--muted);border-bottom:1px solid var(--border)}
227
303
  .detail-meta b{color:var(--text-strong)}
228
304
  .detail-body{display:grid;grid-template-columns:1fr 1fr;gap:0;overflow:hidden}
305
+ .trace-bottom .detail-body{grid-template-columns:1fr}
229
306
  @media(max-width:800px){.detail-body{grid-template-columns:1fr}}
230
307
  .detail-section{padding:12px 18px;border-right:1px solid var(--border);overflow:hidden;min-width:0}
308
+ .trace-bottom .detail-section{border-right:none;border-bottom:1px solid var(--border)}
309
+ .trace-bottom .detail-section:last-child{border-bottom:none}
231
310
  .detail-section:last-child{border-right:none}
232
311
  .detail-section h4{font-size:12px;color:var(--accent);text-transform:uppercase;letter-spacing:.04em;margin-bottom:8px;font-weight:600}
233
312
  .json-view{background:var(--secondary);padding:10px 12px;border-radius:var(--radius-md);font-family:var(--mono);font-size:12px;line-height:1.6;white-space:pre-wrap;word-break:break-all;max-height:none;overflow-y:auto;color:var(--text);border:1px solid var(--border)}
313
+ .kv-view{background:var(--bg-elevated);border:1px solid var(--border);border-radius:var(--radius-md);padding:4px 12px}
314
+ .kv-item{padding:10px 0;border-bottom:1px solid var(--border)}
315
+ .kv-item:last-child{border-bottom:none}
316
+ .kv-key{font-size:12px;font-weight:700;color:#3b82f6;line-height:1.4;letter-spacing:.01em}
317
+ :root[data-theme=light] .kv-key{color:#2563eb}
318
+ .kv-value{margin-top:6px;font-family:var(--mono);font-size:12px;line-height:1.6;color:var(--text-strong);white-space:pre-wrap;word-break:break-word}
319
+ .kv-collapse{margin-top:6px}
320
+ .kv-collapse summary{cursor:pointer;color:var(--muted);font-size:11px;user-select:none}
321
+ .kv-collapse summary:hover{color:var(--text)}
322
+ .kv-collapse .kv-value{margin-top:8px}
323
+ .media-preview{margin-top:10px;display:flex;flex-wrap:wrap;gap:10px}
324
+ .media-card{display:block;width:110px;height:110px;border:1px solid var(--border);border-radius:10px;overflow:hidden;background:var(--secondary);box-shadow:inset 0 1px 0 var(--card-highlight);transition:transform var(--duration-fast),border-color var(--duration-fast),box-shadow var(--duration-fast)}
325
+ .media-card:hover{transform:translateY(-1px);border-color:var(--accent);box-shadow:var(--shadow-sm),inset 0 1px 0 var(--card-highlight)}
326
+ .media-card img{width:100%;height:100%;object-fit:cover;display:block;background:var(--bg-accent)}
234
327
  .json-key{color:#a78bfa}
235
328
  .json-str{color:#34d399;white-space:pre-wrap}
236
329
  .json-num{color:#fbbf24}
@@ -316,12 +409,12 @@ body.resizing *{cursor:row-resize!important}
316
409
  .chart-bar-seg{width:100%;min-height:0;transition:opacity var(--duration-fast)}
317
410
  .chart-bar-seg:last-child{border-radius:3px 3px 0 0}
318
411
  .chart-x-labels{display:flex;gap:3px;padding:6px 4px 0;border-top:1px solid var(--border);justify-content:center}
319
- .chart-x-labels span{flex:1;text-align:center;font-size:9px;color:var(--muted);font-family:var(--mono);overflow:hidden;text-overflow:ellipsis;white-space:nowrap;max-width:60px}
412
+ .chart-x-labels span{flex:1;text-align:center;font-size:9px;color:var(--muted);font-family:var(--mono);line-height:1.2;white-space:normal;overflow:visible}
320
413
  .chart-y-axis{display:flex;flex-direction:column;justify-content:space-between;align-items:flex-end;height:160px;padding-right:8px;min-width:40px}
321
414
  .chart-y-axis span{font-size:10px;color:var(--muted);font-family:var(--mono)}
322
415
  .chart-container{display:flex}
323
416
  .chart-main{flex:1;min-width:0}
324
- .chart-tooltip{position:absolute;bottom:100%;left:50%;transform:translateX(-50%);background:var(--card);border:1px solid var(--border);border-radius:var(--radius-sm);padding:4px 8px;font-size:11px;color:var(--text);white-space:nowrap;pointer-events:none;z-index:20;box-shadow:var(--shadow-md);display:none}
417
+ .chart-tooltip{position:absolute;bottom:100%;left:50%;transform:translateX(-50%);background:var(--card);border:1px solid var(--border);border-radius:var(--radius-sm);padding:6px 8px;font-size:11px;color:var(--text);line-height:1.35;white-space:normal;word-break:break-word;max-width:220px;min-width:150px;pointer-events:none;z-index:20;box-shadow:var(--shadow-md);display:none}
325
418
  .chart-bar-col:hover .chart-tooltip{display:block}
326
419
  .chart-legend{display:flex;gap:16px;justify-content:center;margin-top:12px;flex-wrap:wrap}
327
420
  .chart-legend-item{display:flex;align-items:center;gap:5px;font-size:11px;color:var(--muted)}
@@ -377,12 +470,19 @@ body.resizing *{cursor:row-resize!important}
377
470
 
378
471
  /* ---- Responsive ---- */
379
472
  @media(max-width:900px){.stat-grid{grid-template-columns:repeat(3,1fr)}}
473
+ @media(max-width:900px){
474
+ .content--trace .content-inner{flex-direction:column}
475
+ .trace-resize{display:none}
476
+ .trace-bottom{flex:0 0 320px;min-width:0;max-width:none;border-left:none;border-top:1px solid var(--border)}
477
+ .sec-runtime-grid{grid-template-columns:1fr}
478
+ .sec-custom-form{grid-template-columns:1fr}
479
+ }
380
480
  @media(max-width:560px){
381
481
  .stat-grid{grid-template-columns:repeat(2,1fr)}
382
482
  .topbar{padding:0 12px;height:48px}
383
483
  .content{padding:12px}
384
484
  .waterfall{margin:10px}
385
- .wf-header,.wf-row,.wf-ticks{grid-template-columns:200px 70px 1fr}
485
+ .wf-header,.wf-row{grid-template-columns:200px 70px 1fr}
386
486
  }
387
487
  `;
388
488
  /* ================================================================== */
@@ -394,7 +494,7 @@ const CLIENT_JS = `
394
494
  /* ---------- constants ---------- */
395
495
  var API = window.location.pathname.replace(/\\/+$/, '') + '/api';
396
496
  var TYPE_COLORS = {
397
- message:'#8b5cf6', tool_call:'#f59e0b', tool_persist:'#f97316',
497
+ message:'#8b5cf6', assistant_stream:'#7c3aed', thinking:'#0f766e', tool_call:'#f59e0b', tool_update:'#fb923c', tool_persist:'#f97316',
398
498
  prompt_build:'#3b82f6', model_resolve:'#06b6d4', agent_end:'#10b981',
399
499
  session_start:'#22c55e', session_end:'#ef4444', compaction:'#14b8a6',
400
500
  reset:'#f43f5e', user_message:'#0ea5e9', assistant_msg:'#a855f7',
@@ -402,7 +502,7 @@ var TYPE_COLORS = {
402
502
  gateway_start:'#94a3b8', gateway_stop:'#475569'
403
503
  };
404
504
  var TYPE_LABELS = {
405
- message:'LLM Call', tool_call:'Tool Call', tool_persist:'Tool Persist',
505
+ message:'LLM Call', assistant_stream:'Assistant Stream', thinking:'Thinking', tool_call:'Tool Call', tool_update:'Tool Update', tool_persist:'Tool Persist',
406
506
  prompt_build:'Prompt Build', model_resolve:'Model Resolve', agent_end:'Agent End',
407
507
  session_start:'Session Start', session_end:'Session End', compaction:'Compaction',
408
508
  reset:'Reset', user_message:'User Message', assistant_msg:'Assistant Msg',
@@ -463,6 +563,7 @@ applyTheme(getTheme());
463
563
 
464
564
  /* ---------- helpers ---------- */
465
565
  function esc(s) { if (!s) return ''; var d = document.createElement('div'); d.textContent = s; return d.innerHTML; }
566
+ function escapeRegExp(s) { return s.replace(/[.*+?^{}()|[\]\\$]/g, '\\$&'); }
466
567
  function fmtDur(ms) {
467
568
  if (ms == null) return '-';
468
569
  if (ms < 1000) return ms + 'ms';
@@ -487,6 +588,189 @@ function parseJson(s) {
487
588
  if (!s) return null;
488
589
  try { return typeof s === 'string' ? JSON.parse(s) : s; } catch(e) { return s; }
489
590
  }
591
+ function getAuthTokenFromQuery() {
592
+ var params = new URLSearchParams(window.location.search);
593
+ return params.get('token') || '';
594
+ }
595
+ function buildMediaUrl(rawPath) {
596
+ var url = API + '/media?path=' + encodeURIComponent(rawPath);
597
+ var token = getAuthTokenFromQuery();
598
+ if (token) url += '&token=' + encodeURIComponent(token);
599
+ return url;
600
+ }
601
+ function onMediaImgError(imgEl) {
602
+ if (!imgEl) return;
603
+ var card = imgEl.closest('.media-card');
604
+ if (card) card.remove();
605
+ var preview = imgEl.closest('.media-preview');
606
+ if (preview && preview.children.length === 0) {
607
+ preview.remove();
608
+ }
609
+ }
610
+ function collectMediaPaths(value, out, depth) {
611
+ if (!out) out = [];
612
+ if (!depth) depth = 0;
613
+ if (depth > 6 || value == null) return out;
614
+
615
+ if (typeof value === 'string') {
616
+ var s = value;
617
+ var i = 0;
618
+ while (i < s.length) {
619
+ var pos = s.indexOf('file://', i);
620
+ if (pos < 0) break;
621
+ var end = pos;
622
+ while (end < s.length) {
623
+ var ch = s[end];
624
+ if (ch === ' ' || ch === '\\n' || ch === '\\r' || ch === '\\t' || ch === '"' || ch.charCodeAt(0) === 39 || ch === ')' || ch === '<' || ch === '>') break;
625
+ end++;
626
+ }
627
+ var candidate = s.slice(pos, end);
628
+ if (candidate) out.push(candidate);
629
+ i = Math.max(end, pos + 7);
630
+ }
631
+ return out;
632
+ }
633
+ if (Array.isArray(value)) {
634
+ value.forEach(function(v) { collectMediaPaths(v, out, depth + 1); });
635
+ return out;
636
+ }
637
+ if (typeof value === 'object') {
638
+ Object.keys(value).forEach(function(k) { collectMediaPaths(value[k], out, depth + 1); });
639
+ return out;
640
+ }
641
+ return out;
642
+ }
643
+ function getToolCallId(action) {
644
+ function readToolCallId(obj) {
645
+ if (!obj || typeof obj !== 'object') return '';
646
+ if (typeof obj.toolCallId === 'string' && obj.toolCallId) return obj.toolCallId;
647
+ if (typeof obj.tool_call_id === 'string' && obj.tool_call_id) return obj.tool_call_id;
648
+ if (obj.toolCall && typeof obj.toolCall === 'object') {
649
+ if (typeof obj.toolCall.id === 'string' && obj.toolCall.id) return obj.toolCall.id;
650
+ }
651
+ return '';
652
+ }
653
+ var input = parseJson(action && action.input_params);
654
+ var id = readToolCallId(input);
655
+ if (id) return id;
656
+ var output = parseJson(action && action.output_result);
657
+ return readToolCallId(output);
658
+ }
659
+ function getActionRunId(action) {
660
+ if (!action) return '';
661
+ var input = parseJson(action.input_params);
662
+ if (input && typeof input === 'object' && typeof input.runId === 'string' && input.runId) {
663
+ return input.runId;
664
+ }
665
+ var output = parseJson(action.output_result);
666
+ if (output && typeof output === 'object' && typeof output.runId === 'string' && output.runId) {
667
+ return output.runId;
668
+ }
669
+ return '';
670
+ }
671
+ function normalizeTraceSearch(s) {
672
+ return (s || '').trim().toLowerCase();
673
+ }
674
+ function stringifySearchValue(v) {
675
+ if (v == null) return '';
676
+ if (typeof v === 'string') return v;
677
+ try { return JSON.stringify(v); } catch(e) { return String(v); }
678
+ }
679
+ function getActionSearchText(action) {
680
+ return [
681
+ action.action_name,
682
+ action.model_name,
683
+ action.input_params,
684
+ action.output_result
685
+ ].map(stringifySearchValue).filter(Boolean).join('\\n');
686
+ }
687
+ function getActionSearchSnippet(action, query) {
688
+ if (!query) return '';
689
+ var fields = [
690
+ action.action_name,
691
+ action.input_params,
692
+ action.output_result,
693
+ action.model_name
694
+ ];
695
+ for (var i = 0; i < fields.length; i++) {
696
+ var text = stringifySearchValue(fields[i]);
697
+ if (!text) continue;
698
+ var normalized = text.toLowerCase();
699
+ var idx = normalized.indexOf(query);
700
+ if (idx < 0) continue;
701
+ var start = Math.max(0, idx - 36);
702
+ var end = Math.min(text.length, idx + query.length + 52);
703
+ var snippet = text.slice(start, end).replace(/\s+/g, ' ').trim();
704
+ if (start > 0) snippet = '...' + snippet;
705
+ if (end < text.length) snippet = snippet + '...';
706
+ return snippet;
707
+ }
708
+ return '';
709
+ }
710
+ function highlightSearchText(text, query) {
711
+ var safeText = esc(text || '');
712
+ if (!query) return safeText;
713
+ var re = new RegExp('(' + escapeRegExp(query) + ')', 'ig');
714
+ return safeText.replace(re, '<span class="search-hit">$1</span>');
715
+ }
716
+
717
+ function isToolActionType(actionType) {
718
+ return typeof actionType === 'string' && actionType.indexOf('tool_') === 0;
719
+ }
720
+
721
+ var KV_VALUE_COLLAPSE_LIMIT = 2000;
722
+
723
+ function collectTopLevelEntries(value) {
724
+ if (value === null || value === undefined) return [];
725
+ if (typeof value !== 'object' || Array.isArray(value)) {
726
+ return [{ key: 'value', value: value }];
727
+ }
728
+
729
+ var keys = Object.keys(value);
730
+ if (keys.length === 0) return [{ key: 'value', value: '{}' }];
731
+ return keys.map(function(k) {
732
+ return { key: k, value: value[k] };
733
+ });
734
+ }
735
+
736
+ function renderKvValue(raw) {
737
+ var text;
738
+ if (raw === null || raw === undefined) {
739
+ text = 'null';
740
+ } else if (typeof raw === 'string') {
741
+ text = raw;
742
+ } else if (typeof raw === 'object') {
743
+ try { text = JSON.stringify(raw, null, 2); } catch(e) { text = String(raw); }
744
+ } else {
745
+ text = String(raw);
746
+ }
747
+
748
+ if (text.length > KV_VALUE_COLLAPSE_LIMIT) {
749
+ return '<details class="kv-collapse">' +
750
+ '<summary>Show value (' + text.length + ' chars)</summary>' +
751
+ '<div class="kv-value">' + esc(text) + '</div>' +
752
+ '</details>';
753
+ }
754
+ return '<div class="kv-value">' + esc(text) + '</div>';
755
+ }
756
+
757
+ function renderStructuredFields(value) {
758
+ var entries = collectTopLevelEntries(value);
759
+ if (!entries.length) {
760
+ return '<div class="json-view" style="color:var(--muted)">null</div>';
761
+ }
762
+
763
+ var html = '<div class="kv-view">';
764
+ for (var i = 0; i < entries.length; i++) {
765
+ var item = entries[i];
766
+ html += '<div class="kv-item">';
767
+ html += '<div class="kv-key">' + esc(item.key) + '</div>';
768
+ html += renderKvValue(item.value);
769
+ html += '</div>';
770
+ }
771
+ html += '</div>';
772
+ return html;
773
+ }
490
774
 
491
775
  /* ---------- Pretty JSON renderer (recursive) ---------- */
492
776
  function prettyJson(val, indent) {
@@ -553,12 +837,28 @@ async function fetchApi(path) {
553
837
  if (!resp.ok) throw new Error('API error: ' + resp.status);
554
838
  return resp.json();
555
839
  }
840
+ async function postApi(path, payload) {
841
+ var urlParams = new URLSearchParams(window.location.search);
842
+ var token = urlParams.get('token');
843
+ var headers = { 'Content-Type': 'application/json' };
844
+ if (token) {
845
+ headers['Authorization'] = 'Bearer ' + token;
846
+ }
847
+ var resp = await fetch(API + path, {
848
+ method: 'POST',
849
+ headers: headers,
850
+ body: JSON.stringify(payload || {})
851
+ });
852
+ if (!resp.ok) throw new Error('API error: ' + resp.status);
853
+ return resp.json();
854
+ }
556
855
 
557
856
  /* ---------- SVG icons ---------- */
558
857
  var ICON_BACK = '<svg viewBox="0 0 24 24"><path d="M19 12H5M12 19l-7-7 7-7"/></svg>';
559
858
  var ICON_SUN = '<svg viewBox="0 0 24 24"><circle cx="12" cy="12" r="5"/><path d="M12 1v2M12 21v2M4.22 4.22l1.42 1.42M18.36 18.36l1.42 1.42M1 12h2M21 12h2M4.22 19.78l1.42-1.42M18.36 5.64l1.42-1.42"/></svg>';
560
859
  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>';
561
860
  var ICON_ACTIVITY = '<svg viewBox="0 0 24 24"><polyline points="22 12 18 12 15 21 9 3 6 12 2 12"/></svg>';
861
+ 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>';
562
862
 
563
863
  /* ---------- router ---------- */
564
864
  function parseHashParams(hash) {
@@ -673,6 +973,7 @@ async function renderTraceList() {
673
973
  html += '</div>';
674
974
  });
675
975
  html += '</div></div>';
976
+ html += '<button class="icon-refresh-btn" onclick="refreshTraceList()" title="Refresh trace list">' + ICON_REFRESH + '</button>';
676
977
  if (hasFilter) {
677
978
  html += '<button class="btn-clear" onclick="clearFilter()">✕ Clear</button>';
678
979
  }
@@ -685,7 +986,11 @@ async function renderTraceList() {
685
986
  if (sessData.sessions.length === 0) {
686
987
  html += '<div class="empty"><div class="icon">📭</div><div class="text">No sessions recorded yet</div></div>';
687
988
  } else {
688
- sessData.sessions.forEach(function(s) {
989
+ var visibleSessions = (sessData.sessions || []).filter(function(s) {
990
+ var sid = String((s && s.session_id) || '').toLowerCase();
991
+ return sid !== 'unknown' && sid !== 'unkown' && sid !== '-';
992
+ });
993
+ visibleSessions.forEach(function(s) {
689
994
  var dur = (s.start_time && s.end_time)
690
995
  ? fmtDur(new Date(s.end_time).getTime() - new Date(s.start_time).getTime())
691
996
  : '-';
@@ -705,6 +1010,9 @@ async function renderTraceList() {
705
1010
  html += '</div>';
706
1011
  html += '</div>';
707
1012
  });
1013
+ if (visibleSessions.length === 0) {
1014
+ html += '<div class="empty"><div class="icon">📭</div><div class="text">No sessions recorded yet</div></div>';
1015
+ }
708
1016
  }
709
1017
 
710
1018
  html += '</div>';
@@ -749,6 +1057,10 @@ window.clearFilter = function() {
749
1057
  renderTraceList();
750
1058
  };
751
1059
 
1060
+ window.refreshTraceList = function() {
1061
+ renderTraceList();
1062
+ };
1063
+
752
1064
  window.toggleTimeMenu = function(e) {
753
1065
  e.stopPropagation();
754
1066
  var menu = document.getElementById('time-menu');
@@ -854,7 +1166,7 @@ async function renderDashboard() {
854
1166
  // Traces by time (bar chart)
855
1167
  html += '<div class="an-card">';
856
1168
  var gran = data.granularity || 'day';
857
- var granLabel = gran === 'hour' ? (ts.length + ' hours') : (ts.length + ' days');
1169
+ var granLabel = gran === 'hour' ? anGetTimeLabel() : (ts.length + ' days');
858
1170
  html += '<h3><span class="icon">📊</span> Activity Over Time';
859
1171
  html += '<span class="sub">' + granLabel + '</span></h3>';
860
1172
  html += buildTimeSeriesChart(ts, anMetricTab, gran);
@@ -916,6 +1228,8 @@ function buildTimeSeriesChart(ts, metric, gran) {
916
1228
  if (metric === 'actions') return { v1: p.actions, v2: 0, total: p.actions, label: p.date };
917
1229
  return { v1: p.sessions, v2: 0, total: p.sessions, label: p.date };
918
1230
  });
1231
+ // Keep chronological order (older -> newer, left to right)
1232
+ vals = vals.slice();
919
1233
  var maxVal = Math.max.apply(null, vals.map(function(v){ return v.total; }));
920
1234
  if (maxVal === 0) maxVal = 1;
921
1235
 
@@ -934,24 +1248,41 @@ function buildTimeSeriesChart(ts, metric, gran) {
934
1248
  var barColor1 = metric === 'tokens' ? '#8b5cf6' : (metric === 'actions' ? '#3b82f6' : 'var(--accent)');
935
1249
  var barColor2 = '#f59e0b';
936
1250
 
937
- vals.forEach(function(v) {
938
- var pct = Math.max((v.total / maxVal) * 100, 1);
939
- var dayLabel;
1251
+ function formatFullTimeLabel(rawLabel) {
1252
+ var raw = String(rawLabel || '');
1253
+ if (!raw) return '-';
1254
+ // hour bucket: "YYYY-MM-DD HH" -> "YYYY-MM-DD HH:00:00"
1255
+ if (gran === 'hour' && raw.length >= 13) {
1256
+ return raw.slice(0, 13) + ':00:00';
1257
+ }
1258
+ // day bucket: "YYYY-MM-DD" -> "YYYY-MM-DD 00:00:00"
1259
+ if (raw.length >= 10) {
1260
+ return raw.slice(0, 10) + ' 00:00:00';
1261
+ }
1262
+ return raw;
1263
+ }
1264
+
1265
+ function formatAxisLabel(rawLabel) {
1266
+ var raw = String(rawLabel || '');
1267
+ if (!raw) return '';
940
1268
  if (gran === 'hour') {
941
- // "2026-03-12 14" "14:00"
942
- var hourPart = v.label.length >= 13 ? v.label.slice(11, 13) : v.label;
943
- dayLabel = hourPart + ':00';
944
- } else {
945
- dayLabel = v.label.length > 5 ? v.label.slice(5) : v.label; // MM-DD
1269
+ // "2026-03-12 14" -> "03-12<br>14:00"
1270
+ if (raw.length >= 13) return raw.slice(5, 10) + '<br>' + raw.slice(11, 13) + ':00';
1271
+ return raw;
946
1272
  }
1273
+ // "2026-03-12" -> "03-12"
1274
+ return raw.length >= 10 ? raw.slice(5, 10) : raw;
1275
+ }
1276
+
1277
+ vals.forEach(function(v) {
1278
+ var pct = Math.max((v.total / maxVal) * 100, 1);
1279
+ var fullLabel = formatFullTimeLabel(v.label);
947
1280
  h += '<div class="chart-bar-col">';
948
- h += '<div class="chart-tooltip">' + v.label + ': ' + fmtNum(v.total);
1281
+ h += '<div class="chart-tooltip"><div><b>Time:</b> ' + esc(fullLabel) + '</div><div><b>Value:</b> ' + fmtNum(v.total);
949
1282
  if (showTokenSplit) h += ' (in:' + fmtNum(v.v1) + ' out:' + fmtNum(v.v2) + ')';
950
- h += '</div>';
1283
+ h += '</div></div>';
951
1284
 
952
1285
  if (showTokenSplit && v.v1 + v.v2 > 0) {
953
- var pct1 = (v.v1 / maxVal) * 100;
954
- var pct2 = (v.v2 / maxVal) * 100;
955
1286
  h += '<div class="chart-bar-stack" style="height:' + pct + '%">';
956
1287
  h += '<div class="chart-bar-seg" style="height:' + (v.v2 / v.total * 100) + '%;background:' + barColor2 + '"></div>';
957
1288
  h += '<div class="chart-bar-seg" style="height:' + (v.v1 / v.total * 100) + '%;background:' + barColor1 + '"></div>';
@@ -965,16 +1296,13 @@ function buildTimeSeriesChart(ts, metric, gran) {
965
1296
 
966
1297
  // X labels (show max 15 labels)
967
1298
  h += '<div class="chart-x-labels">';
968
- var step = Math.max(1, Math.ceil(vals.length / 15));
1299
+ var maxLabels = gran === 'hour' ? 8 : 10;
1300
+ var step = Math.max(1, Math.ceil(vals.length / maxLabels));
1301
+ var lastIdx = vals.length - 1;
969
1302
  vals.forEach(function(v, i) {
970
- var xLabel;
971
- if (gran === 'hour') {
972
- var hp = v.label.length >= 13 ? v.label.slice(11, 13) : v.label;
973
- xLabel = hp + ':00';
974
- } else {
975
- xLabel = v.label.length > 5 ? v.label.slice(5) : v.label;
976
- }
977
- h += '<span>' + (i % step === 0 ? xLabel : '') + '</span>';
1303
+ var xLabel = formatAxisLabel(v.label);
1304
+ var show = (i === 0) || (i === lastIdx) || (i % step === 0);
1305
+ h += '<span title="' + esc(formatFullTimeLabel(v.label)) + '">' + (show ? xLabel : '') + '</span>';
978
1306
  });
979
1307
  h += '</div>';
980
1308
  h += '</div></div>'; // chart-main, chart-container
@@ -1089,14 +1417,23 @@ function buildActionDistribution(ad) {
1089
1417
  }
1090
1418
 
1091
1419
  function buildAgentsTable(ta) {
1092
- if (!ta || ta.length === 0) {
1420
+ var rows = (ta || []).filter(function(a) {
1421
+ var name = (a && a.agent ? String(a.agent) : '').toLowerCase();
1422
+ return name !== 'unknown' && name !== 'unkown';
1423
+ }).sort(function(a, b) {
1424
+ var aSystem = String(a.agent || '').toLowerCase() === 'system' ? 1 : 0;
1425
+ var bSystem = String(b.agent || '').toLowerCase() === 'system' ? 1 : 0;
1426
+ if (aSystem !== bSystem) return aSystem - bSystem;
1427
+ return b.sessions - a.sessions;
1428
+ });
1429
+
1430
+ if (!rows || rows.length === 0) {
1093
1431
  return '<div class="empty" style="padding:24px"><div class="text">No agent data</div></div>';
1094
1432
  }
1095
1433
 
1096
- var maxSess = Math.max.apply(null, ta.map(function(a){ return a.sessions; }));
1097
1434
  var h = '<table class="an-table">';
1098
1435
  h += '<tr><th>Agent</th><th>Sessions</th><th>Actions</th><th>Tokens</th></tr>';
1099
- ta.forEach(function(a) {
1436
+ rows.forEach(function(a) {
1100
1437
  h += '<tr>';
1101
1438
  h += '<td>🤖 ' + esc(a.agent) + '</td>';
1102
1439
  h += '<td class="mono">' + a.sessions + '</td>';
@@ -1133,14 +1470,35 @@ window.anSwitchMetric = function(metric) {
1133
1470
  /* ================================================================ */
1134
1471
 
1135
1472
  window.__alertCount = 0;
1473
+ var __alertBadgeLoading = false;
1474
+ var __alertBadgeLoaded = false;
1475
+ var __alertBadgeScheduled = false;
1136
1476
 
1137
- // Fetch open alert count on init for navigation badge
1138
- (async function loadAlertBadge() {
1477
+ async function loadAlertBadge() {
1478
+ if (__alertBadgeLoading || __alertBadgeLoaded) return;
1479
+ __alertBadgeLoading = true;
1139
1480
  try {
1140
1481
  var st = await fetchApi('/alerts/stats');
1141
1482
  window.__alertCount = (st.byStatus && st.byStatus.open) || 0;
1483
+ __alertBadgeLoaded = true;
1142
1484
  } catch(e) { /* ignore */ }
1143
- })();
1485
+ __alertBadgeLoading = false;
1486
+ }
1487
+
1488
+ function scheduleAlertBadgeLoad() {
1489
+ if (__alertBadgeScheduled || __alertBadgeLoaded) return;
1490
+ __alertBadgeScheduled = true;
1491
+ var run = function() {
1492
+ setTimeout(function() {
1493
+ loadAlertBadge();
1494
+ }, 800); // avoid competing with first-screen critical requests
1495
+ };
1496
+ if (typeof window.requestIdleCallback === 'function') {
1497
+ window.requestIdleCallback(run, { timeout: 2000 });
1498
+ } else {
1499
+ setTimeout(run, 1200);
1500
+ }
1501
+ }
1144
1502
 
1145
1503
  /* ---- Security filter state ---- */
1146
1504
  var secFilterSearch = '';
@@ -1149,6 +1507,8 @@ var secFilterCategory = '';
1149
1507
  var secFilterStatus = 'open';
1150
1508
  var secFilterTimeRange = '';
1151
1509
  var secPage = 1;
1510
+ var secRuntimeCfg = null;
1511
+ var secActiveRuleKey = 'secretLeakage';
1152
1512
 
1153
1513
  /* ---- severity helpers ---- */
1154
1514
  var SEV_ICON = {critical:'🔴',warn:'🟡',info:'ℹ️'};
@@ -1156,13 +1516,72 @@ var SEV_LABEL = {critical:'CRITICAL',warn:'WARNING',info:'INFO'};
1156
1516
  var CAT_LABEL = {
1157
1517
  secret_leakage:'Secret Leakage',high_risk_operation:'High Risk Operation',
1158
1518
  data_exfiltration:'Data Exfiltration',prompt_injection:'Prompt Injection',
1519
+ custom_regex:'Custom Regex',
1159
1520
  skill_anomaly:'Skill Anomaly'
1160
1521
  };
1161
1522
  var STATUS_LABEL = {open:'Open',acknowledged:'Acknowledged',resolved:'Resolved',false_positive:'False Positive'};
1162
1523
 
1163
1524
  var SEV_OPTIONS = [{k:'',l:'All Severity'},{k:'critical',l:'CRITICAL'},{k:'warn',l:'WARNING'},{k:'info',l:'INFO'}];
1164
- 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:'skill_anomaly',l:'Skill Anomaly'}];
1525
+ 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'}];
1165
1526
  var STA_OPTIONS = [{k:'',l:'All Status'},{k:'open',l:'Open'},{k:'acknowledged',l:'Acknowledged'},{k:'resolved',l:'Resolved'},{k:'false_positive',l:'False Positive'}];
1527
+ var SEC_RULE_META = {
1528
+ secretLeakage: {
1529
+ key: 'secretLeakage',
1530
+ title: 'Secret Leakage',
1531
+ brief: 'Detect exposed AK/SK, token, password and key materials',
1532
+ level: 'critical',
1533
+ description: 'Scans tool input/output and model responses for secret patterns, including keys, tokens and sensitive credential strings.',
1534
+ detects: ['Hardcoded access key or token values', 'Credential output copied from files or env blocks']
1535
+ },
1536
+ highRiskOps: {
1537
+ key: 'highRiskOps',
1538
+ title: 'High Risk Ops',
1539
+ brief: 'Detect dangerous operations such as delete/reset/wipe',
1540
+ level: 'high',
1541
+ description: 'Flags risky tool operations that may alter or destroy data, especially shell and file-system actions.',
1542
+ detects: ['Destructive shell commands', 'Irreversible write/remove actions']
1543
+ },
1544
+ dataExfiltration: {
1545
+ key: 'dataExfiltration',
1546
+ title: 'External Access',
1547
+ brief: 'Detect outbound requests and potential data exfiltration',
1548
+ level: 'high',
1549
+ description: 'Monitors requests to external websites or domains and raises alerts when outbound access may carry sensitive data.',
1550
+ detects: ['Tool calls to non-whitelisted external domains', 'Potential outbound data transfer patterns']
1551
+ },
1552
+ customRegex: {
1553
+ key: 'customRegex',
1554
+ title: 'Custom Regex',
1555
+ brief: 'Customer-defined regex rules with live matching',
1556
+ level: 'medium',
1557
+ description: 'Create your own regex rules for sensitive keywords, IDs, or organization-specific policy patterns.',
1558
+ detects: ['Custom compliance vocabulary', 'Business-specific sensitive identifiers']
1559
+ },
1560
+ promptInjection: {
1561
+ key: 'promptInjection',
1562
+ title: 'Prompt Injection',
1563
+ brief: 'Detect jailbreak or instruction override attempts',
1564
+ level: 'high',
1565
+ description: 'Finds suspicious prompts attempting to override system policy, bypass guardrails or exfiltrate hidden context.',
1566
+ detects: ['Ignore previous instructions patterns', 'Role hijacking and policy bypass phrasing']
1567
+ },
1568
+ chainDetection: {
1569
+ key: 'chainDetection',
1570
+ title: 'Chain Detection',
1571
+ brief: 'Detect suspicious multi-step attack chains',
1572
+ level: 'medium',
1573
+ description: 'Correlates multiple actions in a session and raises alerts for risky sequences that are mild alone but dangerous in combination.',
1574
+ detects: ['Recon + secret read + external write pattern', 'Repeated escalations over short window']
1575
+ }
1576
+ };
1577
+ var SEC_RULE_ORDER = ['secretLeakage', 'highRiskOps', 'promptInjection', 'chainDetection', 'customRegex', 'dataExfiltration'];
1578
+
1579
+ function severityRank(sev) {
1580
+ if (sev === 'critical') return 0;
1581
+ if (sev === 'warn') return 1;
1582
+ if (sev === 'info') return 2;
1583
+ return 3;
1584
+ }
1166
1585
 
1167
1586
  function secGetTimeFromISO() {
1168
1587
  if (!secFilterTimeRange) return '';
@@ -1182,6 +1601,17 @@ function secSelectedLabel(opts, curKey) {
1182
1601
  return found ? found.l : opts[0].l;
1183
1602
  }
1184
1603
 
1604
+ function secRuleEnabled(ruleKey) {
1605
+ if (!secRuntimeCfg) return false;
1606
+ if (ruleKey === 'enabled') return !!secRuntimeCfg.enabled;
1607
+ return !!(secRuntimeCfg.rules && secRuntimeCfg.rules[ruleKey]);
1608
+ }
1609
+
1610
+ function secSafeActiveRuleKey() {
1611
+ if (SEC_RULE_ORDER.indexOf(secActiveRuleKey) >= 0) return secActiveRuleKey;
1612
+ return 'secretLeakage';
1613
+ }
1614
+
1185
1615
  /* build one custom dropdown (same look as Dashboard time picker) */
1186
1616
  function secDropdown(id, icon, opts, curKey) {
1187
1617
  var label = secSelectedLabel(opts, curKey);
@@ -1218,10 +1648,12 @@ async function renderSecurity() {
1218
1648
 
1219
1649
  var res = await Promise.all([
1220
1650
  fetchApi('/alerts/stats'),
1221
- fetchApi('/alerts?' + qs)
1651
+ fetchApi('/alerts?' + qs),
1652
+ fetchApi('/security/config').catch(function(){ return null; })
1222
1653
  ]);
1223
1654
  var alertStats = res[0];
1224
1655
  var alertData = res[1];
1656
+ secRuntimeCfg = res[2] && res[2].config ? res[2].config : null;
1225
1657
 
1226
1658
  // Update badge count
1227
1659
  window.__alertCount = (alertStats.byStatus && alertStats.byStatus.open) || 0;
@@ -1242,6 +1674,107 @@ async function renderSecurity() {
1242
1674
  html += statCard('Last 24h', String(alertStats.recent24h || 0));
1243
1675
  html += '</div>';
1244
1676
 
1677
+ // --- Runtime security rule toggles ---
1678
+ if (secRuntimeCfg) {
1679
+ var activeRuleKey = secSafeActiveRuleKey();
1680
+ var activeMeta = SEC_RULE_META[activeRuleKey] || SEC_RULE_META.secretLeakage;
1681
+ var activeOn = secRuleEnabled(activeRuleKey);
1682
+ var enabledRules = SEC_RULE_ORDER.filter(function(k){
1683
+ return secRuleEnabled(k);
1684
+ }).length;
1685
+ var customRegexRules = Array.isArray(secRuntimeCfg.customRegexRules) ? secRuntimeCfg.customRegexRules : [];
1686
+
1687
+ html += '<div class="sec-runtime">';
1688
+ html += '<div class="sec-runtime-head">';
1689
+ html += '<div>';
1690
+ html += '<div class="sec-runtime-title">Runtime Security Rules</div>';
1691
+ html += '</div>';
1692
+ html += '<div class="sec-runtime-summary">' + enabledRules + '/' + SEC_RULE_ORDER.length + ' rule(s) enabled</div>';
1693
+ html += '</div>';
1694
+
1695
+ html += '<div class="sec-runtime-grid">';
1696
+ html += '<div class="sec-rule-list">';
1697
+ SEC_RULE_ORDER.forEach(function(ruleKey) {
1698
+ var meta = SEC_RULE_META[ruleKey];
1699
+ if (!meta) return;
1700
+ var on = secRuleEnabled(ruleKey);
1701
+ html += '<div class="sec-rule-item' + (ruleKey === activeRuleKey ? ' active' : '') + '" onclick="secSelectRule(\\'' + ruleKey + '\\')">';
1702
+ html += '<div class="sec-rule-copy">';
1703
+ html += '<div class="sec-rule-name">' + esc(meta.title) + '</div>';
1704
+ html += '<div class="sec-rule-brief">' + esc(meta.brief) + '</div>';
1705
+ html += '</div>';
1706
+ html += '<span class="sec-state-pill ' + (on ? 'on' : 'off') + '">' + (on ? 'ON' : 'OFF') + '</span>';
1707
+ html += '<button class="sec-switch ' + (on ? 'on' : 'off') + '" onclick="event.stopPropagation();secToggleRuntime(\\'' + ruleKey + '\\')" aria-label="Toggle ' + esc(meta.title) + '">';
1708
+ html += '<span class="sec-switch-track"></span><span class="sec-switch-thumb"></span>';
1709
+ html += '</button>';
1710
+ html += '</div>';
1711
+ });
1712
+ html += '</div>';
1713
+
1714
+ html += '<div class="sec-rule-detail">';
1715
+ html += '<div class="sec-rule-detail-title">';
1716
+ html += '<h4>' + esc(activeMeta.title) + '</h4>';
1717
+ html += '<span class="sec-state-pill ' + (activeOn ? 'on' : 'off') + '">' + (activeOn ? 'ON' : 'OFF') + '</span>';
1718
+ html += '</div>';
1719
+ html += '<div class="sec-rule-detail-meta">';
1720
+ html += '<span class="sec-meta-chip level-' + esc(activeMeta.level) + '">Risk ' + esc(activeMeta.level || 'unknown') + '</span>';
1721
+ if (activeRuleKey === 'customRegex') {
1722
+ html += '<span class="sec-meta-chip">Severity Fixed: CRITICAL</span>';
1723
+ }
1724
+ html += '</div>';
1725
+ if (activeRuleKey !== 'customRegex') {
1726
+ html += '<div class="sec-rule-explain"><div class="sec-rule-explain-text">' + esc(activeMeta.description || 'No description') + '</div></div>';
1727
+ html += '<div class="sec-rule-detail-list">';
1728
+ html += '<div class="sec-rule-detail-list-title">Coverage examples</div>';
1729
+ (activeMeta.detects || []).forEach(function(item){
1730
+ html += '<div class="sec-rule-detail-list-item">* ' + esc(item) + '</div>';
1731
+ });
1732
+ html += '</div>';
1733
+ }
1734
+
1735
+ if (activeRuleKey === 'customRegex') {
1736
+ html += '<div class="sec-custom-editor">';
1737
+ html += '<div class="sec-rule-detail-list-title">Add custom regex rule</div>';
1738
+ html += '<div class="sec-custom-form">';
1739
+ html += '<div class="sec-custom-field">';
1740
+ html += '<div class="sec-custom-field-label">Rule Name</div>';
1741
+ html += '<input id="sec-cr-name" type="text" placeholder="e.g. Internal Ticket ID">';
1742
+ html += '</div>';
1743
+ html += '<div class="sec-custom-field">';
1744
+ html += '<div class="sec-custom-field-label">Regex Pattern</div>';
1745
+ html += '<input id="sec-cr-pattern" type="text" placeholder="e.g. TKT-[0-9]{6}">';
1746
+ html += '</div>';
1747
+ html += '<button onclick="secAddCustomRegex()">+ Create Rule</button>';
1748
+ html += '</div>';
1749
+
1750
+ html += '<div class="sec-custom-list">';
1751
+ if (customRegexRules.length === 0) {
1752
+ html += '<div class="sec-rule-detail-list-item">No custom regex rules yet.</div>';
1753
+ } else {
1754
+ customRegexRules.forEach(function(rule, idx){
1755
+ var on = rule.enabled !== false;
1756
+ var flags = rule.flags || 'i';
1757
+ html += '<div class="sec-custom-item">';
1758
+ html += '<div class="sec-custom-item-main">';
1759
+ html += '<div class="sec-custom-item-name">' + esc(rule.name || ('Rule ' + (idx + 1))) + '</div>';
1760
+ html += '<div class="sec-custom-item-pattern">/' + esc(rule.pattern || '') + '/' + esc(flags) + ' · ' + esc((rule.severity || 'warn').toUpperCase()) + '</div>';
1761
+ html += '</div>';
1762
+ html += '<div class="sec-custom-item-actions">';
1763
+ html += '<button class="sec-switch ' + (on ? 'on' : 'off') + '" onclick="secToggleCustomRegexRule(' + idx + ')"><span class="sec-switch-track"></span><span class="sec-switch-thumb"></span></button>';
1764
+ html += '<button class="sec-custom-item-del" title="Delete rule" onclick="secDeleteCustomRegexRule(' + idx + ')">Delete</button>';
1765
+ html += '</div>';
1766
+ html += '</div>';
1767
+ });
1768
+ }
1769
+ html += '</div>';
1770
+ html += '</div>';
1771
+ }
1772
+
1773
+ html += '</div>';
1774
+ html += '</div>';
1775
+ html += '</div>';
1776
+ }
1777
+
1245
1778
  // --- Filter bar (same .filter-bar as Dashboard) ---
1246
1779
  var hasFilter = secFilterSearch || secFilterSeverity || secFilterCategory || secFilterStatus || secFilterTimeRange;
1247
1780
  html += '<div class="filter-bar">';
@@ -1282,15 +1815,21 @@ async function renderSecurity() {
1282
1815
  }
1283
1816
  html += '</div>';
1284
1817
 
1818
+ var sortedAlerts = (alertData.alerts || []).slice().sort(function(a, b) {
1819
+ var sevDiff = severityRank(a.severity) - severityRank(b.severity);
1820
+ if (sevDiff !== 0) return sevDiff;
1821
+ return new Date(b.created_at || 0).getTime() - new Date(a.created_at || 0).getTime();
1822
+ });
1823
+
1285
1824
  // --- Alert list ---
1286
1825
  html += '<div class="section-title">Alerts <span class="count">' + alertData.total + (hasFilter ? ' matched' : ' total') + '</span></div>';
1287
1826
  html += '<div class="alert-list">';
1288
1827
 
1289
- if (alertData.alerts.length === 0) {
1828
+ if (sortedAlerts.length === 0) {
1290
1829
  html += '<div class="empty"><div class="icon">✅</div><div class="text">No alerts found</div></div>';
1291
1830
  } else {
1292
- alertData.alerts.forEach(function(a) {
1293
- html += '<div class="alert-card sev-' + a.severity + '" onclick="toggleAlertDetail(\\'' + a.alert_id + '\\')">';
1831
+ sortedAlerts.forEach(function(a) {
1832
+ html += '<div class="alert-card sev-' + a.severity + '" id="alert-card-' + a.alert_id + '" onclick="toggleAlertDetail(\\'' + a.alert_id + '\\')">';
1294
1833
  html += '<div class="alert-top">';
1295
1834
  html += '<span class="alert-sev ' + a.severity + '">' + (SEV_ICON[a.severity]||'') + ' ' + (SEV_LABEL[a.severity]||a.severity) + '</span>';
1296
1835
  html += '<span class="alert-rule">' + esc(a.rule_name) + '</span>';
@@ -1321,7 +1860,6 @@ async function renderSecurity() {
1321
1860
  }
1322
1861
  if (a.status === 'open' || a.status === 'acknowledged') {
1323
1862
  html += '<button class="btn-resolve" onclick="event.stopPropagation();updateAlertSt(\\'' + a.alert_id + '\\',\\'resolved\\')">✓ Resolve</button>';
1324
- html += '<button class="btn-dismiss" onclick="event.stopPropagation();updateAlertSt(\\'' + a.alert_id + '\\',\\'false_positive\\')">✕ False Positive</button>';
1325
1863
  }
1326
1864
  html += '</div>';
1327
1865
  html += '</div>';
@@ -1393,6 +1931,98 @@ window.secClearFilter = function() {
1393
1931
  renderSecurity();
1394
1932
  };
1395
1933
 
1934
+ window.secSelectRule = function(ruleKey) {
1935
+ if (SEC_RULE_ORDER.indexOf(ruleKey) < 0) return;
1936
+ secActiveRuleKey = ruleKey;
1937
+ renderSecurity();
1938
+ };
1939
+
1940
+ window.secToggleRuntime = async function(key) {
1941
+ if (!secRuntimeCfg) return;
1942
+ var patch = {};
1943
+ if (key === 'enabled') {
1944
+ patch.enabled = !secRuntimeCfg.enabled;
1945
+ } else {
1946
+ patch.rules = {};
1947
+ patch.rules[key] = !secRuntimeCfg.rules[key];
1948
+ }
1949
+ try {
1950
+ await postApi('/security/config', patch);
1951
+ renderSecurity();
1952
+ } catch (e) {
1953
+ console.error('Failed to update security config:', e);
1954
+ }
1955
+ };
1956
+
1957
+ window.secAddCustomRegex = async function() {
1958
+ if (!secRuntimeCfg) return;
1959
+ var nameEl = document.getElementById('sec-cr-name');
1960
+ var patternEl = document.getElementById('sec-cr-pattern');
1961
+ var name = nameEl ? nameEl.value.trim() : '';
1962
+ var pattern = patternEl ? patternEl.value.trim() : '';
1963
+ var flags = 'i';
1964
+ var severity = 'critical';
1965
+ if (!pattern) return;
1966
+
1967
+ try {
1968
+ new RegExp(pattern, flags || 'i');
1969
+ } catch (e) {
1970
+ alert('Invalid regex pattern or flags');
1971
+ return;
1972
+ }
1973
+
1974
+ var nextRules = Array.isArray(secRuntimeCfg.customRegexRules) ? secRuntimeCfg.customRegexRules.slice() : [];
1975
+ nextRules.push({
1976
+ id: 'cr_' + Date.now() + '_' + Math.floor(Math.random() * 10000),
1977
+ name: name || 'Custom Regex',
1978
+ pattern: pattern,
1979
+ flags: flags || 'i',
1980
+ severity: severity,
1981
+ enabled: true
1982
+ });
1983
+
1984
+ try {
1985
+ await postApi('/security/config', { customRegexRules: nextRules });
1986
+ renderSecurity();
1987
+ } catch (e) {
1988
+ console.error('Failed to add custom regex rule:', e);
1989
+ }
1990
+ };
1991
+
1992
+ window.secToggleCustomRegexRule = async function(idx) {
1993
+ if (!secRuntimeCfg || !Array.isArray(secRuntimeCfg.customRegexRules)) return;
1994
+ if (idx < 0 || idx >= secRuntimeCfg.customRegexRules.length) return;
1995
+ var nextRules = secRuntimeCfg.customRegexRules.slice();
1996
+ var rule = nextRules[idx];
1997
+ nextRules[idx] = {
1998
+ id: rule.id,
1999
+ name: rule.name,
2000
+ pattern: rule.pattern,
2001
+ flags: rule.flags,
2002
+ severity: rule.severity,
2003
+ enabled: rule.enabled === false ? true : false
2004
+ };
2005
+ try {
2006
+ await postApi('/security/config', { customRegexRules: nextRules });
2007
+ renderSecurity();
2008
+ } catch (e) {
2009
+ console.error('Failed to toggle custom regex rule:', e);
2010
+ }
2011
+ };
2012
+
2013
+ window.secDeleteCustomRegexRule = async function(idx) {
2014
+ if (!secRuntimeCfg || !Array.isArray(secRuntimeCfg.customRegexRules)) return;
2015
+ if (idx < 0 || idx >= secRuntimeCfg.customRegexRules.length) return;
2016
+ var nextRules = secRuntimeCfg.customRegexRules.slice();
2017
+ nextRules.splice(idx, 1);
2018
+ try {
2019
+ await postApi('/security/config', { customRegexRules: nextRules });
2020
+ renderSecurity();
2021
+ } catch (e) {
2022
+ console.error('Failed to delete custom regex rule:', e);
2023
+ }
2024
+ };
2025
+
1396
2026
  window.secGoPage = function(p) {
1397
2027
  if (p < 1) return;
1398
2028
  secPage = p;
@@ -1401,12 +2031,34 @@ window.secGoPage = function(p) {
1401
2031
 
1402
2032
  window.updateAlertSt = async function(alertId, newStatus) {
1403
2033
  try {
1404
- await fetch(API + '/alerts/' + encodeURIComponent(alertId) + '/status', {
1405
- method: 'POST',
1406
- headers: {'Content-Type': 'application/json'},
1407
- body: JSON.stringify({status: newStatus})
1408
- });
1409
- renderSecurity();
2034
+ await postApi('/alerts/' + encodeURIComponent(alertId) + '/status', { status: newStatus });
2035
+
2036
+ var card = document.getElementById('alert-card-' + alertId);
2037
+ if (card) {
2038
+ card.style.transition = 'opacity .12s ease, transform .12s ease';
2039
+ card.style.opacity = '0';
2040
+ card.style.transform = 'translateY(-2px)';
2041
+ setTimeout(function() {
2042
+ if (card && card.parentNode) card.parentNode.removeChild(card);
2043
+ }, 130);
2044
+ }
2045
+
2046
+ if (window.__alertCount > 0) {
2047
+ window.__alertCount -= 1;
2048
+ }
2049
+
2050
+ var statNodes = document.querySelectorAll('.stat .stat-label');
2051
+ for (var i = 0; i < statNodes.length; i++) {
2052
+ var labelNode = statNodes[i];
2053
+ if (!labelNode || !labelNode.textContent) continue;
2054
+ if (labelNode.textContent.trim().toLowerCase() === 'open') {
2055
+ var valueNode = labelNode.parentElement ? labelNode.parentElement.querySelector('.stat-value') : null;
2056
+ if (valueNode) {
2057
+ var cur = parseInt((valueNode.textContent || '0').trim(), 10);
2058
+ if (!isNaN(cur) && cur > 0) valueNode.textContent = String(cur - 1);
2059
+ }
2060
+ }
2061
+ }
1410
2062
  } catch(e) {
1411
2063
  console.error('Failed to update alert:', e);
1412
2064
  }
@@ -1425,9 +2077,16 @@ document.addEventListener('click', function() {
1425
2077
  /* ================================================================ */
1426
2078
 
1427
2079
  var selectedActionIdx = -1;
2080
+ var currentTraceSessionId = '';
2081
+ var currentTraceHighlightAction = '';
2082
+ var currentTraceHighlightTime = '';
2083
+ var traceSearchQuery = '';
1428
2084
 
1429
2085
  async function renderTraceDetail(sessionId, highlightAction, highlightTime) {
1430
2086
  selectedActionIdx = -1;
2087
+ currentTraceSessionId = sessionId;
2088
+ currentTraceHighlightAction = highlightAction || '';
2089
+ currentTraceHighlightTime = highlightTime || '';
1431
2090
  app.innerHTML = renderLayout('trace',
1432
2091
  '<div class="trace-header"><div class="loading">Loading trace...</div></div>');
1433
2092
 
@@ -1459,61 +2118,289 @@ async function renderTraceDetail(sessionId, highlightAction, highlightTime) {
1459
2118
  action: a,
1460
2119
  startMs: startMs,
1461
2120
  endMs: endMs,
2121
+ displayDurationMs: a.duration_ms,
1462
2122
  level: 0,
1463
2123
  children: []
1464
2124
  };
1465
2125
  });
1466
2126
 
2127
+ function findOwnerLlmStartMs(thinkingSpan, allSpans) {
2128
+ var owner = null;
2129
+ for (var i = 0; i < allSpans.length; i++) {
2130
+ var span = allSpans[i];
2131
+ if (span.action.action_type !== 'message') continue;
2132
+ if (!span.action.action_name || span.action.action_name.indexOf('llm_call:') !== 0) continue;
2133
+ if (span.startMs <= thinkingSpan.endMs && span.endMs >= thinkingSpan.endMs) {
2134
+ if (!owner || span.endMs < owner.endMs) owner = span;
2135
+ }
2136
+ }
2137
+ return owner ? owner.startMs : null;
2138
+ }
2139
+
2140
+ // For thinking rows without explicit duration, infer a visible span from the
2141
+ // previous action end to current thinking timestamp.
2142
+ var spansByEndTime = spans.slice().sort(function(a, b) {
2143
+ var diff = a.endMs - b.endMs;
2144
+ if (diff !== 0) return diff;
2145
+ return a.idx - b.idx;
2146
+ });
2147
+ var prevEndMs = null;
2148
+ spansByEndTime.forEach(function(span) {
2149
+ if (
2150
+ span.action.action_type === 'thinking' &&
2151
+ (span.action.duration_ms == null || span.action.duration_ms <= 0) &&
2152
+ prevEndMs != null &&
2153
+ span.endMs > prevEndMs
2154
+ ) {
2155
+ var ownerLlmStartMs = findOwnerLlmStartMs(span, spans);
2156
+ var inferredStartMs = prevEndMs;
2157
+ if (ownerLlmStartMs != null) {
2158
+ inferredStartMs = Math.max(inferredStartMs, ownerLlmStartMs);
2159
+ }
2160
+ span.startMs = inferredStartMs;
2161
+ span.displayDurationMs = span.endMs - span.startMs;
2162
+ }
2163
+ prevEndMs = Math.max(prevEndMs == null ? span.endMs : prevEndMs, span.endMs);
2164
+ });
2165
+
1467
2166
  // Determine time range
1468
2167
  var sessionStart = Math.min.apply(null, spans.map(function(s){ return s.startMs; }));
1469
2168
  var sessionEnd = Math.max.apply(null, spans.map(function(s){ return s.endMs; }));
1470
2169
  var totalDur = sessionEnd - sessionStart || 1;
1471
2170
 
1472
2171
  // ---- Sorting + auto-nesting ----
1473
- // Semantic priority: some action_types represent outer lifecycle, should rank first as parents
1474
- // agent_end = entire agent execution cycle, message(llm_call) = LLM call cycle
2172
+ // We keep the data in chronological order inside a trace. Structural span
2173
+ // priority is only a tie-breaker for near-identical timestamps.
1475
2174
  function typePriority(actionType) {
1476
- if (actionType === 'agent_end') return 0; // outermost
1477
- if (actionType === 'message') return 1; // LLM call
1478
- return 10; // others
2175
+ if (actionType === 'agent_end') return 0;
2176
+ if (actionType === 'message') return 1;
2177
+ if (actionType === 'model_resolve') return 2;
2178
+ if (actionType === 'prompt_build') return 3;
2179
+ if (actionType === 'tool_call') return 2;
2180
+ if (actionType === 'assistant_stream') return 4;
2181
+ if (actionType === 'thinking') return 5;
2182
+ if (actionType === 'tool_update') return 6;
2183
+ return 10;
2184
+ }
2185
+ function canBecomeParent(actionType) {
2186
+ return actionType === 'agent_end' || actionType === 'message' || actionType === 'tool_call';
1479
2187
  }
1480
2188
  // Sort by startMs asc; when startMs is close (within 2s), prioritize by semantic priority; then by duration desc
1481
2189
  spans.sort(function(a, b) {
1482
2190
  var startDiff = a.startMs - b.startMs;
1483
- if (Math.abs(startDiff) > 2000) return startDiff;
2191
+ if (startDiff !== 0) return startDiff;
1484
2192
  var priA = typePriority(a.action.action_type);
1485
2193
  var priB = typePriority(b.action.action_type);
1486
2194
  if (priA !== priB) return priA - priB;
1487
- return (b.endMs - b.startMs) - (a.endMs - a.startMs);
2195
+ var endDiff = a.endMs - b.endMs;
2196
+ if (endDiff !== 0) return endDiff;
2197
+ return a.idx - b.idx;
1488
2198
  });
1489
2199
 
2200
+ var toolParentsById = {};
2201
+ spans.forEach(function(span) {
2202
+ if (span.action.action_type === 'tool_call') {
2203
+ var toolCallId = getToolCallId(span.action);
2204
+ if (!toolCallId) return;
2205
+ if (!toolParentsById[toolCallId]) toolParentsById[toolCallId] = [];
2206
+ toolParentsById[toolCallId].push(span);
2207
+ }
2208
+ });
2209
+ Object.keys(toolParentsById).forEach(function(id) {
2210
+ toolParentsById[id].sort(function(a, b) {
2211
+ var diff = a.startMs - b.startMs;
2212
+ if (diff !== 0) return diff;
2213
+ return a.idx - b.idx;
2214
+ });
2215
+ });
2216
+
2217
+ function resolveExactToolParent(span, toolCallId) {
2218
+ var candidates = toolParentsById[toolCallId];
2219
+ if (!candidates || candidates.length === 0) return null;
2220
+ var containing = null;
2221
+ for (var i = 0; i < candidates.length; i++) {
2222
+ var parent = candidates[i];
2223
+ if (parent.startMs <= span.endMs + 50 && parent.endMs >= span.startMs - 1000) {
2224
+ containing = parent;
2225
+ }
2226
+ }
2227
+ if (containing) return containing;
2228
+
2229
+ var nearest = null;
2230
+ var nearestGap = Infinity;
2231
+ for (var j = 0; j < candidates.length; j++) {
2232
+ var p = candidates[j];
2233
+ var gap = Math.abs(p.endMs - span.endMs);
2234
+ if (gap < nearestGap) {
2235
+ nearest = p;
2236
+ nearestGap = gap;
2237
+ }
2238
+ }
2239
+ return nearest;
2240
+ }
2241
+
1490
2242
  // Auto-nesting: use "open parent" stack to determine depth
1491
2243
  // If a span's time range is fully contained within an earlier span, indent it
1492
- var parentStack = []; // { startMs, endMs, level }
2244
+ var parentStack = []; // { startMs, endMs, level, actionType }
1493
2245
  var flatSpans = [];
1494
2246
 
1495
2247
  spans.forEach(function(span) {
2248
+ var exactToolParent = null;
2249
+ var spanToolCallId = getToolCallId(span.action);
2250
+ if (span.action.action_type !== 'tool_call' && spanToolCallId) {
2251
+ exactToolParent = resolveExactToolParent(span, spanToolCallId);
2252
+ }
2253
+
1496
2254
  // Pop completed parents
1497
2255
  while (parentStack.length > 0 &&
1498
2256
  parentStack[parentStack.length - 1].endMs < span.startMs - 1000) {
1499
2257
  parentStack.pop();
1500
2258
  }
1501
2259
 
1502
- // Current span depth = number of containing parents
1503
- span.level = parentStack.length;
2260
+ // Prefer exact tool-call linkage over duration-based guesses.
2261
+ if (exactToolParent) {
2262
+ span.level = exactToolParent.level + 1;
2263
+ } else if (span.action.action_type === 'tool_call') {
2264
+ // Tool calls should be peer spans under the current non-tool parent
2265
+ // (e.g. under llm_call), not nested under previous tool_call rows.
2266
+ span.level = parentStack.filter(function(p) {
2267
+ return p.actionType !== 'tool_call';
2268
+ }).length;
2269
+ } else if (span.action.action_type === 'thinking') {
2270
+ // Keep thinking on the same layer as tool_call to avoid drifting
2271
+ // into nested tool sub-rows.
2272
+ span.level = parentStack.filter(function(p) {
2273
+ return p.actionType !== 'tool_call';
2274
+ }).length;
2275
+ } else {
2276
+ // Current span depth = number of containing parents
2277
+ span.level = parentStack.length;
2278
+ }
1504
2279
 
1505
2280
  flatSpans.push(span);
1506
2281
 
1507
- // Spans with duration can become parents ("wrapper" bars in waterfall chart)
2282
+ // Only structural spans with real duration can become parents in the tree.
1508
2283
  var dur = span.endMs - span.startMs;
1509
- if (dur > 100) { // only spans > 100ms become parents
1510
- parentStack.push({ startMs: span.startMs, endMs: span.endMs, level: span.level });
2284
+ if (dur > 100 && canBecomeParent(span.action.action_type)) {
2285
+ parentStack.push({
2286
+ startMs: span.startMs,
2287
+ endMs: span.endMs,
2288
+ level: span.level,
2289
+ actionType: span.action.action_type
2290
+ });
1511
2291
  }
1512
2292
  });
1513
2293
 
1514
- // Session metadata
1515
- var firstAction = actions[0];
1516
- var lastAction = actions[actions.length - 1];
2294
+ flatSpans.forEach(function(span, idx) {
2295
+ span.flatIdx = idx;
2296
+ });
2297
+
2298
+ function splitTraceGroups(spansInOrder) {
2299
+ var runGroupsById = {};
2300
+ var runGroups = [];
2301
+
2302
+ spansInOrder.forEach(function(span) {
2303
+ var runId = getActionRunId(span.action);
2304
+ if (!runId) return;
2305
+ if (!runGroupsById[runId]) {
2306
+ var group = {
2307
+ runId: runId,
2308
+ rootSpan: null,
2309
+ items: [],
2310
+ startMs: span.startMs,
2311
+ endMs: span.endMs
2312
+ };
2313
+ runGroupsById[runId] = group;
2314
+ runGroups.push(group);
2315
+ }
2316
+ var g = runGroupsById[runId];
2317
+ g.items.push(span);
2318
+ g.startMs = Math.min(g.startMs, span.startMs);
2319
+ g.endMs = Math.max(g.endMs, span.endMs);
2320
+ });
2321
+
2322
+ if (runGroups.length > 0) {
2323
+ return runGroups
2324
+ .map(function(g) { return { runId: g.runId, rootSpan: null, items: g.items }; })
2325
+ .filter(function(g) { return g.items && g.items.length; });
2326
+ }
2327
+
2328
+ var rootSpans = spansInOrder.filter(function(span) {
2329
+ return span.level === 0 && span.action.action_type === 'agent_end';
2330
+ });
2331
+
2332
+ if (rootSpans.length === 0) {
2333
+ return spansInOrder.length ? [{ rootSpan: null, items: spansInOrder.slice() }] : [];
2334
+ }
2335
+
2336
+ var groups = rootSpans.map(function(rootSpan) {
2337
+ return {
2338
+ rootSpan: rootSpan,
2339
+ items: [],
2340
+ windowStart: rootSpan.startMs,
2341
+ windowEnd: rootSpan.endMs + 1000
2342
+ };
2343
+ });
2344
+
2345
+ var orphanSpans = [];
2346
+
2347
+ spansInOrder.forEach(function(span) {
2348
+ if (span.level === 0 && span.action.action_type === 'agent_end') return;
2349
+
2350
+ var targetGroup = null;
2351
+ for (var i = 0; i < groups.length; i++) {
2352
+ var group = groups[i];
2353
+ var overlapsWindow = span.endMs >= group.windowStart - 1000 && span.startMs <= group.windowEnd;
2354
+ if (overlapsWindow) {
2355
+ targetGroup = group;
2356
+ }
2357
+ }
2358
+
2359
+ if (targetGroup) {
2360
+ targetGroup.items.push(span);
2361
+ } else {
2362
+ orphanSpans.push(span);
2363
+ }
2364
+ });
2365
+
2366
+ var normalizedGroups = groups.map(function(group) {
2367
+ return {
2368
+ runId: '',
2369
+ rootSpan: group.rootSpan,
2370
+ items: group.items
2371
+ };
2372
+ });
2373
+
2374
+ if (orphanSpans.length > 0) {
2375
+ normalizedGroups.unshift({ runId: '', rootSpan: null, items: orphanSpans });
2376
+ }
2377
+
2378
+ return normalizedGroups;
2379
+ }
2380
+
2381
+ var normalizedTraceQuery = normalizeTraceSearch(traceSearchQuery);
2382
+ var matchedFlatIdxs = [];
2383
+ var matchedByIdx = {};
2384
+ var snippetByIdx = {};
2385
+
2386
+ if (normalizedTraceQuery) {
2387
+ flatSpans.forEach(function(span) {
2388
+ if (span.action.action_type === 'agent_end' && span.level === 0) return;
2389
+ var searchText = getActionSearchText(span.action).toLowerCase();
2390
+ if (searchText.indexOf(normalizedTraceQuery) >= 0) {
2391
+ matchedFlatIdxs.push(span.flatIdx);
2392
+ matchedByIdx[span.flatIdx] = true;
2393
+ snippetByIdx[span.flatIdx] = getActionSearchSnippet(span.action, normalizedTraceQuery);
2394
+ }
2395
+ });
2396
+ }
2397
+
2398
+ // Session metadata (chronological boundaries)
2399
+ var actionsByTime = actions.slice().sort(function(a, b) {
2400
+ return new Date(a.created_at).getTime() - new Date(b.created_at).getTime();
2401
+ });
2402
+ var firstAction = actionsByTime[0];
2403
+ var lastAction = actionsByTime[actionsByTime.length - 1];
1517
2404
  var modelName = '';
1518
2405
  var userId = firstAction.user_id || '';
1519
2406
  for (var i = 0; i < actions.length; i++) {
@@ -1527,7 +2414,9 @@ async function renderTraceDetail(sessionId, highlightAction, highlightTime) {
1527
2414
 
1528
2415
  // Trace header
1529
2416
  html += '<div class="trace-header">';
2417
+ html += '<div class="trace-header-top">';
1530
2418
  html += '<a href="#/traces" class="trace-back">← Back to Traces</a>';
2419
+ html += '</div>';
1531
2420
  html += '<div class="trace-title">Session: ' + esc(sessionId) + '</div>';
1532
2421
  html += '<div class="trace-meta">';
1533
2422
  html += '<span class="trace-meta-item"><b>Agent:</b> ' + esc(userId) + '</span>';
@@ -1535,7 +2424,27 @@ async function renderTraceDetail(sessionId, highlightAction, highlightTime) {
1535
2424
  html += '<span class="trace-meta-item"><b>Duration:</b> ' + fmtDur(totalDur) + '</span>';
1536
2425
  html += '<span class="trace-meta-item"><b>Actions:</b> ' + actions.length + '</span>';
1537
2426
  html += '<span class="trace-meta-item"><b>Time:</b> ' + fmtTime(firstAction.created_at) + ' → ' + fmtTime(lastAction.created_at) + '</span>';
1538
- html += '</div></div>';
2427
+ html += '</div>';
2428
+ html += '<div class="filter-bar trace-search-bar">';
2429
+ 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()">';
2430
+ html += '<div class="trace-search-nav">';
2431
+ html += '<button class="icon-refresh-btn" onclick="refreshCurrentTrace()" title="Refresh current trace">' + ICON_REFRESH + '</button>';
2432
+ html += '<button onclick="applyTraceSearch()">Search</button>';
2433
+ html += '<button onclick="traceSearchStep(-1)"' + (matchedFlatIdxs.length ? '' : ' disabled') + '>Prev</button>';
2434
+ html += '<button onclick="traceSearchStep(1)"' + (matchedFlatIdxs.length ? '' : ' disabled') + '>Next</button>';
2435
+ html += '<button class="btn-clear" onclick="clearTraceSearch()">Clear</button>';
2436
+ html += '</div>';
2437
+ html += '<div class="trace-search-stats">';
2438
+ if (normalizedTraceQuery) {
2439
+ html += matchedFlatIdxs.length
2440
+ ? ('Matched ' + matchedFlatIdxs.length + ' action' + (matchedFlatIdxs.length > 1 ? 's' : '') + ' in this session')
2441
+ : 'No matches in this session';
2442
+ } else {
2443
+ html += 'Search within this session';
2444
+ }
2445
+ html += '</div>';
2446
+ html += '</div>';
2447
+ html += '</div>';
1539
2448
 
1540
2449
  // Security alert banner (if any)
1541
2450
  if (traceAlerts.length > 0) {
@@ -1550,44 +2459,94 @@ async function renderTraceDetail(sessionId, highlightAction, highlightTime) {
1550
2459
  }
1551
2460
 
1552
2461
  // Waterfall
2462
+ if (normalizedTraceQuery && matchedFlatIdxs.length === 0) {
2463
+ html += '<div class="trace-search-empty">No action matched "' + esc(traceSearchQuery) + '". Try a tool name, model, prompt fragment, or error text.</div>';
2464
+ }
1553
2465
  html += '<div class="waterfall">';
1554
2466
  html += '<div class="wf-header"><span>Name</span><span style="text-align:right;padding-right:14px">Duration</span><span>Timeline</span></div>';
1555
2467
 
1556
- var WF_FOLD_LIMIT = 100;
1557
- var needFold = flatSpans.length > WF_FOLD_LIMIT;
2468
+ function getVisibleGroupItems(group) {
2469
+ return group.items && group.items.length ? group.items : (group.rootSpan ? [group.rootSpan] : []);
2470
+ }
1558
2471
 
1559
- flatSpans.forEach(function(span, fi) {
1560
- var a = span.action;
1561
- var color = typeColor(a.action_type);
1562
- var leftPct = ((span.startMs - sessionStart) / totalDur * 100).toFixed(2);
1563
- var widthPct = Math.max(((span.endMs - span.startMs) / totalDur * 100), 0.3).toFixed(2);
1564
- var indent = span.level * 20;
2472
+ function getPrimaryGroupActionIdx(group) {
2473
+ var visibleItems = getVisibleGroupItems(group);
2474
+ return visibleItems[0] ? visibleItems[0].flatIdx : -1;
2475
+ }
1565
2476
 
1566
- // When over 100 rows, fold the rest
1567
- if (needFold && fi === WF_FOLD_LIMIT) {
1568
- html += '<div class="wf-fold" id="wf-fold-btn" onclick="expandWaterfall()">';
1569
- html += '<span>▼ Show remaining ' + (flatSpans.length - WF_FOLD_LIMIT) + ' actions (' + flatSpans.length + ' total)</span>';
1570
- html += '</div>';
2477
+ var traceGroups = splitTraceGroups(flatSpans).reverse();
2478
+ var TRACE_COLLAPSE_LIMIT = 30;
2479
+
2480
+ traceGroups.forEach(function(group, groupIdx) {
2481
+ var allGroupSpans = group.rootSpan ? [group.rootSpan].concat(group.items) : group.items.slice();
2482
+ var visibleGroupItems = getVisibleGroupItems(group).slice().sort(function(a, b) {
2483
+ var startDiff = a.startMs - b.startMs;
2484
+ if (startDiff !== 0) return startDiff;
2485
+ var priA = typePriority(a.action.action_type);
2486
+ var priB = typePriority(b.action.action_type);
2487
+ if (priA !== priB) return priA - priB;
2488
+ var endDiff = a.endMs - b.endMs;
2489
+ if (endDiff !== 0) return endDiff;
2490
+ return a.idx - b.idx;
2491
+ });
2492
+ var orderedByCreated = allGroupSpans.slice().sort(function(a, b) {
2493
+ return new Date(a.action.created_at).getTime() - new Date(b.action.created_at).getTime();
2494
+ });
2495
+ var groupStart = orderedByCreated[0];
2496
+ var groupEnd = orderedByCreated[orderedByCreated.length - 1];
2497
+ var groupStartMs = Math.min.apply(null, allGroupSpans.map(function(span) { return span.startMs; }));
2498
+ var groupEndMs = Math.max.apply(null, allGroupSpans.map(function(span) { return span.endMs; }));
2499
+ var groupTotalDur = groupEndMs - groupStartMs || 1;
2500
+ var groupMatchIdxs = visibleGroupItems.filter(function(span) { return !!matchedByIdx[span.flatIdx]; }).map(function(span) { return span.flatIdx; });
2501
+ var groupSnippet = groupMatchIdxs.length ? snippetByIdx[groupMatchIdxs[0]] : '';
2502
+ var shouldCollapseForSearch = normalizedTraceQuery && groupMatchIdxs.length === 0;
2503
+ var shouldCollapseForLength = !normalizedTraceQuery && visibleGroupItems.length > TRACE_COLLAPSE_LIMIT;
2504
+ var runDuration = group.rootSpan ? group.rootSpan.action.duration_ms : (groupEndMs - groupStartMs);
2505
+ var collapsedCls = (shouldCollapseForSearch || shouldCollapseForLength) ? ' collapsed' : '';
2506
+ html += '<div class="wf-group-section' + collapsedCls + '" data-group-idx="' + groupIdx + '">';
2507
+ html += '<div class="wf-group" onclick="toggleTraceGroup(' + groupIdx + ')">';
2508
+ html += '<div class="wf-group-main">';
2509
+ html += '<span class="wf-group-toggle" data-group-toggle="' + groupIdx + '">' + (collapsedCls ? '▸' : '▾') + '</span>';
2510
+ html += '<div class="wf-group-copy">';
2511
+ html += '<div class="wf-group-title-row">';
2512
+ var runTitle = 'Trace ' + (groupIdx + 1);
2513
+ html += '<span class="wf-group-title">' + runTitle + '</span>';
2514
+ if (runDuration != null) {
2515
+ html += '<span class="wf-group-hit">' + fmtDur(runDuration) + '</span>';
2516
+ }
2517
+ if (groupMatchIdxs.length) {
2518
+ html += '<span class="wf-group-hit">' + groupMatchIdxs.length + ' hit' + (groupMatchIdxs.length > 1 ? 's' : '') + '</span>';
2519
+ }
2520
+ html += '</div>';
2521
+ if (groupSnippet) {
2522
+ html += '<div class="wf-group-snippet">' + highlightSearchText(groupSnippet, normalizedTraceQuery) + '</div>';
2523
+ } else if (group.rootSpan) {
2524
+ html += '<div class="wf-group-snippet">' + esc(group.rootSpan.action.action_name) + ' completed this run</div>';
1571
2525
  }
1572
-
1573
- var hiddenCls = (needFold && fi >= WF_FOLD_LIMIT) ? ' wf-row--hidden' : '';
1574
- html += '<div class="wf-row' + hiddenCls + (fi === selectedActionIdx ? ' selected' : '') + '" data-idx="' + fi + '" onclick="selectAction(' + fi + ')">';
1575
- html += '<div class="wf-name">';
1576
- if (indent > 0) html += '<span class="indent" style="width:' + indent + 'px;display:inline-flex;justify-content:center">└</span>';
1577
- html += '<span class="dot" style="background:' + color + '"></span>';
1578
- html += '<span class="text">' + esc(a.action_name) + '</span>';
1579
2526
  html += '</div>';
1580
- html += '<div class="wf-dur">' + fmtDur(a.duration_ms) + '</div>';
1581
- html += '<div class="wf-bar-wrap"><div class="wf-bar" style="left:' + leftPct + '%;width:' + widthPct + '%;background:' + color + '"></div></div>';
1582
2527
  html += '</div>';
1583
- });
2528
+ html += '<span class="wf-group-meta">' + fmtTime(new Date(groupStartMs).toISOString()) + ' → ' + fmtTime(groupEnd.action.created_at) + ' | ' + visibleGroupItems.length + ' actions</span>';
2529
+ html += '</div>';
1584
2530
 
1585
- // Timeline ticks
1586
- html += '<div class="wf-ticks"><span></span><span></span><div class="wf-ticks-bar">';
1587
- for (var t = 0; t <= 4; t++) {
1588
- html += '<span class="wf-tick">' + fmtDur(Math.round(totalDur * t / 4)) + '</span>';
1589
- }
1590
- html += '</div></div>';
2531
+ visibleGroupItems.forEach(function(span) {
2532
+ var a = span.action;
2533
+ var color = typeColor(a.action_type);
2534
+ var leftPct = ((span.startMs - groupStartMs) / groupTotalDur * 100).toFixed(2);
2535
+ var widthPct = Math.max(((span.endMs - span.startMs) / groupTotalDur * 100), 0.3).toFixed(2);
2536
+ var indent = span.level * 20;
2537
+ var matchCls = matchedByIdx[span.flatIdx] ? ' wf-row--match' : '';
2538
+ html += '<div class="wf-row' + matchCls + (span.flatIdx === selectedActionIdx ? ' selected' : '') + '" data-action-idx="' + span.flatIdx + '" onclick="selectAction(' + span.flatIdx + ', false)">';
2539
+ html += '<div class="wf-name">';
2540
+ if (indent > 0) html += '<span class="indent" style="width:' + indent + 'px;display:inline-flex;justify-content:center">└</span>';
2541
+ html += '<span class="dot" style="background:' + color + '"></span>';
2542
+ html += '<span class="text">' + highlightSearchText(a.action_name, normalizedTraceQuery) + '</span>';
2543
+ html += '</div>';
2544
+ html += '<div class="wf-dur">' + fmtDur(span.displayDurationMs != null ? span.displayDurationMs : a.duration_ms) + '</div>';
2545
+ html += '<div class="wf-bar-wrap"><div class="wf-bar" style="left:' + leftPct + '%;width:' + widthPct + '%;background:' + color + '"></div></div>';
2546
+ html += '</div>';
2547
+ });
2548
+ html += '</div>';
2549
+ });
1591
2550
  html += '</div>'; // .waterfall
1592
2551
  html += '</div>'; // .trace-top
1593
2552
 
@@ -1603,6 +2562,8 @@ async function renderTraceDetail(sessionId, highlightAction, highlightTime) {
1603
2562
 
1604
2563
  // Store spans for detail rendering
1605
2564
  window.__traceSpans = flatSpans;
2565
+ window.__traceMatchActionIdxs = matchedFlatIdxs;
2566
+ window.__traceSearchCursor = 0;
1606
2567
 
1607
2568
  // Init resize drag
1608
2569
  initResizeDrag();
@@ -1621,18 +2582,31 @@ async function renderTraceDetail(sessionId, highlightAction, highlightTime) {
1621
2582
  });
1622
2583
  }
1623
2584
  }
1624
- // If no highlight match, default to first LLM call or first action
2585
+ if (autoIdx < 0 && matchedFlatIdxs.length > 0) {
2586
+ autoIdx = matchedFlatIdxs[0];
2587
+ }
2588
+ if (autoIdx >= 0) {
2589
+ var autoSpan = flatSpans[autoIdx];
2590
+ if (autoSpan && autoSpan.action.action_type === 'agent_end' && autoSpan.level === 0) {
2591
+ var containingGroup = traceGroups.find(function(group) {
2592
+ return group.rootSpan && group.rootSpan.flatIdx === autoIdx;
2593
+ });
2594
+ autoIdx = containingGroup ? getPrimaryGroupActionIdx(containingGroup) : autoIdx;
2595
+ }
2596
+ }
2597
+ // If no highlight match, default to the newest trace's first action
1625
2598
  if (autoIdx < 0) {
1626
- autoIdx = flatSpans.findIndex(function(s){ return s.action.action_type === 'message'; });
2599
+ autoIdx = traceGroups[0] ? getPrimaryGroupActionIdx(traceGroups[0]) : -1;
1627
2600
  }
1628
2601
  if (autoIdx < 0) autoIdx = 0;
1629
2602
 
1630
- // If the target action is in the folded section, expand first
1631
- if (needFold && autoIdx >= WF_FOLD_LIMIT) {
1632
- expandWaterfall();
2603
+ if (matchedFlatIdxs.length > 0) {
2604
+ var selectedMatchIdx = matchedFlatIdxs.indexOf(autoIdx);
2605
+ window.__traceSearchCursor = selectedMatchIdx >= 0 ? selectedMatchIdx : 0;
1633
2606
  }
1634
2607
 
1635
- selectAction(autoIdx);
2608
+ var shouldAutoScroll = !!normalizedTraceQuery || !!highlightAction || !!highlightTime;
2609
+ selectAction(autoIdx, shouldAutoScroll);
1636
2610
 
1637
2611
  } catch(err) {
1638
2612
  app.innerHTML = renderLayout('trace',
@@ -1643,12 +2617,41 @@ async function renderTraceDetail(sessionId, highlightAction, highlightTime) {
1643
2617
  }
1644
2618
  }
1645
2619
 
1646
- /* ---------- expand folded waterfall rows ---------- */
1647
- window.expandWaterfall = function() {
1648
- var hidden = document.querySelectorAll('.wf-row--hidden');
1649
- hidden.forEach(function(r) { r.classList.remove('wf-row--hidden'); });
1650
- var btn = document.getElementById('wf-fold-btn');
1651
- if (btn) btn.style.display = 'none';
2620
+ window.toggleTraceGroup = function(groupIdx) {
2621
+ var section = document.querySelector('.wf-group-section[data-group-idx="' + groupIdx + '"]');
2622
+ if (!section) return;
2623
+ section.classList.toggle('collapsed');
2624
+ var toggle = section.querySelector('[data-group-toggle="' + groupIdx + '"]');
2625
+ if (toggle) {
2626
+ toggle.textContent = section.classList.contains('collapsed') ? '▸' : '▾';
2627
+ }
2628
+ };
2629
+
2630
+ window.applyTraceSearch = function() {
2631
+ var input = document.getElementById('trace-search-input');
2632
+ traceSearchQuery = input ? input.value.trim() : '';
2633
+ if (!currentTraceSessionId) return;
2634
+ renderTraceDetail(currentTraceSessionId, currentTraceHighlightAction, currentTraceHighlightTime);
2635
+ };
2636
+
2637
+ window.clearTraceSearch = function() {
2638
+ traceSearchQuery = '';
2639
+ if (!currentTraceSessionId) return;
2640
+ renderTraceDetail(currentTraceSessionId, currentTraceHighlightAction, currentTraceHighlightTime);
2641
+ };
2642
+
2643
+ window.refreshCurrentTrace = function() {
2644
+ if (!currentTraceSessionId) return;
2645
+ renderTraceDetail(currentTraceSessionId, currentTraceHighlightAction, currentTraceHighlightTime);
2646
+ };
2647
+
2648
+ window.traceSearchStep = function(delta) {
2649
+ var matches = window.__traceMatchActionIdxs || [];
2650
+ if (!matches.length) return;
2651
+ var cursor = Number(window.__traceSearchCursor || 0);
2652
+ cursor = (cursor + delta + matches.length) % matches.length;
2653
+ window.__traceSearchCursor = cursor;
2654
+ selectAction(matches[cursor], true);
1652
2655
  };
1653
2656
 
1654
2657
  /* ---------- resize drag logic ---------- */
@@ -1656,17 +2659,18 @@ function initResizeDrag() {
1656
2659
  var handle = document.getElementById('trace-resize');
1657
2660
  if (!handle) return;
1658
2661
 
1659
- var startY = 0;
1660
- var startBottomH = 0;
1661
- var bottomEl = null;
2662
+ var startX = 0;
2663
+ var startRightW = 0;
2664
+ var rightEl = null;
1662
2665
 
1663
2666
  function onMouseDown(e) {
1664
2667
  e.preventDefault();
1665
2668
  e.stopPropagation();
1666
- bottomEl = document.querySelector('.trace-bottom');
1667
- if (!bottomEl) return;
1668
- startY = e.clientY != null ? e.clientY : (e.touches ? e.touches[0].clientY : 0);
1669
- startBottomH = bottomEl.getBoundingClientRect().height;
2669
+ if (window.innerWidth <= 900) return;
2670
+ rightEl = document.querySelector('.trace-bottom');
2671
+ if (!rightEl) return;
2672
+ startX = e.clientX != null ? e.clientX : (e.touches ? e.touches[0].clientX : 0);
2673
+ startRightW = rightEl.getBoundingClientRect().width;
1670
2674
  handle.classList.add('active');
1671
2675
  document.body.classList.add('resizing');
1672
2676
  document.addEventListener('mousemove', onMouseMove, true);
@@ -1678,10 +2682,10 @@ function initResizeDrag() {
1678
2682
  function onMouseMove(e) {
1679
2683
  e.preventDefault();
1680
2684
  e.stopPropagation();
1681
- var clientY = e.clientY != null ? e.clientY : (e.touches ? e.touches[0].clientY : 0);
1682
- var diff = startY - clientY;
1683
- var newH = Math.max(140, Math.min(window.innerHeight * 0.7, startBottomH + diff));
1684
- bottomEl.style.flexBasis = newH + 'px';
2685
+ var clientX = e.clientX != null ? e.clientX : (e.touches ? e.touches[0].clientX : 0);
2686
+ var diff = startX - clientX;
2687
+ var newW = Math.max(320, Math.min(window.innerWidth * 0.55, startRightW + diff));
2688
+ rightEl.style.flexBasis = newW + 'px';
1685
2689
  }
1686
2690
 
1687
2691
  function onMouseUp(e) {
@@ -1698,20 +2702,44 @@ function initResizeDrag() {
1698
2702
  }
1699
2703
 
1700
2704
  /* ---------- select action (renders detail in bottom pane) ---------- */
1701
- window.selectAction = function(fi) {
2705
+ window.selectAction = function(fi, shouldScroll) {
1702
2706
  selectedActionIdx = fi;
1703
2707
  var spans = window.__traceSpans;
1704
2708
  if (!spans || fi < 0 || fi >= spans.length) return;
2709
+ var matches = window.__traceMatchActionIdxs || [];
2710
+ var matchPos = matches.indexOf(fi);
2711
+ if (matchPos >= 0) {
2712
+ window.__traceSearchCursor = matchPos;
2713
+ }
1705
2714
 
1706
2715
  // Update selected row styling
1707
2716
  var rows = document.querySelectorAll('.wf-row');
1708
- rows.forEach(function(r, i) {
1709
- r.classList.toggle('selected', i === fi);
2717
+ rows.forEach(function(r) {
2718
+ r.classList.toggle('selected', Number(r.getAttribute('data-action-idx')) === fi);
1710
2719
  });
1711
2720
 
1712
2721
  // Scroll the selected row into view within the top pane
1713
- if (rows[fi]) {
1714
- rows[fi].scrollIntoView({block:'nearest',behavior:'smooth'});
2722
+ var row = document.querySelector('.wf-row[data-action-idx="' + fi + '"]');
2723
+ if (row) {
2724
+ var section = row.closest('.wf-group-section');
2725
+ if (section && section.classList.contains('collapsed')) {
2726
+ section.classList.remove('collapsed');
2727
+ var toggle = section.querySelector('[data-group-toggle]');
2728
+ if (toggle) toggle.textContent = '▾';
2729
+ }
2730
+ }
2731
+ if (row && shouldScroll === true) {
2732
+ var traceTop = document.querySelector('.trace-top');
2733
+ if (traceTop) {
2734
+ var header = traceTop.querySelector('.trace-header');
2735
+ var headerHeight = header ? header.getBoundingClientRect().height : 0;
2736
+ var traceTopRect = traceTop.getBoundingClientRect();
2737
+ var rowRect = row.getBoundingClientRect();
2738
+ var targetTop = (rowRect.top - traceTopRect.top) + traceTop.scrollTop - headerHeight - 8;
2739
+ traceTop.scrollTo({ top: Math.max(0, targetTop), behavior: 'smooth' });
2740
+ } else {
2741
+ row.scrollIntoView({ block: 'start', behavior: 'smooth' });
2742
+ }
1715
2743
  }
1716
2744
 
1717
2745
  var span = spans[fi];
@@ -1719,6 +2747,7 @@ window.selectAction = function(fi) {
1719
2747
  var color = typeColor(a.action_type);
1720
2748
  var input = parseJson(a.input_params);
1721
2749
  var output = parseJson(a.output_result);
2750
+ var mediaPaths = Array.from(new Set(collectMediaPaths(input))).slice(0, 8);
1722
2751
 
1723
2752
  var html = '<div class="detail-panel">';
1724
2753
 
@@ -1742,20 +2771,39 @@ window.selectAction = function(fi) {
1742
2771
 
1743
2772
  // Body: Input / Output side by side
1744
2773
  html += '<div class="detail-body">';
2774
+ var useStructuredView = !isToolActionType(a.action_type);
1745
2775
 
1746
2776
  html += '<div class="detail-section">';
1747
2777
  html += '<h4>Input</h4>';
1748
- if (input) {
1749
- html += '<div class="json-view">' + prettyJson(input) + '</div>';
2778
+ if (input !== null && input !== undefined) {
2779
+ if (useStructuredView) {
2780
+ html += renderStructuredFields(input);
2781
+ } else {
2782
+ html += '<div class="json-view">' + prettyJson(input) + '</div>';
2783
+ }
1750
2784
  } else {
1751
2785
  html += '<div class="json-view" style="color:var(--muted)">null</div>';
1752
2786
  }
2787
+ if (mediaPaths.length > 0) {
2788
+ html += '<div class="media-preview">';
2789
+ mediaPaths.forEach(function(p) {
2790
+ var mediaUrl = buildMediaUrl(p);
2791
+ html += '<a class="media-card" href="' + mediaUrl + '" target="_blank" rel="noopener noreferrer" title="Open full image">';
2792
+ html += '<img src="' + mediaUrl + '" loading="lazy" alt="inbound-media" onerror="onMediaImgError(this)">';
2793
+ html += '</a>';
2794
+ });
2795
+ html += '</div>';
2796
+ }
1753
2797
  html += '</div>';
1754
2798
 
1755
2799
  html += '<div class="detail-section">';
1756
2800
  html += '<h4>Output</h4>';
1757
- if (output) {
1758
- html += '<div class="json-view">' + prettyJson(output) + '</div>';
2801
+ if (output !== null && output !== undefined) {
2802
+ if (useStructuredView) {
2803
+ html += renderStructuredFields(output);
2804
+ } else {
2805
+ html += '<div class="json-view">' + prettyJson(output) + '</div>';
2806
+ }
1759
2807
  } else {
1760
2808
  html += '<div class="json-view" style="color:var(--muted)">null</div>';
1761
2809
  }
@@ -1772,5 +2820,6 @@ window.selectAction = function(fi) {
1772
2820
 
1773
2821
  /* ---------- init ---------- */
1774
2822
  router();
2823
+ scheduleAlertBadgeLoad();
1775
2824
  `;
1776
2825
  //# sourceMappingURL=ui.js.map