react-native-debug-toolkit 3.0.0 → 3.1.3

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 (83) hide show
  1. package/README.md +115 -97
  2. package/README.zh-CN.md +113 -95
  3. package/lib/commonjs/core/initialize.js +5 -0
  4. package/lib/commonjs/core/initialize.js.map +1 -1
  5. package/lib/commonjs/index.js +23 -26
  6. package/lib/commonjs/index.js.map +1 -1
  7. package/lib/commonjs/ui/panel/StreamingSettingsModal.js +24 -58
  8. package/lib/commonjs/ui/panel/StreamingSettingsModal.js.map +1 -1
  9. package/lib/commonjs/utils/DaemonClient.js +721 -0
  10. package/lib/commonjs/utils/DaemonClient.js.map +1 -0
  11. package/lib/commonjs/utils/{sessionReport.js → deviceReport.js} +3 -3
  12. package/lib/commonjs/utils/deviceReport.js.map +1 -0
  13. package/lib/module/core/initialize.js +6 -0
  14. package/lib/module/core/initialize.js.map +1 -1
  15. package/lib/module/index.js +3 -5
  16. package/lib/module/index.js.map +1 -1
  17. package/lib/module/ui/panel/StreamingSettingsModal.js +21 -55
  18. package/lib/module/ui/panel/StreamingSettingsModal.js.map +1 -1
  19. package/lib/module/utils/DaemonClient.js +703 -0
  20. package/lib/module/utils/DaemonClient.js.map +1 -0
  21. package/lib/module/utils/{sessionReport.js → deviceReport.js} +2 -2
  22. package/lib/module/utils/deviceReport.js.map +1 -0
  23. package/lib/typescript/src/core/initialize.d.ts.map +1 -1
  24. package/lib/typescript/src/index.d.ts +5 -10
  25. package/lib/typescript/src/index.d.ts.map +1 -1
  26. package/lib/typescript/src/ui/panel/StreamingSettingsModal.d.ts.map +1 -1
  27. package/lib/typescript/src/utils/DaemonClient.d.ts +141 -0
  28. package/lib/typescript/src/utils/DaemonClient.d.ts.map +1 -0
  29. package/lib/typescript/src/utils/{sessionReport.d.ts → deviceReport.d.ts} +4 -4
  30. package/lib/typescript/src/utils/deviceReport.d.ts.map +1 -0
  31. package/node/daemon/src/cli.js +9 -2
  32. package/node/daemon/src/console/console.html +1052 -249
  33. package/node/daemon/src/constants.js +6 -0
  34. package/node/daemon/src/server.js +205 -123
  35. package/node/daemon/src/store.js +122 -45
  36. package/node/mcp/src/daemonClient.js +6 -6
  37. package/node/mcp/src/index.js +2 -2
  38. package/node/mcp/src/logs.js +5 -4
  39. package/node/mcp/src/tools.js +16 -16
  40. package/package.json +2 -2
  41. package/src/core/initialize.ts +8 -0
  42. package/src/index.ts +18 -10
  43. package/src/ui/panel/StreamingSettingsModal.tsx +25 -63
  44. package/src/utils/DaemonClient.ts +887 -0
  45. package/src/utils/{sessionReport.ts → deviceReport.ts} +6 -6
  46. package/lib/commonjs/utils/autoDetectDaemon.js +0 -141
  47. package/lib/commonjs/utils/autoDetectDaemon.js.map +0 -1
  48. package/lib/commonjs/utils/daemonConnection.js +0 -81
  49. package/lib/commonjs/utils/daemonConnection.js.map +0 -1
  50. package/lib/commonjs/utils/daemonSettings.js +0 -110
  51. package/lib/commonjs/utils/daemonSettings.js.map +0 -1
  52. package/lib/commonjs/utils/reportToDaemon.js +0 -112
  53. package/lib/commonjs/utils/reportToDaemon.js.map +0 -1
  54. package/lib/commonjs/utils/sessionReport.js.map +0 -1
  55. package/lib/commonjs/utils/streamToDaemon.js +0 -334
  56. package/lib/commonjs/utils/streamToDaemon.js.map +0 -1
  57. package/lib/module/utils/autoDetectDaemon.js +0 -136
  58. package/lib/module/utils/autoDetectDaemon.js.map +0 -1
  59. package/lib/module/utils/daemonConnection.js +0 -77
  60. package/lib/module/utils/daemonConnection.js.map +0 -1
  61. package/lib/module/utils/daemonSettings.js +0 -102
  62. package/lib/module/utils/daemonSettings.js.map +0 -1
  63. package/lib/module/utils/reportToDaemon.js +0 -105
  64. package/lib/module/utils/reportToDaemon.js.map +0 -1
  65. package/lib/module/utils/sessionReport.js.map +0 -1
  66. package/lib/module/utils/streamToDaemon.js +0 -328
  67. package/lib/module/utils/streamToDaemon.js.map +0 -1
  68. package/lib/typescript/src/utils/autoDetectDaemon.d.ts +0 -15
  69. package/lib/typescript/src/utils/autoDetectDaemon.d.ts.map +0 -1
  70. package/lib/typescript/src/utils/daemonConnection.d.ts +0 -18
  71. package/lib/typescript/src/utils/daemonConnection.d.ts.map +0 -1
  72. package/lib/typescript/src/utils/daemonSettings.d.ts +0 -19
  73. package/lib/typescript/src/utils/daemonSettings.d.ts.map +0 -1
  74. package/lib/typescript/src/utils/reportToDaemon.d.ts +0 -34
  75. package/lib/typescript/src/utils/reportToDaemon.d.ts.map +0 -1
  76. package/lib/typescript/src/utils/sessionReport.d.ts.map +0 -1
  77. package/lib/typescript/src/utils/streamToDaemon.d.ts +0 -23
  78. package/lib/typescript/src/utils/streamToDaemon.d.ts.map +0 -1
  79. package/src/utils/autoDetectDaemon.ts +0 -175
  80. package/src/utils/daemonConnection.ts +0 -133
  81. package/src/utils/daemonSettings.ts +0 -134
  82. package/src/utils/reportToDaemon.ts +0 -172
  83. package/src/utils/streamToDaemon.ts +0 -419
@@ -15,8 +15,10 @@
15
15
  --red:#ff1744;--red-dim:rgba(255,23,68,.12);
16
16
  --amber:#ffab00;--amber-dim:rgba(255,171,0,.12);
17
17
  --orange:#ff6e40;--orange-dim:rgba(255,110,64,.12);
18
- --font-mono:'SF Mono',Monaco,Consolas,monospace;
19
- --font-sans:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;
18
+ --purple:#7c4dff;--purple-dim:rgba(124,77,255,.12);
19
+ --pink:#e040fb;--pink-dim:rgba(224,64,251,.12);
20
+ --font-mono:'SF Mono',Monaco,Consolas,monospace;
21
+ --font-sans:-apple-system,BlinkMacSystemFont,'Segoe UI',sans-serif;
20
22
  --radius:6px;
21
23
  }
22
24
 
@@ -32,7 +34,6 @@ body{
32
34
  repeating-linear-gradient(0deg,transparent,transparent 2px,rgba(0,229,255,.012) 2px,rgba(0,229,255,.012) 4px);
33
35
  }
34
36
 
35
- /* Scanline overlay */
36
37
  body::after{
37
38
  content:'';position:fixed;inset:0;pointer-events:none;z-index:9999;
38
39
  background:repeating-linear-gradient(0deg,transparent 0px,transparent 1px,rgba(0,0,0,.03) 1px,rgba(0,0,0,.03) 2px);
@@ -73,6 +74,9 @@ header h1 span{color:var(--text3);font-weight:400}
73
74
  .btn:hover{background:var(--surface3);color:var(--text);border-color:var(--cyan-mid)}
74
75
  .btn:active{transform:scale(.97)}
75
76
  .btn-icon{padding:6px 10px;font-size:14px}
77
+ .btn-sm{padding:3px 8px;font-size:10px;border-radius:3px}
78
+ .btn-ghost{background:transparent;border-color:transparent}
79
+ .btn-ghost:hover{background:var(--surface2);border-color:var(--border)}
76
80
 
77
81
  /* Container */
78
82
  .container{max-width:1160px;margin:0 auto;padding:28px 24px}
@@ -87,7 +91,7 @@ header h1 span{color:var(--text3);font-weight:400}
87
91
  border:1px solid var(--border);color:var(--cyan);
88
92
  }
89
93
 
90
- /* Session list */
94
+ /* Device list */
91
95
  .section-title{
92
96
  font-size:13px;font-weight:600;color:var(--text2);
93
97
  text-transform:uppercase;letter-spacing:.08em;
@@ -95,21 +99,26 @@ header h1 span{color:var(--text3);font-weight:400}
95
99
  }
96
100
  .section-title::after{content:'';flex:1;height:1px;background:var(--border)}
97
101
 
98
- .session-grid{display:flex;flex-direction:column;gap:6px}
99
- .session-card{
100
- display:grid;grid-template-columns:1fr auto auto;align-items:center;gap:16px;
102
+ .device-grid{display:flex;flex-direction:column;gap:6px}
103
+ .device-card{
104
+ display:grid;grid-template-columns:minmax(0,1.2fr) minmax(170px,.8fr) auto 20px;align-items:center;gap:16px;
101
105
  padding:14px 18px;background:var(--surface);
102
106
  border:1px solid var(--border);border-radius:var(--radius);
103
107
  cursor:pointer;transition:all .15s;
104
108
  }
105
- .session-card:hover{
109
+ .device-card:hover{
106
110
  background:var(--surface2);border-color:var(--border2);
107
111
  transform:translateX(3px);
108
112
  box-shadow:0 0 0 1px var(--cyan-dim),0 4px 16px rgba(0,0,0,.3);
109
113
  }
110
- .session-id{font-family:var(--font-mono);font-size:12px;color:var(--text);font-weight:500}
111
- .session-time{font-size:11px;color:var(--text3);font-family:var(--font-mono);margin-top:2px}
112
- .session-tags{display:flex;gap:6px;flex-wrap:wrap}
114
+ .device-id{font-family:var(--font-mono);font-size:12px;color:var(--text);font-weight:500}
115
+ .device-time{font-size:11px;color:var(--text3);font-family:var(--font-mono);margin-top:2px}
116
+ .device-title{font-size:13px;color:var(--text);font-weight:600;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
117
+ .device-subtitle{font-family:var(--font-mono);font-size:11px;color:var(--text2);margin-top:2px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
118
+ .device-meta-group{display:flex;flex-direction:column;gap:3px;min-width:0}
119
+ .device-meta-line{font-family:var(--font-mono);font-size:11px;color:var(--text3);overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
120
+ .device-meta-line strong{font-family:var(--font-sans);font-weight:600;color:var(--text2);margin-right:5px}
121
+ .device-tags{display:flex;gap:6px;flex-wrap:wrap}
113
122
  .tag{
114
123
  font-family:var(--font-mono);font-size:10px;font-weight:500;
115
124
  padding:2px 8px;border-radius:3px;
@@ -121,8 +130,8 @@ header h1 span{color:var(--text3);font-weight:400}
121
130
  .tag-error{background:var(--red-dim);color:var(--red);border-color:rgba(255,23,68,.1)}
122
131
  .tag-warn{background:var(--amber-dim);color:var(--amber);border-color:rgba(255,171,0,.1)}
123
132
  .tag-track{background:var(--orange-dim);color:var(--orange);border-color:rgba(255,110,64,.1)}
124
- .session-arrow{color:var(--text3);font-size:16px;transition:color .15s}
125
- .session-card:hover .session-arrow{color:var(--cyan)}
133
+ .device-arrow{color:var(--text3);font-size:16px;transition:color .15s}
134
+ .device-card:hover .device-arrow{color:var(--cyan)}
126
135
 
127
136
  /* Detail view */
128
137
  .back-link{
@@ -170,12 +179,6 @@ header h1 span{color:var(--text3);font-weight:400}
170
179
  font-size:12px;color:var(--text2);display:flex;align-items:center;gap:6px;
171
180
  font-family:var(--font-mono);cursor:pointer;
172
181
  }
173
- .toolbar input[type=number]{
174
- width:56px;padding:4px 8px;
175
- background:var(--surface);border:1px solid var(--border2);border-radius:4px;
176
- color:var(--text);font-size:11px;font-family:var(--font-mono);
177
- }
178
- .toolbar input[type=number]:focus{outline:none;border-color:var(--cyan)}
179
182
  .toggle{
180
183
  position:relative;width:32px;height:18px;
181
184
  background:var(--surface3);border-radius:9px;cursor:pointer;
@@ -189,11 +192,86 @@ header h1 span{color:var(--text3);font-weight:400}
189
192
  }
190
193
  .toggle.on::after{transform:translateX(14px)}
191
194
 
192
- /* Log entries */
195
+ /* Search */
196
+ .search-wrap{
197
+ flex:1;min-width:160px;position:relative;
198
+ }
199
+ .search-input{
200
+ width:100%;padding:5px 10px 5px 28px;
201
+ background:var(--surface);border:1px solid var(--border2);border-radius:4px;
202
+ color:var(--text);font-size:12px;font-family:var(--font-mono);
203
+ transition:border-color .15s;
204
+ }
205
+ .search-input::placeholder{color:var(--text3)}
206
+ .search-input:focus{outline:none;border-color:var(--cyan)}
207
+ .search-icon{
208
+ position:absolute;left:8px;top:50%;transform:translateY(-50%);
209
+ color:var(--text3);font-size:13px;pointer-events:none;
210
+ }
211
+ .search-clear{
212
+ position:absolute;right:6px;top:50%;transform:translateY(-50%);
213
+ background:none;border:none;color:var(--text3);cursor:pointer;
214
+ font-size:14px;padding:2px 4px;line-height:1;
215
+ display:none;
216
+ }
217
+ .search-clear.visible{display:block}
218
+ .search-clear:hover{color:var(--text)}
219
+ .kbd{
220
+ font-family:var(--font-mono);font-size:9px;color:var(--text3);
221
+ background:var(--surface2);border:1px solid var(--border2);
222
+ padding:1px 4px;border-radius:2px;letter-spacing:.02em;
223
+ }
224
+ .pager{
225
+ display:flex;align-items:center;justify-content:flex-end;gap:8px;
226
+ flex-wrap:wrap;color:var(--text3);font-family:var(--font-mono);font-size:11px;
227
+ }
228
+ .toolbar .pager{margin-left:auto}
229
+ .pager-bottom{margin-top:14px}
230
+ .pager-info{white-space:nowrap}
231
+ .page-btn{
232
+ padding:4px 9px;border:1px solid var(--border2);border-radius:4px;
233
+ background:var(--surface);color:var(--text2);font-family:var(--font-mono);
234
+ font-size:11px;cursor:pointer;
235
+ }
236
+ .page-btn:hover:not(:disabled){color:var(--cyan);border-color:var(--cyan-mid)}
237
+ .page-btn:disabled{opacity:.4;cursor:not-allowed}
238
+ .live-notice{
239
+ display:none;padding:4px 9px;border:1px solid var(--cyan-mid);border-radius:4px;
240
+ background:var(--cyan-dim);color:var(--cyan);font-family:var(--font-mono);
241
+ font-size:11px;cursor:pointer;
242
+ }
243
+ .live-notice.visible{display:inline-flex}
244
+
245
+ /* Curl help - collapsible */
246
+ .curl-panel{
247
+ margin-bottom:18px;padding:12px 14px;background:var(--bg2);
248
+ border:1px solid var(--border);border-radius:var(--radius);
249
+ }
250
+ .curl-header{
251
+ display:flex;align-items:center;justify-content:space-between;
252
+ cursor:pointer;user-select:none;
253
+ }
254
+ .curl-title{
255
+ font-size:11px;font-family:var(--font-mono);font-weight:700;
256
+ color:var(--cyan);text-transform:uppercase;letter-spacing:.08em;
257
+ }
258
+ .curl-toggle{
259
+ font-size:10px;color:var(--text3);transition:transform .2s;
260
+ }
261
+ .curl-toggle.open{transform:rotate(90deg)}
262
+ .curl-body{display:none;margin-top:10px}
263
+ .curl-body.open{display:block}
264
+ .curl-list{display:flex;flex-direction:column;gap:6px}
265
+ .curl-list code{
266
+ display:block;padding:7px 9px;background:rgba(0,0,0,.18);
267
+ border:1px solid rgba(42,63,102,.65);border-radius:4px;
268
+ color:var(--text2);font-family:var(--font-mono);font-size:11px;
269
+ white-space:pre-wrap;word-break:break-word;
270
+ }
271
+
272
+ /* Log entries - redesigned */
193
273
  .log-list{display:flex;flex-direction:column;gap:2px}
194
274
  .log-entry{
195
- display:grid;grid-template-columns:80px 1fr 90px 30px;
196
- align-items:center;gap:0;
197
275
  background:var(--surface);border:1px solid transparent;border-radius:var(--radius);
198
276
  cursor:pointer;transition:all .15s;overflow:hidden;
199
277
  animation:fadeSlideIn .3s ease-out both;
@@ -203,51 +281,174 @@ header h1 span{color:var(--text3);font-weight:400}
203
281
  background:var(--surface2);border-color:var(--border2);
204
282
  box-shadow:0 2px 8px rgba(0,0,0,.2);
205
283
  }
206
- .log-entry.expanded{border-color:var(--cyan-mid);background:var(--surface2)}
284
+ .log-entry.expanded{
285
+ border-color:var(--cyan-mid);background:var(--surface2);
286
+ box-shadow:0 2px 12px rgba(0,0,0,.25);
287
+ }
288
+ .log-entry.focused{outline:1px solid var(--cyan);outline-offset:-1px}
289
+
290
+ .log-row{
291
+ display:grid;grid-template-columns:90px 1fr auto auto 28px;
292
+ align-items:center;gap:0;
293
+ padding:0 4px 0 0;min-height:46px;
294
+ }
207
295
  .log-type{
208
296
  font-family:var(--font-mono);font-size:10px;font-weight:600;
209
297
  text-transform:uppercase;letter-spacing:.06em;
210
- padding:0 14px;height:42px;display:flex;align-items:center;
211
- border-right:1px solid var(--border);
298
+ padding:0 12px;height:100%;display:flex;align-items:center;
299
+ justify-content:center;text-align:center;
212
300
  }
213
301
  .log-type-network{color:var(--cyan)}
214
302
  .log-type-console{color:var(--green)}
215
- .log-type-navigation{color:#7c4dff}
303
+ .log-type-navigation{color:var(--purple)}
216
304
  .log-type-track{color:var(--orange)}
217
- .log-type-zustand{color:#e040fb}
305
+ .log-type-zustand{color:var(--pink)}
218
306
  .log-type-unknown{color:var(--text3)}
307
+
308
+ .log-summary-col{
309
+ padding:8px 12px;min-width:0;overflow:hidden;
310
+ }
219
311
  .log-summary{
220
- padding:0 14px;height:42px;display:flex;align-items:center;
221
312
  font-family:var(--font-mono);font-size:12px;color:var(--text);
222
- overflow:hidden;text-overflow:ellipsis;white-space:nowrap;
313
+ display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;
314
+ overflow:hidden;line-height:1.5;word-break:break-all;
315
+ }
316
+ .log-timestamp{
317
+ font-family:var(--font-mono);font-size:10px;color:var(--text3);
318
+ margin-top:2px;
223
319
  }
320
+
224
321
  .log-status{
225
- padding:0 10px;height:42px;display:flex;align-items:center;justify-content:center;
322
+ padding:0 8px;height:100%;display:flex;align-items:center;justify-content:center;
226
323
  }
227
324
  .badge{
228
325
  font-family:var(--font-mono);font-size:10px;font-weight:600;
229
- padding:2px 8px;border-radius:3px;letter-spacing:.02em;
326
+ padding:2px 8px;border-radius:3px;letter-spacing:.02em;white-space:nowrap;
230
327
  }
231
328
  .badge-ok{background:var(--green-dim);color:var(--green)}
232
329
  .badge-error{background:var(--red-dim);color:var(--red)}
233
330
  .badge-warn{background:var(--amber-dim);color:var(--amber)}
234
331
  .badge-info{background:var(--cyan-dim);color:var(--cyan)}
332
+
333
+ .log-copy{
334
+ padding:0 4px;height:100%;display:flex;align-items:center;justify-content:center;
335
+ opacity:0;transition:opacity .15s;
336
+ }
337
+ .log-entry:hover .log-copy{opacity:1}
338
+ .copy-btn{
339
+ background:none;border:1px solid transparent;cursor:pointer;
340
+ color:var(--text3);font-size:13px;padding:2px 4px;border-radius:3px;
341
+ transition:all .15s;
342
+ }
343
+ .copy-btn:hover{color:var(--cyan);background:var(--cyan-dim);border-color:rgba(0,229,255,.15)}
344
+
235
345
  .log-expand{
236
- padding:0 8px;height:42px;display:flex;align-items:center;justify-content:center;
237
- color:var(--text3);font-size:10px;transition:color .15s;
346
+ padding:0 8px;height:100%;display:flex;align-items:center;justify-content:center;
347
+ color:var(--text3);font-size:10px;transition:transform .2s,color .15s;
238
348
  }
239
349
  .log-entry:hover .log-expand{color:var(--cyan)}
240
- .log-json{
241
- grid-column:1/-1;
242
- border-top:1px solid var(--border);
243
- overflow:hidden;
244
- max-height:0;transition:max-height .3s ease-out;
350
+ .log-entry.expanded .log-expand{transform:rotate(90deg);color:var(--cyan)}
351
+
352
+ /* Expanded detail panel */
353
+ .log-detail{
354
+ display:none;border-top:1px solid var(--border);
355
+ animation:detailFadeIn .2s ease-out;
245
356
  }
246
- .log-json.open{max-height:400px}
247
- .log-json pre{
248
- padding:14px 18px;font-family:var(--font-mono);font-size:11px;
249
- line-height:1.6;color:var(--text2);white-space:pre-wrap;word-break:break-all;
250
- overflow:auto;max-height:400px;
357
+ .log-entry.expanded .log-detail{display:block}
358
+ @keyframes detailFadeIn{from{opacity:0;transform:translateY(-4px)}to{opacity:1;transform:translateY(0)}}
359
+
360
+ .log-detail-inner{padding:16px 18px}
361
+
362
+ /* Detail sections */
363
+ .detail-sections{display:flex;flex-direction:column;gap:10px}
364
+ .detail-section{
365
+ border:1px solid var(--border);border-radius:var(--radius);
366
+ background:rgba(8,12,22,.35);overflow:hidden;
367
+ }
368
+ .detail-section-header{
369
+ display:flex;align-items:center;justify-content:space-between;
370
+ padding:7px 12px;border-bottom:1px solid var(--border);
371
+ background:rgba(0,229,255,.03);
372
+ }
373
+ .detail-section-title{
374
+ font-size:10px;font-weight:700;text-transform:uppercase;letter-spacing:.08em;
375
+ color:var(--cyan);font-family:var(--font-mono);
376
+ }
377
+ .detail-section-copy{
378
+ background:none;border:none;cursor:pointer;
379
+ color:var(--text3);font-size:11px;padding:1px 4px;border-radius:2px;
380
+ transition:color .15s;
381
+ }
382
+ .detail-section-copy:hover{color:var(--cyan)}
383
+ .detail-section-body{padding:0}
384
+
385
+ /* Detail key-value table */
386
+ .detail-table{width:100%;border-collapse:collapse}
387
+ .detail-table tr{border-bottom:1px solid rgba(30,45,74,.5)}
388
+ .detail-table tr:last-child{border-bottom:none}
389
+ .detail-table th,.detail-table td{
390
+ padding:8px 12px;vertical-align:top;font-size:12px;
391
+ }
392
+ .detail-table th{
393
+ width:100px;color:var(--text3);font-family:var(--font-mono);font-weight:500;
394
+ text-align:left;white-space:nowrap;
395
+ }
396
+ .detail-table td{color:var(--text2);font-family:var(--font-mono);word-break:break-word;line-height:1.6}
397
+
398
+ /* JSON blocks */
399
+ .json-block{
400
+ margin:0;padding:10px 12px;font-family:var(--font-mono);font-size:11px;
401
+ line-height:1.7;color:var(--text2);white-space:pre-wrap;word-break:break-word;
402
+ max-height:320px;overflow:auto;background:rgba(0,0,0,.16);
403
+ }
404
+ .json-block .json-key{color:var(--cyan)}
405
+ .json-block .json-string{color:var(--green)}
406
+ .json-block .json-number{color:var(--amber)}
407
+ .json-block .json-bool{color:var(--purple)}
408
+ .json-block .json-null{color:var(--text3)}
409
+ .json-compact{max-height:160px;font-size:10px;line-height:1.5;padding:8px 10px}
410
+
411
+ /* Value pills */
412
+ .value-list{display:flex;flex-direction:column;gap:6px;padding:10px 12px}
413
+ .value-pill{
414
+ display:block;padding:8px 10px;border:1px solid rgba(42,63,102,.6);
415
+ border-radius:4px;background:rgba(0,0,0,.1);font-family:var(--font-mono);
416
+ font-size:12px;color:var(--text2);word-break:break-word;line-height:1.6;
417
+ }
418
+
419
+ /* Network detail hero */
420
+ .network-hero{
421
+ padding:12px 14px;border-bottom:1px solid rgba(30,45,74,.5);
422
+ display:flex;align-items:center;gap:10px;
423
+ }
424
+ .method-badge{
425
+ font-family:var(--font-mono);font-size:11px;font-weight:700;
426
+ padding:3px 10px;border-radius:3px;letter-spacing:.04em;
427
+ }
428
+ .method-get{background:var(--cyan-dim);color:var(--cyan)}
429
+ .method-post{background:var(--green-dim);color:var(--green)}
430
+ .method-put{background:var(--amber-dim);color:var(--amber)}
431
+ .method-patch{background:var(--amber-dim);color:var(--amber)}
432
+ .method-delete{background:var(--red-dim);color:var(--red)}
433
+ .network-url{
434
+ font-family:var(--font-mono);font-size:12px;color:var(--text);
435
+ word-break:break-all;line-height:1.5;
436
+ }
437
+
438
+ /* Navigation hero */
439
+ .nav-hero{
440
+ padding:12px 14px;border-bottom:1px solid rgba(30,45,74,.5);
441
+ display:flex;align-items:center;gap:8px;
442
+ font-family:var(--font-mono);font-size:12px;
443
+ }
444
+ .nav-from{color:var(--text3)}
445
+ .nav-arrow{color:var(--cyan);font-size:14px}
446
+ .nav-to{color:var(--text);font-weight:500}
447
+
448
+ /* Entry footer */
449
+ .entry-footer{
450
+ display:flex;align-items:center;gap:8px;
451
+ padding:10px 0 0;margin-top:10px;border-top:1px solid rgba(30,45,74,.5);
251
452
  }
252
453
 
253
454
  /* Actions bar */
@@ -302,13 +503,23 @@ header h1 span{color:var(--text3);font-weight:400}
302
503
  @keyframes livePulse{0%,100%{opacity:1}50%{opacity:.6}}
303
504
  .live-badge-dot{width:6px;height:6px;border-radius:50%;background:var(--cyan)}
304
505
 
506
+ /* Search highlight */
507
+ mark{
508
+ background:rgba(0,229,255,.25);color:var(--text);
509
+ border-radius:2px;padding:0 1px;
510
+ }
511
+
305
512
  /* Responsive */
306
513
  @media(max-width:640px){
307
514
  .container{padding:16px}
308
- .session-card{grid-template-columns:1fr;gap:8px}
309
- .log-entry{grid-template-columns:60px 1fr 70px 24px}
515
+ .device-card{grid-template-columns:1fr;gap:8px}
516
+ .log-row{grid-template-columns:72px 1fr auto 28px}
517
+ .log-copy{display:none}
518
+ .detail-table th{width:80px}
310
519
  .detail-header{padding:14px 16px}
311
520
  .tabs{overflow-x:auto;-webkit-overflow-scrolling:touch}
521
+ .toolbar{gap:8px}
522
+ .search-wrap{min-width:120px;flex-basis:100%}
312
523
  }
313
524
  </style>
314
525
  </head>
@@ -320,6 +531,7 @@ header h1 span{color:var(--text3);font-weight:400}
320
531
  </div>
321
532
  <div class="header-right">
322
533
  <span class="header-meta" id="status"></span>
534
+ <span class="header-meta" id="ipHint" style="color:var(--text2);display:none"></span>
323
535
  <button class="btn" onclick="refresh()">
324
536
  <svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><path d="M21 2v6h-6"/><path d="M3 12a9 9 0 0 1 15-6.7L21 8"/><path d="M3 22v-6h6"/><path d="M21 12a9 9 0 0 1-15 6.7L3 16"/></svg>
325
537
  Refresh
@@ -336,28 +548,62 @@ header h1 span{color:var(--text3);font-weight:400}
336
548
  var app = document.getElementById('app');
337
549
  var statusEl = document.getElementById('status');
338
550
  var toastEl = document.getElementById('toast');
339
- var pulseDot = document.getElementById('pulseDot');
340
- var currentSession = null;
341
- var expandedRows = {};
342
- var authToken = null;
343
-
344
- try {
345
- var params = new URLSearchParams(location.search);
346
- authToken = params.get('token') || sessionStorage.getItem('debugToolkitToken');
347
- if (authToken) sessionStorage.setItem('debugToolkitToken', authToken);
348
- } catch {
349
- authToken = null;
350
- }
351
-
352
- function withAuth(path) {
353
- if (!authToken) return path;
354
- var join = path.indexOf('?') >= 0 ? '&' : '?';
355
- return path + join + 'token=' + encodeURIComponent(authToken);
356
- }
357
-
358
- function api(path) {
359
- return fetch(withAuth(path)).then(function(r) { return r.json(); });
360
- }
551
+ var pulseDot = document.getElementById('pulseDot');
552
+ var currentDevice = null;
553
+ var expandedRows = {};
554
+ var authToken = null;
555
+ var searchTerm = '';
556
+ var focusedIndex = -1;
557
+ var PAGE_SIZE = 200;
558
+ var currentPage = 1;
559
+ var pendingLiveCount = 0;
560
+ var liveSequence = 0;
561
+
562
+ try {
563
+ var params = new URLSearchParams(location.search);
564
+ authToken = params.get('token') || localStorage.getItem('debugToolkitToken');
565
+ if (authToken) localStorage.setItem('debugToolkitToken', authToken);
566
+ } catch {
567
+ authToken = null;
568
+ }
569
+
570
+ function withAuth(path) {
571
+ if (!authToken) return path;
572
+ var join = path.indexOf('?') >= 0 ? '&' : '?';
573
+ return path + join + 'token=' + encodeURIComponent(authToken);
574
+ }
575
+
576
+ function api(path) {
577
+ return fetch(withAuth(path)).then(function(r) {
578
+ return r.json().then(function(body) {
579
+ if (!r.ok) throw new Error(body && body.error ? body.error : ('HTTP ' + r.status));
580
+ return body;
581
+ });
582
+ });
583
+ }
584
+
585
+ function absoluteUrl(path) {
586
+ return location.origin + withAuth(path);
587
+ }
588
+
589
+ function curlCommand(path) {
590
+ return "curl '" + absoluteUrl(path).replace(/'/g, "'\\''") + "'";
591
+ }
592
+
593
+ function renderCurlPanel(title, commands) {
594
+ var id = 'curl-' + Math.random().toString(36).slice(2,8);
595
+ var html = '<div class="curl-panel">';
596
+ html += '<div class="curl-header" onclick="toggleCurl(\'' + id + '\')">';
597
+ html += '<div class="curl-title">' + escapeHtml(title) + '</div>';
598
+ html += '<span class="curl-toggle" id="toggle-' + id + '">&#9654;</span>';
599
+ html += '</div>';
600
+ html += '<div class="curl-body" id="' + id + '"><div class="curl-list">';
601
+ commands.forEach(function(command) {
602
+ html += '<code>' + escapeHtml(command) + '</code>';
603
+ });
604
+ html += '</div></div></div>';
605
+ return html;
606
+ }
361
607
 
362
608
  function showToast(msg) {
363
609
  toastEl.textContent = msg;
@@ -385,10 +631,52 @@ header h1 span{color:var(--text3);font-weight:400}
385
631
  if (!iso) return '';
386
632
  try {
387
633
  var d = new Date(iso);
388
- return d.toLocaleTimeString();
634
+ var pad = function(n) { return String(n).padStart(2, '0'); };
635
+ return pad(d.getHours()) + ':' + pad(d.getMinutes()) + ':' + pad(d.getSeconds()) + '.' + String(d.getMilliseconds()).padStart(3,'0').slice(0,2);
389
636
  } catch { return iso; }
390
637
  }
391
638
 
639
+ function readObject(value) {
640
+ return value && typeof value === 'object' && !Array.isArray(value) ? value : null;
641
+ }
642
+
643
+ function readTimestamp(entry) {
644
+ if (!entry || typeof entry !== 'object') return 0;
645
+ var value = entry.timestamp || entry.time || entry.createdAt;
646
+ if (typeof value === 'number') return value;
647
+ if (typeof value === 'string') {
648
+ var parsed = Date.parse(value);
649
+ return Number.isFinite(parsed) ? parsed : 0;
650
+ }
651
+ return 0;
652
+ }
653
+
654
+ function labelForType(type) {
655
+ var labels = {
656
+ network: 'Network',
657
+ console: 'Console',
658
+ navigation: 'Navigation',
659
+ track: 'Track',
660
+ zustand: 'State',
661
+ clipboard: 'Clipboard',
662
+ environment: 'Environment',
663
+ };
664
+ return labels[type] || (type ? type.charAt(0).toUpperCase() + type.slice(1) : 'Unknown');
665
+ }
666
+
667
+ function formatDevice(device) {
668
+ if (!device || typeof device !== 'object') return 'Unknown device';
669
+ var parts = [];
670
+ if (device.platform) parts.push(String(device.platform).toUpperCase());
671
+ if (device.model) parts.push(String(device.model));
672
+ if (device.osVersion) parts.push('OS ' + String(device.osVersion));
673
+ return parts.length ? parts.join(' / ') : 'Unknown device';
674
+ }
675
+
676
+ function formatIp(source) {
677
+ return source && source.ip ? String(source.ip) : 'unknown ip';
678
+ }
679
+
392
680
  function statusBadge(entry) {
393
681
  if (!entry || typeof entry !== 'object') return '<span class="badge badge-info">-</span>';
394
682
  if (entry.error) return '<span class="badge badge-error">ERR</span>';
@@ -401,25 +689,33 @@ header h1 span{color:var(--text3);font-weight:400}
401
689
  }
402
690
  if (entry.level === 'error') return '<span class="badge badge-error">ERR</span>';
403
691
  if (entry.level === 'warn') return '<span class="badge badge-warn">WRN</span>';
404
- return '<span class="badge badge-ok">OK</span>';
692
+ return '';
405
693
  }
406
694
 
407
695
  function summarize(entry) {
408
696
  if (!entry || typeof entry !== 'object') return escapeHtml(String(entry));
697
+ if (entry.request && typeof entry.request === 'object') {
698
+ return escapeHtml((entry.request.method || 'GET') + ' ' + (entry.request.url || '-'));
699
+ }
409
700
  if (entry.method && entry.url) return escapeHtml(entry.method + ' ' + entry.url);
410
- if (entry.level && entry.data) return escapeHtml((Array.isArray(entry.data) ? entry.data.join(' ') : String(entry.data)).substring(0, 120));
411
- if (entry.path) return escapeHtml(entry.path);
412
- if (entry.event) return escapeHtml(entry.event);
413
- if (entry.action) return escapeHtml(entry.action);
414
- return escapeHtml(JSON.stringify(entry).substring(0, 120));
701
+ if (entry.level && entry.data !== undefined) {
702
+ var data = Array.isArray(entry.data) ? entry.data.map(formatInlineValue).join(' ') : formatInlineValue(entry.data);
703
+ return escapeHtml(data.substring(0, 200));
704
+ }
705
+ if (entry.from || entry.to) return escapeHtml((entry.from || '-') + ' -> ' + (entry.to || '-'));
706
+ if (entry.eventName) return escapeHtml(String(entry.eventName));
707
+ if (entry.event) return escapeHtml(String(entry.event));
708
+ if (entry.action) return escapeHtml(String(entry.action));
709
+ return escapeHtml(JSON.stringify(entry).substring(0, 200));
415
710
  }
416
711
 
417
712
  function getLogType(entry) {
418
713
  if (!entry || typeof entry !== 'object') return 'unknown';
419
- if (entry.method && entry.url) return 'network';
714
+ if (entry.request || entry.response || (entry.method && entry.url)) return 'network';
420
715
  if (entry.level && entry.data !== undefined) return 'console';
421
- if (entry.path) return 'navigation';
422
- if (entry.event) return 'track';
716
+ if (entry.from || entry.to || entry.path) return 'navigation';
717
+ if (entry.eventName || entry.event) return 'track';
718
+ if (entry.prevState !== undefined || entry.nextState !== undefined || entry.storeName) return 'zustand';
423
719
  if (entry.action) return 'zustand';
424
720
  return 'unknown';
425
721
  }
@@ -452,37 +748,245 @@ header h1 span{color:var(--text3);font-weight:400}
452
748
  return logType + '-fallback-' + index + '-' + toKeyPart(stringifyForKey(entry));
453
749
  }
454
750
 
751
+ function parseJsonString(value) {
752
+ if (typeof value !== 'string') return value;
753
+ var trimmed = value.trim();
754
+ if (!trimmed || !/^[\[{]/.test(trimmed)) return value;
755
+ try {
756
+ return JSON.parse(trimmed);
757
+ } catch {
758
+ return value;
759
+ }
760
+ }
761
+
762
+ function formatInlineValue(value) {
763
+ var parsed = parseJsonString(value);
764
+ if (parsed === null || parsed === undefined) return String(parsed);
765
+ if (typeof parsed === 'string') return parsed;
766
+ if (typeof parsed === 'number' || typeof parsed === 'boolean') return String(parsed);
767
+ try {
768
+ return JSON.stringify(parsed);
769
+ } catch {
770
+ return String(parsed);
771
+ }
772
+ }
773
+
774
+ function highlightJson(json) {
775
+ var str = typeof json === 'string' ? json : JSON.stringify(json, null, 2);
776
+ var escaped = escapeHtml(str);
777
+ return escaped
778
+ .replace(/"([^"]+)":/g, '<span class="json-key">"$1"</span>:')
779
+ .replace(/: "((?:[^"\\]|\\.)*)"/g, ': <span class="json-string">"$1"</span>')
780
+ .replace(/: (\d+\.?\d*)/g, ': <span class="json-number">$1</span>')
781
+ .replace(/: (true|false)/g, ': <span class="json-bool">$1</span>')
782
+ .replace(/: (null)/g, ': <span class="json-null">$1</span>');
783
+ }
784
+
785
+ function renderValue(value) {
786
+ var parsed = parseJsonString(value);
787
+ if (parsed === null || parsed === undefined) {
788
+ return '<span class="value-pill">' + escapeHtml(String(parsed)) + '</span>';
789
+ }
790
+ if (typeof parsed === 'string' || typeof parsed === 'number' || typeof parsed === 'boolean') {
791
+ return '<span class="value-pill">' + escapeHtml(String(parsed)) + '</span>';
792
+ }
793
+ return '<pre class="json-block">' + highlightJson(parsed) + '</pre>';
794
+ }
795
+
796
+ function renderRows(rows) {
797
+ var html = '<table class="detail-table"><tbody>';
798
+ rows.forEach(function(row) {
799
+ if (row[1] === undefined || row[1] === null || row[1] === '') return;
800
+ html += '<tr><th>' + escapeHtml(row[0]) + '</th><td>' + renderValue(row[1]) + '</td></tr>';
801
+ });
802
+ html += '</tbody></table>';
803
+ return html;
804
+ }
805
+
806
+ function renderSection(title, content, dataForCopy) {
807
+ var copyAttr = dataForCopy !== undefined
808
+ ? ' data-copy="' + escapeHtml(typeof dataForCopy === 'string' ? dataForCopy : JSON.stringify(dataForCopy)) + '"'
809
+ : '';
810
+ return '<div class="detail-section"><div class="detail-section-header">' +
811
+ '<span class="detail-section-title">' + escapeHtml(title) + '</span>' +
812
+ '<button class="detail-section-copy" onclick="event.stopPropagation();copySectionData(this)" title="Copy section"' + copyAttr + '>&#9112;</button>' +
813
+ '</div><div class="detail-section-body">' + content + '</div></div>';
814
+ }
815
+
816
+ function renderObjectSection(title, value) {
817
+ var object = readObject(value);
818
+ if (!object) return '';
819
+ return renderSection(title, renderValue(object), object);
820
+ }
821
+
822
+ function renderConsoleDetails(entry) {
823
+ var messages = Array.isArray(entry.data) ? entry.data : [entry.data];
824
+ return renderSection('Console', renderRows([
825
+ ['Level', entry.level],
826
+ ['Time', entry.timestamp ? formatTime(new Date(entry.timestamp).toISOString()) : ''],
827
+ ])) + renderSection('Messages',
828
+ '<div class="value-list">' + messages.map(renderValue).join('') + '</div>',
829
+ entry.data);
830
+ }
831
+
832
+ function renderNetworkDetails(entry) {
833
+ var request = readObject(entry.request) || entry;
834
+ var response = readObject(entry.response);
835
+ var method = (request.method || 'GET').toUpperCase();
836
+ var methodClass = 'method-' + method.toLowerCase();
837
+
838
+ var hero = '<div class="network-hero"><span class="method-badge ' + methodClass + '">' + escapeHtml(method) + '</span>' +
839
+ '<span class="network-url">' + escapeHtml(request.url || '-') + '</span></div>';
840
+
841
+ var reqRows = [
842
+ ['Time', entry.timestamp ? formatTime(new Date(entry.timestamp).toISOString()) : ''],
843
+ ['Duration', entry.duration !== undefined ? entry.duration + 'ms' : ''],
844
+ ];
845
+ if (request.headers) reqRows.push(['Headers', request.headers]);
846
+ if (request.body) reqRows.push(['Body', request.body]);
847
+
848
+ var html = renderSection('Request', hero + renderRows(reqRows), request);
849
+
850
+ if (response) {
851
+ var resRows = [
852
+ ['Status', response.status !== undefined ? response.status + (response.statusText ? ' ' + response.statusText : '') : ''],
853
+ ['Success', response.success !== undefined ? String(response.success) : ''],
854
+ ];
855
+ if (response.headers) resRows.push(['Headers', response.headers]);
856
+ if (response.data) resRows.push(['Data', response.data]);
857
+ html += renderSection('Response', renderRows(resRows), response);
858
+ }
859
+ if (entry.error) {
860
+ html += renderSection('Error', renderValue(entry.error), entry.error);
861
+ }
862
+ return html;
863
+ }
864
+
865
+ function renderNavigationDetails(entry) {
866
+ var hero = '<div class="nav-hero">';
867
+ if (entry.from || entry.path) hero += '<span class="nav-from">' + escapeHtml(entry.from || entry.path || '-') + '</span>';
868
+ if (entry.to) hero += '<span class="nav-arrow">&rarr;</span><span class="nav-to">' + escapeHtml(entry.to) + '</span>';
869
+ hero += '</div>';
870
+ return renderSection('Navigation', hero + renderRows([
871
+ ['Action', entry.action],
872
+ ['Duration', entry.duration !== undefined ? entry.duration + 'ms' : ''],
873
+ ['Time', entry.timestamp ? formatTime(new Date(entry.timestamp).toISOString()) : ''],
874
+ ]));
875
+ }
876
+
877
+ function renderTrackDetails(entry) {
878
+ return renderSection('Event', renderRows([
879
+ ['Name', entry.eventName || entry.event],
880
+ ['Time', entry.timestamp ? formatTime(new Date(entry.timestamp).toISOString()) : ''],
881
+ ])) + renderSection('Payload', renderValue(entry), entry);
882
+ }
883
+
884
+ function renderStateDetails(entry) {
885
+ return renderSection('State', renderRows([
886
+ ['Store', entry.storeName],
887
+ ['Action', entry.action],
888
+ ['Time', entry.timestamp ? formatTime(new Date(entry.timestamp).toISOString()) : ''],
889
+ ])) +
890
+ (entry.prevState !== undefined ? renderSection('Prev', renderValueCompact(entry.prevState), entry.prevState) : '') +
891
+ (entry.nextState !== undefined ? renderSection('Next', renderValueCompact(entry.nextState), entry.nextState) : '');
892
+ }
893
+
894
+ function renderValueCompact(value) {
895
+ var parsed = parseJsonString(value);
896
+ if (parsed === null || parsed === undefined) {
897
+ return '<span class="value-pill">' + escapeHtml(String(parsed)) + '</span>';
898
+ }
899
+ if (typeof parsed === 'string' || typeof parsed === 'number' || typeof parsed === 'boolean') {
900
+ return '<span class="value-pill">' + escapeHtml(String(parsed)) + '</span>';
901
+ }
902
+ return '<pre class="json-block json-compact">' + highlightJson(parsed) + '</pre>';
903
+ }
904
+
905
+ function renderLogDetails(entry, type) {
906
+ var logType = type || getLogType(entry);
907
+ if (!entry || typeof entry !== 'object') {
908
+ return renderSection('Value', renderValue(entry));
909
+ }
910
+ if (logType === 'network') return renderNetworkDetails(entry);
911
+ if (logType === 'console') return renderConsoleDetails(entry);
912
+ if (logType === 'navigation') return renderNavigationDetails(entry);
913
+ if (logType === 'track') return renderTrackDetails(entry);
914
+ if (logType === 'zustand') return renderStateDetails(entry);
915
+ return renderObjectSection(labelForType(logType), entry) || renderSection('Raw', renderValue(entry));
916
+ }
917
+
918
+ // --- Search ---
919
+
920
+ function matchSearch(text) {
921
+ if (!searchTerm) return text;
922
+ var escaped = escapeHtml(text);
923
+ var re = new RegExp('(' + escapeRegex(searchTerm) + ')', 'gi');
924
+ return escaped.replace(re, '<mark>$1</mark>');
925
+ }
926
+
927
+ function escapeRegex(s) {
928
+ return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
929
+ }
930
+
931
+ function entryMatchesSearch(entry) {
932
+ if (!searchTerm) return true;
933
+ var term = searchTerm.toLowerCase();
934
+ var text = summarize(entry);
935
+ // Remove HTML for matching
936
+ var div = document.createElement('div');
937
+ div.innerHTML = text;
938
+ return div.textContent.toLowerCase().indexOf(term) >= 0;
939
+ }
940
+
455
941
  // --- Views ---
456
942
 
457
943
  function renderList() {
458
- currentSession = null;
944
+ currentDevice = null;
459
945
  expandedRows = {};
946
+ searchTerm = '';
947
+ focusedIndex = -1;
460
948
  pulseDot.style.background = 'var(--text3)';
461
949
  pulseDot.style.boxShadow = 'none';
462
950
  statusEl.textContent = 'fetching...';
463
- api('/sessions').then(function(data) {
951
+ api('/devices').then(function(data) {
464
952
  statusEl.textContent = '';
465
953
  pulseDot.style.background = 'var(--cyan)';
466
954
  pulseDot.style.boxShadow = '0 0 8px var(--cyan),0 0 20px rgba(0,229,255,.3)';
467
- var sessions = data.sessions || [];
468
- if (!sessions.length) {
955
+ var devices = data.devices || [];
956
+ if (!devices.length) {
469
957
  app.innerHTML =
470
958
  '<div class="empty">' +
471
959
  '<div class="empty-icon">_</div>' +
472
- '<p>No sessions received yet.</p>' +
960
+ '<p>No device logs received yet.</p>' +
473
961
  '<p style="margin-top:12px;font-size:13px">POST a report to <code>/report</code> to see data here.</p>' +
474
- '</div>';
962
+ '</div>' +
963
+ renderCurlPanel('Curl quick read', [
964
+ curlCommand('/health'),
965
+ curlCommand('/devices'),
966
+ curlCommand('/devices/latest'),
967
+ ]);
475
968
  return;
476
969
  }
477
- var html = '<div class="section-title">Sessions <span style="color:var(--text3);font-weight:400">(' + sessions.length + ')</span></div>';
478
- html += '<div class="session-grid">';
479
- sessions.forEach(function(s, i) {
480
- var lc = s.logCount || {};
481
- html += '<div class="session-card" data-sid="' + escapeHtml(s.sessionId) + '" style="animation-delay:' + (i * 40) + 'ms" onclick="location.hash=\'session/' + encodeURIComponent(s.sessionId) + '\'">';
482
- html += '<div><div class="session-id">' + escapeHtml(s.sessionId) + '</div>';
483
- html += '<div class="session-time">' + formatTime(s.receivedAt) + '</div></div>';
484
- html += '<div class="session-tags">' + renderSessionTags(lc) + '</div>';
485
- html += '<div class="session-arrow">&rsaquo;</div>';
970
+ var html = '<div class="section-title">Devices <span style="color:var(--text3);font-weight:400">(' + devices.length + ')</span></div>';
971
+ html += renderCurlPanel('Curl quick read', [
972
+ curlCommand('/health'),
973
+ curlCommand('/devices'),
974
+ curlCommand('/devices/latest'),
975
+ ]);
976
+ html += '<div class="device-grid">';
977
+ devices.forEach(function(deviceLog, i) {
978
+ var lc = deviceLog.logCount || {};
979
+ var deviceText = formatDevice(deviceLog.device);
980
+ var ipText = formatIp(deviceLog.source);
981
+ html += '<div class="device-card" data-device-id="' + escapeHtml(deviceLog.deviceId) + '" style="animation-delay:' + (i * 40) + 'ms">';
982
+ html += '<div><div class="device-title">' + escapeHtml(deviceText) + '</div>';
983
+ html += '<div class="device-subtitle">IP ' + escapeHtml(ipText) + '</div></div>';
984
+ html += '<div class="device-meta-group">';
985
+ html += '<div class="device-meta-line"><strong>Device</strong>' + escapeHtml(deviceLog.deviceId) + '</div>';
986
+ html += '<div class="device-meta-line"><strong>Last seen</strong>' + formatTime(deviceLog.lastSeenAt || deviceLog.receivedAt) + '</div>';
987
+ html += '</div>';
988
+ html += '<div class="device-tags">' + renderDeviceTags(lc) + '</div>';
989
+ html += '<div class="device-arrow">&rsaquo;</div>';
486
990
  html += '</div>';
487
991
  });
488
992
  html += '</div>';
@@ -495,14 +999,19 @@ header h1 span{color:var(--text3);font-weight:400}
495
999
  });
496
1000
  }
497
1001
 
498
- function renderDetail(sessionId) {
1002
+ function renderDetail(deviceId) {
499
1003
  expandedRows = {};
1004
+ searchTerm = '';
1005
+ focusedIndex = -1;
1006
+ currentPage = 1;
1007
+ window._currentFilterType = '';
1008
+ window._failedOnly = false;
500
1009
  statusEl.textContent = 'loading...';
501
- api('/sessions/' + encodeURIComponent(sessionId)).then(function(data) {
1010
+ api('/devices/' + encodeURIComponent(deviceId)).then(function(data) {
502
1011
  statusEl.textContent = '';
503
1012
  pulseDot.style.background = 'var(--cyan)';
504
1013
  pulseDot.style.boxShadow = '0 0 8px var(--cyan),0 0 20px rgba(0,229,255,.3)';
505
- currentSession = data;
1014
+ currentDevice = data;
506
1015
  var report = data.report || {};
507
1016
  var logs = report.logs || {};
508
1017
  var logTypes = Object.keys(logs);
@@ -510,19 +1019,22 @@ header h1 span{color:var(--text3);font-weight:400}
510
1019
  var html = '';
511
1020
 
512
1021
  // Back link
513
- html += '<a href="#" class="back-link" onclick="location.hash=\'\';return false">';
1022
+ html += '<a href="#" class="back-link">';
514
1023
  html += '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M19 12H5"/><path d="M12 19l-7-7 7-7"/></svg>';
515
- html += 'All sessions</a>';
1024
+ html += 'All devices</a>';
516
1025
 
517
- // Session header card
1026
+ // Device header card
518
1027
  html += '<div class="detail-header">';
519
- html += '<div class="detail-id">' + escapeHtml(data.sessionId) + '</div>';
1028
+ html += '<div class="detail-id">' + escapeHtml(data.deviceId) + '</div>';
520
1029
  html += '<div class="detail-meta">';
521
- html += '<span class="detail-meta-item"><strong>Received</strong> ' + formatTime(data.receivedAt) + '</span>';
1030
+ html += '<span class="detail-meta-item"><strong>Last seen</strong> ' + formatTime(data.lastSeenAt || data.receivedAt) + '</span>';
522
1031
  var totalLogs = Object.values(data.logCount || {}).reduce(function(a, b) { return a + b; }, 0);
523
1032
  html += '<span class="detail-meta-item" data-type="Entries"><strong>Entries</strong> ' + totalLogs + '</span>';
1033
+ if (data.source && data.source.ip) {
1034
+ html += '<span class="detail-meta-item"><strong>IP</strong> ' + escapeHtml(data.source.ip) + '</span>';
1035
+ }
524
1036
  Object.entries(data.logCount || {}).forEach(function(e) {
525
- html += '<span class="detail-meta-item" data-type="' + e[0] + '"><strong>' + e[0] + '</strong> ' + e[1] + '</span>';
1037
+ html += '<span class="detail-meta-item" data-type="' + e[0] + '"><strong>' + escapeHtml(labelForType(e[0])) + '</strong> ' + e[1] + '</span>';
526
1038
  });
527
1039
  html += '</div>';
528
1040
 
@@ -543,45 +1055,70 @@ header h1 span{color:var(--text3);font-weight:400}
543
1055
 
544
1056
  html += '</div>';
545
1057
 
1058
+ html += renderCurlPanel('Curl this device', [
1059
+ curlCommand('/devices/' + encodeURIComponent(deviceId)),
1060
+ curlCommand('/devices/' + encodeURIComponent(deviceId) + '/logs?limit=200'),
1061
+ curlCommand('/devices/' + encodeURIComponent(deviceId) + '/logs?type=network&failedOnly=true&limit=50'),
1062
+ curlCommand('/devices/' + encodeURIComponent(deviceId) + '/logs?type=console&limit=200'),
1063
+ ]);
1064
+
546
1065
  // Tabs
547
1066
  html += '<div class="tabs">';
548
1067
  html += '<button class="tab active" data-type="" onclick="filterType(this,\'\')">All<span class="count">' + totalLogs + '</span></button>';
549
1068
  logTypes.forEach(function(t) {
550
1069
  var count = logs[t] ? logs[t].length : 0;
551
- html += '<button class="tab" data-type="' + t + '" onclick="filterType(this,\'' + t + '\')">' + escapeHtml(t) + '<span class="count">' + count + '</span></button>';
1070
+ html += '<button class="tab" data-type="' + t + '" onclick="filterType(this,\'' + t + '\')">' + escapeHtml(labelForType(t)) + '<span class="count">' + count + '</span></button>';
552
1071
  });
553
1072
  html += '</div>';
554
1073
 
555
- // Toolbar
1074
+ // Toolbar with search
556
1075
  html += '<div class="toolbar">';
1076
+ html += '<div class="search-wrap">';
1077
+ html += '<span class="search-icon">&#9906;</span>';
1078
+ html += '<input class="search-input" id="searchInput" type="text" placeholder="Search logs..." autocomplete="off">';
1079
+ html += '<button class="search-clear" id="searchClear" onclick="clearSearch()">&times;</button>';
1080
+ html += '</div>';
557
1081
  html += '<label>Failed only <div class="toggle" id="failedToggle" onclick="toggleFailed()"></div></label>';
558
- html += '<label>Limit <input type="number" id="limitInput" value="50" min="1" max="500"></label>';
1082
+ html += '<button class="live-notice" id="liveNotice" onclick="showLiveUpdates()">0 new logs</button>';
1083
+ html += '<div class="pager" id="pagerTop"></div>';
559
1084
  html += '</div>';
560
1085
 
561
1086
  // Log container
562
1087
  html += '<div id="logsContainer"></div>';
1088
+ html += '<div class="pager pager-bottom" id="pagerBottom"></div>';
563
1089
 
564
1090
  // Actions
565
1091
  html += '<div class="actions">';
566
1092
  html += '<button class="btn" onclick="copyJSON()">';
567
1093
  html += '<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><rect x="9" y="9" width="13" height="13" rx="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg>';
568
1094
  html += 'Copy JSON</button>';
1095
+ html += '<span style="flex:1"></span>';
1096
+ html += '<span style="font-size:10px;color:var(--text3);font-family:var(--font-mono)"><span class="kbd">/</span> search &nbsp; <span class="kbd">j</span><span class="kbd">k</span> navigate &nbsp; <span class="kbd">Enter</span> expand &nbsp; <span class="kbd">Esc</span> back</span>';
569
1097
  html += '</div>';
570
1098
 
571
1099
  app.innerHTML = html;
572
- document.getElementById('limitInput').addEventListener('change', applyFilters);
573
- renderLogs(logs, '', 50, false);
1100
+
1101
+ // Wire up search
1102
+ var searchInput = document.getElementById('searchInput');
1103
+ searchInput.addEventListener('input', function() {
1104
+ searchTerm = this.value.trim();
1105
+ var clearBtn = document.getElementById('searchClear');
1106
+ if (clearBtn) clearBtn.classList.toggle('visible', searchTerm.length > 0);
1107
+ applyFilters();
1108
+ });
1109
+
1110
+ renderLogs(logs, '', false);
574
1111
  }).catch(function(err) {
575
1112
  statusEl.textContent = '';
576
1113
  app.innerHTML = '<div class="empty" style="color:var(--red)">Failed to load: ' + escapeHtml(err.message) + '</div>';
577
1114
  });
578
1115
  }
579
1116
 
580
- function renderLogs(logs, type, limit, failedOnly) {
1117
+ function collectLogEntries(logs, type, failedOnly) {
581
1118
  var entries = [];
582
1119
  if (type && logs[type]) {
583
1120
  entries = Array.isArray(logs[type])
584
- ? logs[type].map(function(entry) { return { type: type, entry: entry }; })
1121
+ ? logs[type].map(function(entry, index) { return { type: type, entry: entry, order: index }; })
585
1122
  : [];
586
1123
  } else {
587
1124
  Object.entries(logs).forEach(function(logGroup) {
@@ -589,7 +1126,7 @@ header h1 span{color:var(--text3);font-weight:400}
589
1126
  var value = logGroup[1];
590
1127
  if (Array.isArray(value)) {
591
1128
  value.forEach(function(entry) {
592
- entries.push({ type: logType, entry: entry });
1129
+ entries.push({ type: logType, entry: entry, order: entries.length });
593
1130
  });
594
1131
  }
595
1132
  });
@@ -599,28 +1136,88 @@ header h1 span{color:var(--text3);font-weight:400}
599
1136
  entries = entries.filter(function(item) { return isFailedEntry(item.entry); });
600
1137
  }
601
1138
 
602
- entries = entries.slice(-limit);
1139
+ // Search filter
1140
+ if (searchTerm) {
1141
+ entries = entries.filter(function(item) { return entryMatchesSearch(item.entry); });
1142
+ }
1143
+
1144
+ entries = entries
1145
+ .slice()
1146
+ .sort(function(a, b) {
1147
+ var byTime = readTimestamp(b.entry) - readTimestamp(a.entry);
1148
+ return byTime || b.order - a.order;
1149
+ });
1150
+
1151
+ return entries;
1152
+ }
1153
+
1154
+ function renderPagination(total, page, pageSize) {
1155
+ var totalPages = Math.max(1, Math.ceil(total / pageSize));
1156
+ var start = total === 0 ? 0 : ((page - 1) * pageSize) + 1;
1157
+ var end = Math.min(page * pageSize, total);
1158
+ var html = '<span class="pager-info">' + start + '-' + end + ' / ' + total + ' · ' + pageSize + ' per page</span>';
1159
+ html += '<button class="page-btn" onclick="goToPage(' + (page - 1) + ')"' + (page <= 1 ? ' disabled' : '') + '>Prev</button>';
1160
+ html += '<span class="pager-info">' + page + ' / ' + totalPages + '</span>';
1161
+ html += '<button class="page-btn" onclick="goToPage(' + (page + 1) + ')"' + (page >= totalPages ? ' disabled' : '') + '>Next</button>';
1162
+
1163
+ ['pagerTop', 'pagerBottom'].forEach(function(id) {
1164
+ var el = document.getElementById(id);
1165
+ if (el) el.innerHTML = html;
1166
+ });
1167
+ }
1168
+
1169
+ function renderLogEntryHtml(entry, type, rowId, index, isExpanded) {
1170
+ var lt = type || getLogType(entry);
1171
+ var typeClass = toKeyPart(lt);
1172
+ var ts = readTimestamp(entry);
1173
+ var html = '<div class="log-entry' + (isExpanded ? ' expanded' : '') + '" id="entry-' + rowId + '" data-index="' + index + '" data-sort="' + ts + '">';
1174
+ html += '<div class="log-row" onclick="toggleRow(\'' + rowId + '\')">';
1175
+ html += '<div class="log-type log-type-' + typeClass + '">' + escapeHtml(labelForType(lt)) + '</div>';
1176
+ html += '<div class="log-summary-col">';
1177
+ html += '<div class="log-summary">' + matchSearch(summarize(entry)) + '</div>';
1178
+ if (ts) {
1179
+ html += '<div class="log-timestamp">' + formatTimeShort(new Date(ts).toISOString()) + '</div>';
1180
+ }
1181
+ html += '</div>';
1182
+ html += '<div class="log-status">' + statusBadge(entry) + '</div>';
1183
+ html += '<div class="log-copy" onclick="event.stopPropagation();copyEntryJSON(\'' + rowId + '\')"><button class="copy-btn" title="Copy entry JSON">&#9112;</button></div>';
1184
+ html += '<div class="log-expand">' + (isExpanded ? '&#9654;' : '&#9654;') + '</div>';
1185
+ html += '</div>';
1186
+ html += '<div class="log-detail' + (isExpanded ? '' : '') + '" id="detail-' + rowId + '">';
1187
+ html += '<div class="log-detail-inner"><div class="detail-sections">';
1188
+ html += renderLogDetails(entry, lt);
1189
+ html += '<div class="entry-footer">';
1190
+ html += '<button class="btn btn-sm" onclick="event.stopPropagation();copyEntryJSON(\'' + rowId + '\')">&#9112; Copy JSON</button>';
1191
+ html += '</div>';
1192
+ html += '</div></div></div>';
1193
+ html += '</div>';
1194
+ return html;
1195
+ }
1196
+
1197
+ function renderLogs(logs, type, failedOnly) {
1198
+ var allEntries = collectLogEntries(logs, type, failedOnly);
1199
+ var totalPages = Math.max(1, Math.ceil(allEntries.length / PAGE_SIZE));
1200
+ if (currentPage > totalPages) currentPage = totalPages;
1201
+ if (currentPage < 1) currentPage = 1;
1202
+
1203
+ var startIndex = (currentPage - 1) * PAGE_SIZE;
1204
+ var entries = allEntries.slice(startIndex, startIndex + PAGE_SIZE);
1205
+ renderPagination(allEntries.length, currentPage, PAGE_SIZE);
603
1206
 
604
1207
  var container = document.getElementById('logsContainer');
605
1208
  if (!entries.length) {
606
- container.innerHTML = '<div class="empty" style="padding:40px"><div class="empty-icon" style="font-size:24px">0</div><p style="font-size:13px">No logs match filters.</p></div>';
1209
+ container.innerHTML = '<div class="empty" style="padding:40px"><div class="empty-icon" style="font-size:24px">0</div><p style="font-size:13px">' +
1210
+ (searchTerm ? 'No logs match "' + escapeHtml(searchTerm) + '"' : 'No logs match filters.') +
1211
+ '</p></div>';
607
1212
  return;
608
1213
  }
609
1214
 
1215
+ focusedIndex = -1;
610
1216
  var html = '<div class="log-list">';
611
1217
  entries.forEach(function(item, i) {
612
- var entry = item.entry;
613
- var rowId = getLogEntryKey(entry, item.type, i);
614
- var lt = item.type || getLogType(entry);
615
- var typeClass = toKeyPart(lt);
616
- var isExpanded = expandedRows[rowId];
617
- html += '<div class="log-entry' + (isExpanded ? ' expanded' : '') + '" id="entry-' + rowId + '" onclick="toggleRow(\'' + rowId + '\')">';
618
- html += '<div class="log-type log-type-' + typeClass + '">' + escapeHtml(lt.substring(0,4)) + '</div>';
619
- html += '<div class="log-summary">' + summarize(entry) + '</div>';
620
- html += '<div class="log-status">' + statusBadge(entry) + '</div>';
621
- html += '<div class="log-expand">' + (isExpanded ? '&#9660;' : '&#9654;') + '</div>';
622
- html += '<div class="log-json' + (isExpanded ? ' open' : '') + '" id="json-' + rowId + '"><pre>' + escapeHtml(JSON.stringify(entry, null, 2)) + '</pre></div>';
623
- html += '</div>';
1218
+ var absoluteIndex = startIndex + i;
1219
+ var rowId = getLogEntryKey(item.entry, item.type, absoluteIndex);
1220
+ html += renderLogEntryHtml(item.entry, item.type, rowId, i, expandedRows[rowId]);
624
1221
  });
625
1222
  html += '</div>';
626
1223
  container.innerHTML = html;
@@ -632,30 +1229,52 @@ header h1 span{color:var(--text3);font-weight:400}
632
1229
  return {
633
1230
  type: window._currentFilterType || '',
634
1231
  failedOnly: window._failedOnly || false,
635
- limit: document.getElementById('limitInput') ? parseInt(document.getElementById('limitInput').value, 10) || 50 : 50,
1232
+ page: currentPage,
636
1233
  };
637
1234
  }
638
1235
 
639
1236
  function rerenderVisibleLogs() {
640
- if (!currentSession) return;
641
- var logs = currentSession.report ? currentSession.report.logs : {};
1237
+ if (!currentDevice) return;
1238
+ var logs = currentDevice.report ? currentDevice.report.logs : {};
642
1239
  var options = readVisibleLogOptions();
643
- renderLogs(logs, options.type, options.limit, options.failedOnly);
1240
+ renderLogs(logs, options.type, options.failedOnly);
1241
+ updateLiveNotice();
1242
+ }
1243
+
1244
+ function updateCurrentPagination() {
1245
+ if (!currentDevice) return;
1246
+ var logs = currentDevice.report ? currentDevice.report.logs : {};
1247
+ var options = readVisibleLogOptions();
1248
+ var total = collectLogEntries(logs, options.type, options.failedOnly).length;
1249
+ renderPagination(total, currentPage, PAGE_SIZE);
1250
+ }
1251
+
1252
+ function updateLiveNotice() {
1253
+ var notice = document.getElementById('liveNotice');
1254
+ if (!notice) return;
1255
+ if (pendingLiveCount > 0) {
1256
+ notice.textContent = pendingLiveCount + ' new logs';
1257
+ notice.classList.add('visible');
1258
+ } else {
1259
+ notice.classList.remove('visible');
1260
+ }
644
1261
  }
645
1262
 
646
- function refreshCurrentSession() {
647
- if (!currentSession) {
1263
+ function refreshCurrentDevice() {
1264
+ if (!currentDevice) {
648
1265
  renderList();
649
1266
  return Promise.resolve();
650
1267
  }
651
1268
 
652
1269
  statusEl.textContent = 'refreshing...';
653
- return api('/sessions/' + encodeURIComponent(currentSession.sessionId)).then(function(data) {
1270
+ return api('/devices/' + encodeURIComponent(currentDevice.deviceId)).then(function(data) {
654
1271
  statusEl.textContent = '';
655
1272
  if (!data) return;
656
- currentSession.report = data.report;
657
- currentSession.logCount = data.logCount;
658
- currentSession.receivedAt = data.receivedAt;
1273
+ currentDevice.report = data.report;
1274
+ currentDevice.logCount = data.logCount;
1275
+ currentDevice.receivedAt = data.receivedAt;
1276
+ currentDevice.lastSeenAt = data.lastSeenAt;
1277
+ pendingLiveCount = 0;
659
1278
  rerenderVisibleLogs();
660
1279
  updateTabCounts();
661
1280
  }).catch(function(err) {
@@ -665,7 +1284,7 @@ header h1 span{color:var(--text3);font-weight:400}
665
1284
  }
666
1285
 
667
1286
  function refreshCurrentView() {
668
- return refreshCurrentSession();
1287
+ return refreshCurrentDevice();
669
1288
  }
670
1289
 
671
1290
  window.refresh = function() { refreshCurrentView(); };
@@ -685,31 +1304,61 @@ header h1 span{color:var(--text3);font-weight:400}
685
1304
  };
686
1305
 
687
1306
  window.applyFilters = function() {
688
- if (!currentSession) return;
1307
+ if (!currentDevice) return;
689
1308
  expandedRows = {};
1309
+ currentPage = 1;
1310
+ pendingLiveCount = 0;
690
1311
  rerenderVisibleLogs();
691
1312
  };
692
1313
 
1314
+ window.goToPage = function(page) {
1315
+ if (!currentDevice) return;
1316
+ currentPage = Math.max(1, Math.floor(page));
1317
+ expandedRows = {};
1318
+ if (currentPage === 1) pendingLiveCount = 0;
1319
+ rerenderVisibleLogs();
1320
+ };
1321
+
1322
+ window.showLiveUpdates = function() {
1323
+ if (!currentDevice) return;
1324
+ currentPage = 1;
1325
+ expandedRows = {};
1326
+ pendingLiveCount = 0;
1327
+ rerenderVisibleLogs();
1328
+ };
1329
+
1330
+ window.clearSearch = function() {
1331
+ var input = document.getElementById('searchInput');
1332
+ if (input) input.value = '';
1333
+ searchTerm = '';
1334
+ var clearBtn = document.getElementById('searchClear');
1335
+ if (clearBtn) clearBtn.classList.remove('visible');
1336
+ applyFilters();
1337
+ };
1338
+
693
1339
  window.toggleRow = function(rowId) {
694
1340
  var entry = document.getElementById('entry-' + rowId);
695
- var json = document.getElementById('json-' + rowId);
696
- var expand = entry ? entry.querySelector('.log-expand') : null;
697
- if (!entry || !json) return;
1341
+ var detail = document.getElementById('detail-' + rowId);
1342
+ if (!entry || !detail) return;
698
1343
  expandedRows[rowId] = !expandedRows[rowId];
699
1344
  if (expandedRows[rowId]) {
700
1345
  entry.classList.add('expanded');
701
- json.classList.add('open');
702
- if (expand) expand.innerHTML = '&#9660;';
703
1346
  } else {
704
1347
  entry.classList.remove('expanded');
705
- json.classList.remove('open');
706
- if (expand) expand.innerHTML = '&#9654;';
707
1348
  }
708
1349
  };
709
1350
 
1351
+ window.toggleCurl = function(id) {
1352
+ var body = document.getElementById(id);
1353
+ var toggle = document.getElementById('toggle-' + id);
1354
+ if (!body) return;
1355
+ body.classList.toggle('open');
1356
+ if (toggle) toggle.classList.toggle('open');
1357
+ };
1358
+
710
1359
  window.copyJSON = function() {
711
- if (!currentSession) return;
712
- var text = JSON.stringify(currentSession.report, null, 2);
1360
+ if (!currentDevice) return;
1361
+ var text = JSON.stringify(currentDevice.report, null, 2);
713
1362
  navigator.clipboard.writeText(text).then(function() {
714
1363
  showToast('Copied to clipboard');
715
1364
  }).catch(function() {
@@ -725,6 +1374,132 @@ header h1 span{color:var(--text3);font-weight:400}
725
1374
  });
726
1375
  };
727
1376
 
1377
+ window.copyEntryJSON = function(rowId) {
1378
+ // Find the entry data from current device logs
1379
+ if (!currentDevice) return;
1380
+ var el = document.getElementById('entry-' + rowId);
1381
+ if (!el) return;
1382
+
1383
+ var logs = currentDevice.report ? currentDevice.report.logs : {};
1384
+ var options = readVisibleLogOptions();
1385
+ var startIndex = (options.page - 1) * PAGE_SIZE;
1386
+ var entries = collectLogEntries(logs, options.type, options.failedOnly)
1387
+ .slice(startIndex, startIndex + PAGE_SIZE);
1388
+
1389
+ var idx = parseInt(el.getAttribute('data-index'), 10);
1390
+ if (isNaN(idx) || idx < 0 || idx >= entries.length) return;
1391
+ var text = JSON.stringify(entries[idx].entry, null, 2);
1392
+ navigator.clipboard.writeText(text).then(function() {
1393
+ showToast('Entry copied');
1394
+ }).catch(function() {
1395
+ var ta = document.createElement('textarea');
1396
+ ta.value = text;
1397
+ ta.style.position = 'fixed';
1398
+ ta.style.opacity = '0';
1399
+ document.body.appendChild(ta);
1400
+ ta.select();
1401
+ document.execCommand('copy');
1402
+ document.body.removeChild(ta);
1403
+ showToast('Entry copied');
1404
+ });
1405
+ };
1406
+
1407
+ window.copySectionData = function(btn) {
1408
+ var data = btn.getAttribute('data-copy');
1409
+ if (!data) return;
1410
+ navigator.clipboard.writeText(data).then(function() {
1411
+ showToast('Section copied');
1412
+ }).catch(function() {
1413
+ var ta = document.createElement('textarea');
1414
+ ta.value = data;
1415
+ ta.style.position = 'fixed';
1416
+ ta.style.opacity = '0';
1417
+ document.body.appendChild(ta);
1418
+ ta.select();
1419
+ document.execCommand('copy');
1420
+ document.body.removeChild(ta);
1421
+ showToast('Section copied');
1422
+ });
1423
+ };
1424
+
1425
+ // --- Keyboard shortcuts ---
1426
+
1427
+ document.addEventListener('keydown', function(e) {
1428
+ // Don't intercept when typing in inputs
1429
+ if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA') {
1430
+ if (e.key === 'Escape') {
1431
+ e.target.blur();
1432
+ if (e.target.id === 'searchInput' && searchTerm) {
1433
+ clearSearch();
1434
+ }
1435
+ }
1436
+ return;
1437
+ }
1438
+
1439
+ if (e.key === '/') {
1440
+ e.preventDefault();
1441
+ var searchInput = document.getElementById('searchInput');
1442
+ if (searchInput) searchInput.focus();
1443
+ } else if (e.key === 'Escape') {
1444
+ if (currentDevice) {
1445
+ location.hash = '';
1446
+ }
1447
+ } else if (e.key === 'j' || e.key === 'k') {
1448
+ e.preventDefault();
1449
+ navigateEntries(e.key === 'j' ? 1 : -1);
1450
+ } else if (e.key === 'Enter') {
1451
+ e.preventDefault();
1452
+ toggleFocusedEntry();
1453
+ }
1454
+ });
1455
+
1456
+ function navigateEntries(direction) {
1457
+ var list = document.querySelector('.log-list');
1458
+ if (!list) return;
1459
+ var items = list.querySelectorAll('.log-entry');
1460
+ if (!items.length) return;
1461
+
1462
+ // Remove previous focus
1463
+ if (focusedIndex >= 0 && focusedIndex < items.length) {
1464
+ items[focusedIndex].classList.remove('focused');
1465
+ }
1466
+
1467
+ focusedIndex += direction;
1468
+ if (focusedIndex < 0) focusedIndex = 0;
1469
+ if (focusedIndex >= items.length) focusedIndex = items.length - 1;
1470
+
1471
+ items[focusedIndex].classList.add('focused');
1472
+ items[focusedIndex].scrollIntoView({ block: 'nearest', behavior: 'smooth' });
1473
+ }
1474
+
1475
+ function toggleFocusedEntry() {
1476
+ if (focusedIndex < 0) return;
1477
+ var list = document.querySelector('.log-list');
1478
+ if (!list) return;
1479
+ var items = list.querySelectorAll('.log-entry');
1480
+ if (focusedIndex >= items.length) return;
1481
+ items[focusedIndex].click();
1482
+ }
1483
+
1484
+ function openDeviceDetail(deviceId) {
1485
+ if (!deviceId) return;
1486
+ var nextHash = 'device/' + encodeURIComponent(deviceId);
1487
+ if (location.hash === '#' + nextHash) {
1488
+ route();
1489
+ return;
1490
+ }
1491
+ location.hash = nextHash;
1492
+ }
1493
+
1494
+ document.addEventListener('click', function(e) {
1495
+ var target = e.target;
1496
+ if (!target || !target.closest) return;
1497
+ var card = target.closest('.device-card[data-device-id]');
1498
+ if (!card) return;
1499
+ e.preventDefault();
1500
+ openDeviceDetail(card.getAttribute('data-device-id'));
1501
+ });
1502
+
728
1503
  // --- Routing ---
729
1504
 
730
1505
  window._currentFilterType = '';
@@ -732,8 +1507,8 @@ header h1 span{color:var(--text3);font-weight:400}
732
1507
 
733
1508
  function route() {
734
1509
  var hash = location.hash.replace('#', '');
735
- if (hash.startsWith('session/')) {
736
- renderDetail(decodeURIComponent(hash.substring(8)));
1510
+ if (hash.startsWith('device/')) {
1511
+ renderDetail(decodeURIComponent(hash.substring(7)));
737
1512
  } else {
738
1513
  renderList();
739
1514
  }
@@ -741,117 +1516,135 @@ header h1 span{color:var(--text3);font-weight:400}
741
1516
 
742
1517
  // --- Incremental DOM updates ---
743
1518
 
744
- function isFailedEntry(e) {
745
- return e && typeof e === 'object' && (
746
- Boolean(e.error) ||
747
- e.level === 'error' ||
748
- (e.response && (e.response.success === false || e.response.status >= 400))
749
- );
750
- }
751
-
752
- function renderSessionTags(logCount) {
753
- var html = '';
754
- Object.entries(logCount || {}).forEach(function(e) {
755
- var type = String(e[0]);
756
- html += '<span class="tag tag-' + toKeyPart(type) + '">' + escapeHtml(type.substring(0,3)) + ' ' + escapeHtml(String(e[1])) + '</span>';
757
- });
758
- return html;
759
- }
760
-
761
- function appendSessionCard(payload) {
762
- var grid = document.querySelector('.session-grid');
763
- if (!grid) { renderList(); return; }
764
-
765
- var sid = payload.sessionId;
766
- var existing = grid.querySelector('[data-sid="' + CSS.escape(sid) + '"]');
767
- if (existing) {
768
- var tags = existing.querySelector('.session-tags');
769
- if (tags) tags.innerHTML = renderSessionTags(payload.logCount || {});
770
- return;
771
- }
1519
+ function isFailedEntry(e) {
1520
+ return e && typeof e === 'object' && (
1521
+ Boolean(e.error) ||
1522
+ e.level === 'error' ||
1523
+ (e.response && (e.response.success === false || e.response.status >= 400))
1524
+ );
1525
+ }
772
1526
 
773
- var lc = payload.logCount || {};
774
- var card = document.createElement('div');
775
- card.className = 'session-card';
776
- card.setAttribute('data-sid', sid);
777
- card.setAttribute('onclick', "location.hash='session/" + encodeURIComponent(sid) + "'");
778
- var html = '<div><div class="session-id">' + escapeHtml(sid) + '</div>';
779
- html += '<div class="session-time">just now</div></div>';
780
- html += '<div class="session-tags">' + renderSessionTags(lc) + '</div>';
781
- html += '<div class="session-arrow">&rsaquo;</div>';
782
- card.innerHTML = html;
783
- grid.prepend(card);
1527
+ function renderDeviceTags(logCount) {
1528
+ var html = '';
1529
+ Object.entries(logCount || {}).forEach(function(e) {
1530
+ var type = String(e[0]);
1531
+ html += '<span class="tag tag-' + toKeyPart(type) + '">' + escapeHtml(labelForType(type)) + ' ' + escapeHtml(String(e[1])) + '</span>';
1532
+ });
1533
+ return html;
1534
+ }
784
1535
 
785
- // Update session count
786
- var titleEl = document.querySelector('.section-title');
787
- if (titleEl) {
788
- var count = grid.querySelectorAll('.session-card').length;
789
- titleEl.innerHTML = 'Sessions <span style="color:var(--text3);font-weight:400">(' + count + ')</span>';
1536
+ function findDeviceCard(deviceId) {
1537
+ var cards = document.querySelectorAll('.device-card[data-device-id]');
1538
+ for (var i = 0; i < cards.length; i += 1) {
1539
+ if (cards[i].getAttribute('data-device-id') === deviceId) return cards[i];
790
1540
  }
1541
+ return null;
791
1542
  }
792
1543
 
793
- function buildLogEntryHtml(entry, rowId, type) {
794
- var lt = type || getLogType(entry);
795
- var typeClass = toKeyPart(lt);
796
- var html = '<div class="log-type log-type-' + typeClass + '">' + escapeHtml(lt.substring(0,4)) + '</div>';
797
- html += '<div class="log-summary">' + summarize(entry) + '</div>';
798
- html += '<div class="log-status">' + statusBadge(entry) + '</div>';
799
- html += '<div class="log-expand">&#9654;</div>';
800
- html += '<div class="log-json" id="json-' + rowId + '"><pre>' + escapeHtml(JSON.stringify(entry, null, 2)) + '</pre></div>';
801
- return html;
1544
+ function updateVisibleIndexes(list) {
1545
+ Array.from(list.querySelectorAll('.log-entry')).forEach(function(entry, index) {
1546
+ entry.setAttribute('data-index', index);
1547
+ });
1548
+ }
1549
+
1550
+ function visibleDeltaItems(deltaLogs) {
1551
+ var options = readVisibleLogOptions();
1552
+ var items = [];
1553
+ Object.entries(deltaLogs || {}).forEach(function(logGroup) {
1554
+ var type = logGroup[0];
1555
+ var entries = logGroup[1];
1556
+ if (!Array.isArray(entries)) return;
1557
+ entries.forEach(function(entry) {
1558
+ if (options.type && type !== options.type) return;
1559
+ if (options.failedOnly && !isFailedEntry(entry)) return;
1560
+ if (searchTerm && !entryMatchesSearch(entry)) return;
1561
+ items.push({ type: type, entry: entry, order: items.length });
1562
+ });
1563
+ });
1564
+ return items.sort(function(a, b) {
1565
+ var byTime = readTimestamp(b.entry) - readTimestamp(a.entry);
1566
+ return byTime || b.order - a.order;
1567
+ });
802
1568
  }
803
1569
 
804
1570
  function appendDeltaLogs(deltaLogs) {
805
- var container = document.getElementById('logsContainer');
806
- if (!container) return;
1571
+ var items = visibleDeltaItems(deltaLogs);
1572
+ updateCurrentPagination();
1573
+ if (!items.length) {
1574
+ return;
1575
+ }
807
1576
 
808
- var list = container.querySelector('.log-list');
809
- if (!list) {
810
- applyFilters();
1577
+ if (currentPage !== 1) {
1578
+ pendingLiveCount += items.length;
1579
+ updateLiveNotice();
811
1580
  return;
812
1581
  }
813
1582
 
814
- var type = window._currentFilterType || '';
815
- var failedOnly = window._failedOnly || false;
816
- var limit = document.getElementById('limitInput') ? parseInt(document.getElementById('limitInput').value, 10) || 50 : 50;
817
- var allNewEntries = [];
1583
+ pendingLiveCount = 0;
1584
+ updateLiveNotice();
818
1585
 
819
- Object.entries(deltaLogs).forEach(function(entry) {
820
- var t = entry[0];
821
- var entries = entry[1];
822
- if (!Array.isArray(entries)) return;
823
- entries.forEach(function(e) {
824
- if (failedOnly && !isFailedEntry(e)) return;
825
- if (type && t !== type) return;
826
- allNewEntries.push({ type: t, entry: e });
827
- });
828
- });
1586
+ var container = document.getElementById('logsContainer');
1587
+ var list = container ? container.querySelector('.log-list') : null;
1588
+ if (!list) {
1589
+ rerenderVisibleLogs();
1590
+ return;
1591
+ }
829
1592
 
830
- var count = list.querySelectorAll('.log-entry').length;
831
- allNewEntries.forEach(function(item) {
832
- var entry = item.entry;
833
- var rowId = getLogEntryKey(entry, item.type, count++);
834
- var div = document.createElement('div');
835
- div.className = 'log-entry';
836
- div.id = 'entry-' + rowId;
837
- div.setAttribute('onclick', "toggleRow('" + rowId + "')");
838
- div.innerHTML = buildLogEntryHtml(entry, rowId, item.type);
839
- list.appendChild(div);
1593
+ var html = '';
1594
+ items.forEach(function(item) {
1595
+ var rowId = getLogEntryKey(item.entry, item.type, 'live-' + (liveSequence += 1));
1596
+ html += renderLogEntryHtml(item.entry, item.type, rowId, 0, false);
840
1597
  });
1598
+ list.insertAdjacentHTML('afterbegin', html);
841
1599
 
842
- // Trim from top if over limit, skip expanded entries
843
- var entries = list.querySelectorAll('.log-entry');
844
- while (entries.length > limit) {
845
- var first = entries[0];
846
- if (first && first.classList.contains('expanded')) break;
847
- list.removeChild(first);
848
- entries = list.querySelectorAll('.log-entry');
1600
+ while (list.querySelectorAll('.log-entry').length > PAGE_SIZE) {
1601
+ var last = list.lastElementChild;
1602
+ if (!last || last.classList.contains('expanded')) break;
1603
+ list.removeChild(last);
1604
+ }
1605
+ updateVisibleIndexes(list);
1606
+ }
1607
+
1608
+ function appendDeviceCard(payload) {
1609
+ var grid = document.querySelector('.device-grid');
1610
+ if (!grid) { renderList(); return; }
1611
+
1612
+ var deviceId = payload.deviceId;
1613
+ if (!deviceId) return;
1614
+ var existing = findDeviceCard(deviceId);
1615
+ if (existing) {
1616
+ var tags = existing.querySelector('.device-tags');
1617
+ if (tags) tags.innerHTML = renderDeviceTags(payload.logCount || {});
1618
+ return;
1619
+ }
1620
+
1621
+ var lc = payload.logCount || {};
1622
+ var deviceText = formatDevice(payload.device);
1623
+ var ipText = formatIp(payload.source);
1624
+ var card = document.createElement('div');
1625
+ card.className = 'device-card';
1626
+ card.setAttribute('data-device-id', deviceId);
1627
+ var html = '<div><div class="device-title">' + escapeHtml(deviceText) + '</div>';
1628
+ html += '<div class="device-subtitle">IP ' + escapeHtml(ipText) + '</div></div>';
1629
+ html += '<div class="device-meta-group">';
1630
+ html += '<div class="device-meta-line"><strong>Device</strong>' + escapeHtml(deviceId) + '</div>';
1631
+ html += '<div class="device-meta-line"><strong>Last seen</strong> just now</div>';
1632
+ html += '</div>';
1633
+ html += '<div class="device-tags">' + renderDeviceTags(lc) + '</div>';
1634
+ html += '<div class="device-arrow">&rsaquo;</div>';
1635
+ card.innerHTML = html;
1636
+ grid.prepend(card);
1637
+
1638
+ var titleEl = document.querySelector('.section-title');
1639
+ if (titleEl) {
1640
+ var count = grid.querySelectorAll('.device-card').length;
1641
+ titleEl.innerHTML = 'Devices <span style="color:var(--text3);font-weight:400">(' + count + ')</span>';
849
1642
  }
850
1643
  }
851
1644
 
852
1645
  function updateTabCounts() {
853
- if (!currentSession) return;
854
- var logs = currentSession.report ? currentSession.report.logs : {};
1646
+ if (!currentDevice) return;
1647
+ var logs = currentDevice.report ? currentDevice.report.logs : {};
855
1648
  var totalLogs = Object.values(logs).reduce(function(a, v) { return a + (Array.isArray(v) ? v.length : 0); }, 0);
856
1649
 
857
1650
  document.querySelectorAll('.tab').forEach(function(tab) {
@@ -877,7 +1670,7 @@ header h1 span{color:var(--text3);font-weight:400}
877
1670
 
878
1671
  function connectSSE() {
879
1672
  if (eventSource) { try { eventSource.close(); } catch {} }
880
- eventSource = new EventSource(withAuth('/events'));
1673
+ eventSource = new EventSource(withAuth('/events'));
881
1674
 
882
1675
  eventSource.addEventListener('logs', function(e) {
883
1676
  try {
@@ -886,17 +1679,15 @@ header h1 span{color:var(--text3);font-weight:400}
886
1679
  pulseDot.style.background = 'var(--cyan)';
887
1680
  pulseDot.style.boxShadow = '0 0 8px var(--cyan),0 0 20px rgba(0,229,255,.3)';
888
1681
 
889
- // Session list page — append new session card
890
- if (!currentSession) {
891
- appendSessionCard(payload);
1682
+ if (!currentDevice) {
1683
+ appendDeviceCard(payload);
892
1684
  return;
893
1685
  }
894
1686
 
895
- // Detail page merge delta if same session
896
- if (payload.sessionId === currentSession.sessionId) {
1687
+ if (payload.deviceId === currentDevice.deviceId) {
897
1688
  if (payload.type === 'delta' && payload.delta) {
898
1689
  var deltaLogs = payload.delta.logs || {};
899
- var report = currentSession.report || { version: 2, logs: {} };
1690
+ var report = currentDevice.report || { version: 2, logs: {} };
900
1691
  if (!report.logs) report.logs = {};
901
1692
 
902
1693
  Object.entries(deltaLogs).forEach(function(entry) {
@@ -907,15 +1698,15 @@ header h1 span{color:var(--text3);font-weight:400}
907
1698
  report.logs[type] = report.logs[type].concat(entries);
908
1699
  });
909
1700
 
910
- currentSession.report = report;
911
- if (payload.logCount) currentSession.logCount = payload.logCount;
1701
+ currentDevice.report = report;
1702
+ if (payload.logCount) currentDevice.logCount = payload.logCount;
912
1703
  appendDeltaLogs(deltaLogs);
913
1704
  updateTabCounts();
914
1705
  } else if (payload.type === 'full') {
915
- refreshCurrentSession();
1706
+ refreshCurrentDevice();
916
1707
  }
917
- } else if (!location.hash.startsWith('session/')) {
918
- appendSessionCard(payload);
1708
+ } else if (!location.hash.startsWith('device/')) {
1709
+ appendDeviceCard(payload);
919
1710
  }
920
1711
  } catch {}
921
1712
  });
@@ -930,6 +1721,18 @@ header h1 span{color:var(--text3);font-weight:400}
930
1721
  window.addEventListener('hashchange', route);
931
1722
  connectSSE();
932
1723
  route();
1724
+
1725
+ // Fetch LAN IPs from /health and display in header
1726
+ api('/health').then(function(data) {
1727
+ var ips = data && data.ips;
1728
+ if (Array.isArray(ips) && ips.length) {
1729
+ var ipEl = document.getElementById('ipHint');
1730
+ if (ipEl) {
1731
+ ipEl.textContent = 'LAN ' + ips.join(' / ');
1732
+ ipEl.style.display = '';
1733
+ }
1734
+ }
1735
+ }).catch(function() {});
933
1736
  })();
934
1737
  </script>
935
1738
  </body>