react-native-debug-toolkit 3.0.0 → 3.1.2

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 +914 -188
  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{
@@ -189,11 +198,66 @@ header h1 span{color:var(--text3);font-weight:400}
189
198
  }
190
199
  .toggle.on::after{transform:translateX(14px)}
191
200
 
192
- /* Log entries */
201
+ /* Search */
202
+ .search-wrap{
203
+ flex:1;min-width:160px;position:relative;
204
+ }
205
+ .search-input{
206
+ width:100%;padding:5px 10px 5px 28px;
207
+ background:var(--surface);border:1px solid var(--border2);border-radius:4px;
208
+ color:var(--text);font-size:12px;font-family:var(--font-mono);
209
+ transition:border-color .15s;
210
+ }
211
+ .search-input::placeholder{color:var(--text3)}
212
+ .search-input:focus{outline:none;border-color:var(--cyan)}
213
+ .search-icon{
214
+ position:absolute;left:8px;top:50%;transform:translateY(-50%);
215
+ color:var(--text3);font-size:13px;pointer-events:none;
216
+ }
217
+ .search-clear{
218
+ position:absolute;right:6px;top:50%;transform:translateY(-50%);
219
+ background:none;border:none;color:var(--text3);cursor:pointer;
220
+ font-size:14px;padding:2px 4px;line-height:1;
221
+ display:none;
222
+ }
223
+ .search-clear.visible{display:block}
224
+ .search-clear:hover{color:var(--text)}
225
+ .kbd{
226
+ font-family:var(--font-mono);font-size:9px;color:var(--text3);
227
+ background:var(--surface2);border:1px solid var(--border2);
228
+ padding:1px 4px;border-radius:2px;letter-spacing:.02em;
229
+ }
230
+
231
+ /* Curl help - collapsible */
232
+ .curl-panel{
233
+ margin-bottom:18px;padding:12px 14px;background:var(--bg2);
234
+ border:1px solid var(--border);border-radius:var(--radius);
235
+ }
236
+ .curl-header{
237
+ display:flex;align-items:center;justify-content:space-between;
238
+ cursor:pointer;user-select:none;
239
+ }
240
+ .curl-title{
241
+ font-size:11px;font-family:var(--font-mono);font-weight:700;
242
+ color:var(--cyan);text-transform:uppercase;letter-spacing:.08em;
243
+ }
244
+ .curl-toggle{
245
+ font-size:10px;color:var(--text3);transition:transform .2s;
246
+ }
247
+ .curl-toggle.open{transform:rotate(90deg)}
248
+ .curl-body{display:none;margin-top:10px}
249
+ .curl-body.open{display:block}
250
+ .curl-list{display:flex;flex-direction:column;gap:6px}
251
+ .curl-list code{
252
+ display:block;padding:7px 9px;background:rgba(0,0,0,.18);
253
+ border:1px solid rgba(42,63,102,.65);border-radius:4px;
254
+ color:var(--text2);font-family:var(--font-mono);font-size:11px;
255
+ white-space:pre-wrap;word-break:break-word;
256
+ }
257
+
258
+ /* Log entries - redesigned */
193
259
  .log-list{display:flex;flex-direction:column;gap:2px}
194
260
  .log-entry{
195
- display:grid;grid-template-columns:80px 1fr 90px 30px;
196
- align-items:center;gap:0;
197
261
  background:var(--surface);border:1px solid transparent;border-radius:var(--radius);
198
262
  cursor:pointer;transition:all .15s;overflow:hidden;
199
263
  animation:fadeSlideIn .3s ease-out both;
@@ -203,51 +267,174 @@ header h1 span{color:var(--text3);font-weight:400}
203
267
  background:var(--surface2);border-color:var(--border2);
204
268
  box-shadow:0 2px 8px rgba(0,0,0,.2);
205
269
  }
206
- .log-entry.expanded{border-color:var(--cyan-mid);background:var(--surface2)}
270
+ .log-entry.expanded{
271
+ border-color:var(--cyan-mid);background:var(--surface2);
272
+ box-shadow:0 2px 12px rgba(0,0,0,.25);
273
+ }
274
+ .log-entry.focused{outline:1px solid var(--cyan);outline-offset:-1px}
275
+
276
+ .log-row{
277
+ display:grid;grid-template-columns:90px 1fr auto auto 28px;
278
+ align-items:center;gap:0;
279
+ padding:0 4px 0 0;min-height:46px;
280
+ }
207
281
  .log-type{
208
282
  font-family:var(--font-mono);font-size:10px;font-weight:600;
209
283
  text-transform:uppercase;letter-spacing:.06em;
210
- padding:0 14px;height:42px;display:flex;align-items:center;
211
- border-right:1px solid var(--border);
284
+ padding:0 12px;height:100%;display:flex;align-items:center;
285
+ justify-content:center;text-align:center;
212
286
  }
213
287
  .log-type-network{color:var(--cyan)}
214
288
  .log-type-console{color:var(--green)}
215
- .log-type-navigation{color:#7c4dff}
289
+ .log-type-navigation{color:var(--purple)}
216
290
  .log-type-track{color:var(--orange)}
217
- .log-type-zustand{color:#e040fb}
291
+ .log-type-zustand{color:var(--pink)}
218
292
  .log-type-unknown{color:var(--text3)}
293
+
294
+ .log-summary-col{
295
+ padding:8px 12px;min-width:0;overflow:hidden;
296
+ }
219
297
  .log-summary{
220
- padding:0 14px;height:42px;display:flex;align-items:center;
221
298
  font-family:var(--font-mono);font-size:12px;color:var(--text);
222
- overflow:hidden;text-overflow:ellipsis;white-space:nowrap;
299
+ display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;
300
+ overflow:hidden;line-height:1.5;word-break:break-all;
301
+ }
302
+ .log-timestamp{
303
+ font-family:var(--font-mono);font-size:10px;color:var(--text3);
304
+ margin-top:2px;
223
305
  }
306
+
224
307
  .log-status{
225
- padding:0 10px;height:42px;display:flex;align-items:center;justify-content:center;
308
+ padding:0 8px;height:100%;display:flex;align-items:center;justify-content:center;
226
309
  }
227
310
  .badge{
228
311
  font-family:var(--font-mono);font-size:10px;font-weight:600;
229
- padding:2px 8px;border-radius:3px;letter-spacing:.02em;
312
+ padding:2px 8px;border-radius:3px;letter-spacing:.02em;white-space:nowrap;
230
313
  }
231
314
  .badge-ok{background:var(--green-dim);color:var(--green)}
232
315
  .badge-error{background:var(--red-dim);color:var(--red)}
233
316
  .badge-warn{background:var(--amber-dim);color:var(--amber)}
234
317
  .badge-info{background:var(--cyan-dim);color:var(--cyan)}
318
+
319
+ .log-copy{
320
+ padding:0 4px;height:100%;display:flex;align-items:center;justify-content:center;
321
+ opacity:0;transition:opacity .15s;
322
+ }
323
+ .log-entry:hover .log-copy{opacity:1}
324
+ .copy-btn{
325
+ background:none;border:1px solid transparent;cursor:pointer;
326
+ color:var(--text3);font-size:13px;padding:2px 4px;border-radius:3px;
327
+ transition:all .15s;
328
+ }
329
+ .copy-btn:hover{color:var(--cyan);background:var(--cyan-dim);border-color:rgba(0,229,255,.15)}
330
+
235
331
  .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;
332
+ padding:0 8px;height:100%;display:flex;align-items:center;justify-content:center;
333
+ color:var(--text3);font-size:10px;transition:transform .2s,color .15s;
238
334
  }
239
335
  .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;
336
+ .log-entry.expanded .log-expand{transform:rotate(90deg);color:var(--cyan)}
337
+
338
+ /* Expanded detail panel */
339
+ .log-detail{
340
+ display:none;border-top:1px solid var(--border);
341
+ animation:detailFadeIn .2s ease-out;
342
+ }
343
+ .log-entry.expanded .log-detail{display:block}
344
+ @keyframes detailFadeIn{from{opacity:0;transform:translateY(-4px)}to{opacity:1;transform:translateY(0)}}
345
+
346
+ .log-detail-inner{padding:16px 18px}
347
+
348
+ /* Detail sections */
349
+ .detail-sections{display:flex;flex-direction:column;gap:10px}
350
+ .detail-section{
351
+ border:1px solid var(--border);border-radius:var(--radius);
352
+ background:rgba(8,12,22,.35);overflow:hidden;
353
+ }
354
+ .detail-section-header{
355
+ display:flex;align-items:center;justify-content:space-between;
356
+ padding:7px 12px;border-bottom:1px solid var(--border);
357
+ background:rgba(0,229,255,.03);
358
+ }
359
+ .detail-section-title{
360
+ font-size:10px;font-weight:700;text-transform:uppercase;letter-spacing:.08em;
361
+ color:var(--cyan);font-family:var(--font-mono);
362
+ }
363
+ .detail-section-copy{
364
+ background:none;border:none;cursor:pointer;
365
+ color:var(--text3);font-size:11px;padding:1px 4px;border-radius:2px;
366
+ transition:color .15s;
367
+ }
368
+ .detail-section-copy:hover{color:var(--cyan)}
369
+ .detail-section-body{padding:0}
370
+
371
+ /* Detail key-value table */
372
+ .detail-table{width:100%;border-collapse:collapse}
373
+ .detail-table tr{border-bottom:1px solid rgba(30,45,74,.5)}
374
+ .detail-table tr:last-child{border-bottom:none}
375
+ .detail-table th,.detail-table td{
376
+ padding:8px 12px;vertical-align:top;font-size:12px;
377
+ }
378
+ .detail-table th{
379
+ width:100px;color:var(--text3);font-family:var(--font-mono);font-weight:500;
380
+ text-align:left;white-space:nowrap;
381
+ }
382
+ .detail-table td{color:var(--text2);font-family:var(--font-mono);word-break:break-word;line-height:1.6}
383
+
384
+ /* JSON blocks */
385
+ .json-block{
386
+ margin:0;padding:10px 12px;font-family:var(--font-mono);font-size:11px;
387
+ line-height:1.7;color:var(--text2);white-space:pre-wrap;word-break:break-word;
388
+ max-height:320px;overflow:auto;background:rgba(0,0,0,.16);
245
389
  }
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;
390
+ .json-block .json-key{color:var(--cyan)}
391
+ .json-block .json-string{color:var(--green)}
392
+ .json-block .json-number{color:var(--amber)}
393
+ .json-block .json-bool{color:var(--purple)}
394
+ .json-block .json-null{color:var(--text3)}
395
+ .json-compact{max-height:160px;font-size:10px;line-height:1.5;padding:8px 10px}
396
+
397
+ /* Value pills */
398
+ .value-list{display:flex;flex-direction:column;gap:6px;padding:10px 12px}
399
+ .value-pill{
400
+ display:block;padding:8px 10px;border:1px solid rgba(42,63,102,.6);
401
+ border-radius:4px;background:rgba(0,0,0,.1);font-family:var(--font-mono);
402
+ font-size:12px;color:var(--text2);word-break:break-word;line-height:1.6;
403
+ }
404
+
405
+ /* Network detail hero */
406
+ .network-hero{
407
+ padding:12px 14px;border-bottom:1px solid rgba(30,45,74,.5);
408
+ display:flex;align-items:center;gap:10px;
409
+ }
410
+ .method-badge{
411
+ font-family:var(--font-mono);font-size:11px;font-weight:700;
412
+ padding:3px 10px;border-radius:3px;letter-spacing:.04em;
413
+ }
414
+ .method-get{background:var(--cyan-dim);color:var(--cyan)}
415
+ .method-post{background:var(--green-dim);color:var(--green)}
416
+ .method-put{background:var(--amber-dim);color:var(--amber)}
417
+ .method-patch{background:var(--amber-dim);color:var(--amber)}
418
+ .method-delete{background:var(--red-dim);color:var(--red)}
419
+ .network-url{
420
+ font-family:var(--font-mono);font-size:12px;color:var(--text);
421
+ word-break:break-all;line-height:1.5;
422
+ }
423
+
424
+ /* Navigation hero */
425
+ .nav-hero{
426
+ padding:12px 14px;border-bottom:1px solid rgba(30,45,74,.5);
427
+ display:flex;align-items:center;gap:8px;
428
+ font-family:var(--font-mono);font-size:12px;
429
+ }
430
+ .nav-from{color:var(--text3)}
431
+ .nav-arrow{color:var(--cyan);font-size:14px}
432
+ .nav-to{color:var(--text);font-weight:500}
433
+
434
+ /* Entry footer */
435
+ .entry-footer{
436
+ display:flex;align-items:center;gap:8px;
437
+ padding:10px 0 0;margin-top:10px;border-top:1px solid rgba(30,45,74,.5);
251
438
  }
252
439
 
253
440
  /* Actions bar */
@@ -302,13 +489,23 @@ header h1 span{color:var(--text3);font-weight:400}
302
489
  @keyframes livePulse{0%,100%{opacity:1}50%{opacity:.6}}
303
490
  .live-badge-dot{width:6px;height:6px;border-radius:50%;background:var(--cyan)}
304
491
 
492
+ /* Search highlight */
493
+ mark{
494
+ background:rgba(0,229,255,.25);color:var(--text);
495
+ border-radius:2px;padding:0 1px;
496
+ }
497
+
305
498
  /* Responsive */
306
499
  @media(max-width:640px){
307
500
  .container{padding:16px}
308
- .session-card{grid-template-columns:1fr;gap:8px}
309
- .log-entry{grid-template-columns:60px 1fr 70px 24px}
501
+ .device-card{grid-template-columns:1fr;gap:8px}
502
+ .log-row{grid-template-columns:72px 1fr auto 28px}
503
+ .log-copy{display:none}
504
+ .detail-table th{width:80px}
310
505
  .detail-header{padding:14px 16px}
311
506
  .tabs{overflow-x:auto;-webkit-overflow-scrolling:touch}
507
+ .toolbar{gap:8px}
508
+ .search-wrap{min-width:120px;flex-basis:100%}
312
509
  }
313
510
  </style>
314
511
  </head>
@@ -320,6 +517,7 @@ header h1 span{color:var(--text3);font-weight:400}
320
517
  </div>
321
518
  <div class="header-right">
322
519
  <span class="header-meta" id="status"></span>
520
+ <span class="header-meta" id="ipHint" style="color:var(--text2);display:none"></span>
323
521
  <button class="btn" onclick="refresh()">
324
522
  <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
523
  Refresh
@@ -336,28 +534,58 @@ header h1 span{color:var(--text3);font-weight:400}
336
534
  var app = document.getElementById('app');
337
535
  var statusEl = document.getElementById('status');
338
536
  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
- }
537
+ var pulseDot = document.getElementById('pulseDot');
538
+ var currentDevice = null;
539
+ var expandedRows = {};
540
+ var authToken = null;
541
+ var searchTerm = '';
542
+ var focusedIndex = -1;
543
+
544
+ try {
545
+ var params = new URLSearchParams(location.search);
546
+ authToken = params.get('token') || localStorage.getItem('debugToolkitToken');
547
+ if (authToken) localStorage.setItem('debugToolkitToken', authToken);
548
+ } catch {
549
+ authToken = null;
550
+ }
551
+
552
+ function withAuth(path) {
553
+ if (!authToken) return path;
554
+ var join = path.indexOf('?') >= 0 ? '&' : '?';
555
+ return path + join + 'token=' + encodeURIComponent(authToken);
556
+ }
557
+
558
+ function api(path) {
559
+ return fetch(withAuth(path)).then(function(r) {
560
+ return r.json().then(function(body) {
561
+ if (!r.ok) throw new Error(body && body.error ? body.error : ('HTTP ' + r.status));
562
+ return body;
563
+ });
564
+ });
565
+ }
566
+
567
+ function absoluteUrl(path) {
568
+ return location.origin + withAuth(path);
569
+ }
570
+
571
+ function curlCommand(path) {
572
+ return "curl '" + absoluteUrl(path).replace(/'/g, "'\\''") + "'";
573
+ }
574
+
575
+ function renderCurlPanel(title, commands) {
576
+ var id = 'curl-' + Math.random().toString(36).slice(2,8);
577
+ var html = '<div class="curl-panel">';
578
+ html += '<div class="curl-header" onclick="toggleCurl(\'' + id + '\')">';
579
+ html += '<div class="curl-title">' + escapeHtml(title) + '</div>';
580
+ html += '<span class="curl-toggle" id="toggle-' + id + '">&#9654;</span>';
581
+ html += '</div>';
582
+ html += '<div class="curl-body" id="' + id + '"><div class="curl-list">';
583
+ commands.forEach(function(command) {
584
+ html += '<code>' + escapeHtml(command) + '</code>';
585
+ });
586
+ html += '</div></div></div>';
587
+ return html;
588
+ }
361
589
 
362
590
  function showToast(msg) {
363
591
  toastEl.textContent = msg;
@@ -385,10 +613,52 @@ header h1 span{color:var(--text3);font-weight:400}
385
613
  if (!iso) return '';
386
614
  try {
387
615
  var d = new Date(iso);
388
- return d.toLocaleTimeString();
616
+ var pad = function(n) { return String(n).padStart(2, '0'); };
617
+ return pad(d.getHours()) + ':' + pad(d.getMinutes()) + ':' + pad(d.getSeconds()) + '.' + String(d.getMilliseconds()).padStart(3,'0').slice(0,2);
389
618
  } catch { return iso; }
390
619
  }
391
620
 
621
+ function readObject(value) {
622
+ return value && typeof value === 'object' && !Array.isArray(value) ? value : null;
623
+ }
624
+
625
+ function readTimestamp(entry) {
626
+ if (!entry || typeof entry !== 'object') return 0;
627
+ var value = entry.timestamp || entry.time || entry.createdAt;
628
+ if (typeof value === 'number') return value;
629
+ if (typeof value === 'string') {
630
+ var parsed = Date.parse(value);
631
+ return Number.isFinite(parsed) ? parsed : 0;
632
+ }
633
+ return 0;
634
+ }
635
+
636
+ function labelForType(type) {
637
+ var labels = {
638
+ network: 'Network',
639
+ console: 'Console',
640
+ navigation: 'Navigation',
641
+ track: 'Track',
642
+ zustand: 'State',
643
+ clipboard: 'Clipboard',
644
+ environment: 'Environment',
645
+ };
646
+ return labels[type] || (type ? type.charAt(0).toUpperCase() + type.slice(1) : 'Unknown');
647
+ }
648
+
649
+ function formatDevice(device) {
650
+ if (!device || typeof device !== 'object') return 'Unknown device';
651
+ var parts = [];
652
+ if (device.platform) parts.push(String(device.platform).toUpperCase());
653
+ if (device.model) parts.push(String(device.model));
654
+ if (device.osVersion) parts.push('OS ' + String(device.osVersion));
655
+ return parts.length ? parts.join(' / ') : 'Unknown device';
656
+ }
657
+
658
+ function formatIp(source) {
659
+ return source && source.ip ? String(source.ip) : 'unknown ip';
660
+ }
661
+
392
662
  function statusBadge(entry) {
393
663
  if (!entry || typeof entry !== 'object') return '<span class="badge badge-info">-</span>';
394
664
  if (entry.error) return '<span class="badge badge-error">ERR</span>';
@@ -401,25 +671,33 @@ header h1 span{color:var(--text3);font-weight:400}
401
671
  }
402
672
  if (entry.level === 'error') return '<span class="badge badge-error">ERR</span>';
403
673
  if (entry.level === 'warn') return '<span class="badge badge-warn">WRN</span>';
404
- return '<span class="badge badge-ok">OK</span>';
674
+ return '';
405
675
  }
406
676
 
407
677
  function summarize(entry) {
408
678
  if (!entry || typeof entry !== 'object') return escapeHtml(String(entry));
679
+ if (entry.request && typeof entry.request === 'object') {
680
+ return escapeHtml((entry.request.method || 'GET') + ' ' + (entry.request.url || '-'));
681
+ }
409
682
  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));
683
+ if (entry.level && entry.data !== undefined) {
684
+ var data = Array.isArray(entry.data) ? entry.data.map(formatInlineValue).join(' ') : formatInlineValue(entry.data);
685
+ return escapeHtml(data.substring(0, 200));
686
+ }
687
+ if (entry.from || entry.to) return escapeHtml((entry.from || '-') + ' -> ' + (entry.to || '-'));
688
+ if (entry.eventName) return escapeHtml(String(entry.eventName));
689
+ if (entry.event) return escapeHtml(String(entry.event));
690
+ if (entry.action) return escapeHtml(String(entry.action));
691
+ return escapeHtml(JSON.stringify(entry).substring(0, 200));
415
692
  }
416
693
 
417
694
  function getLogType(entry) {
418
695
  if (!entry || typeof entry !== 'object') return 'unknown';
419
- if (entry.method && entry.url) return 'network';
696
+ if (entry.request || entry.response || (entry.method && entry.url)) return 'network';
420
697
  if (entry.level && entry.data !== undefined) return 'console';
421
- if (entry.path) return 'navigation';
422
- if (entry.event) return 'track';
698
+ if (entry.from || entry.to || entry.path) return 'navigation';
699
+ if (entry.eventName || entry.event) return 'track';
700
+ if (entry.prevState !== undefined || entry.nextState !== undefined || entry.storeName) return 'zustand';
423
701
  if (entry.action) return 'zustand';
424
702
  return 'unknown';
425
703
  }
@@ -452,37 +730,245 @@ header h1 span{color:var(--text3);font-weight:400}
452
730
  return logType + '-fallback-' + index + '-' + toKeyPart(stringifyForKey(entry));
453
731
  }
454
732
 
733
+ function parseJsonString(value) {
734
+ if (typeof value !== 'string') return value;
735
+ var trimmed = value.trim();
736
+ if (!trimmed || !/^[\[{]/.test(trimmed)) return value;
737
+ try {
738
+ return JSON.parse(trimmed);
739
+ } catch {
740
+ return value;
741
+ }
742
+ }
743
+
744
+ function formatInlineValue(value) {
745
+ var parsed = parseJsonString(value);
746
+ if (parsed === null || parsed === undefined) return String(parsed);
747
+ if (typeof parsed === 'string') return parsed;
748
+ if (typeof parsed === 'number' || typeof parsed === 'boolean') return String(parsed);
749
+ try {
750
+ return JSON.stringify(parsed);
751
+ } catch {
752
+ return String(parsed);
753
+ }
754
+ }
755
+
756
+ function highlightJson(json) {
757
+ var str = typeof json === 'string' ? json : JSON.stringify(json, null, 2);
758
+ var escaped = escapeHtml(str);
759
+ return escaped
760
+ .replace(/"([^"]+)":/g, '<span class="json-key">"$1"</span>:')
761
+ .replace(/: "((?:[^"\\]|\\.)*)"/g, ': <span class="json-string">"$1"</span>')
762
+ .replace(/: (\d+\.?\d*)/g, ': <span class="json-number">$1</span>')
763
+ .replace(/: (true|false)/g, ': <span class="json-bool">$1</span>')
764
+ .replace(/: (null)/g, ': <span class="json-null">$1</span>');
765
+ }
766
+
767
+ function renderValue(value) {
768
+ var parsed = parseJsonString(value);
769
+ if (parsed === null || parsed === undefined) {
770
+ return '<span class="value-pill">' + escapeHtml(String(parsed)) + '</span>';
771
+ }
772
+ if (typeof parsed === 'string' || typeof parsed === 'number' || typeof parsed === 'boolean') {
773
+ return '<span class="value-pill">' + escapeHtml(String(parsed)) + '</span>';
774
+ }
775
+ return '<pre class="json-block">' + highlightJson(parsed) + '</pre>';
776
+ }
777
+
778
+ function renderRows(rows) {
779
+ var html = '<table class="detail-table"><tbody>';
780
+ rows.forEach(function(row) {
781
+ if (row[1] === undefined || row[1] === null || row[1] === '') return;
782
+ html += '<tr><th>' + escapeHtml(row[0]) + '</th><td>' + renderValue(row[1]) + '</td></tr>';
783
+ });
784
+ html += '</tbody></table>';
785
+ return html;
786
+ }
787
+
788
+ function renderSection(title, content, dataForCopy) {
789
+ var copyAttr = dataForCopy !== undefined
790
+ ? ' data-copy="' + escapeHtml(typeof dataForCopy === 'string' ? dataForCopy : JSON.stringify(dataForCopy)) + '"'
791
+ : '';
792
+ return '<div class="detail-section"><div class="detail-section-header">' +
793
+ '<span class="detail-section-title">' + escapeHtml(title) + '</span>' +
794
+ '<button class="detail-section-copy" onclick="event.stopPropagation();copySectionData(this)" title="Copy section"' + copyAttr + '>&#9112;</button>' +
795
+ '</div><div class="detail-section-body">' + content + '</div></div>';
796
+ }
797
+
798
+ function renderObjectSection(title, value) {
799
+ var object = readObject(value);
800
+ if (!object) return '';
801
+ return renderSection(title, renderValue(object), object);
802
+ }
803
+
804
+ function renderConsoleDetails(entry) {
805
+ var messages = Array.isArray(entry.data) ? entry.data : [entry.data];
806
+ return renderSection('Console', renderRows([
807
+ ['Level', entry.level],
808
+ ['Time', entry.timestamp ? formatTime(new Date(entry.timestamp).toISOString()) : ''],
809
+ ])) + renderSection('Messages',
810
+ '<div class="value-list">' + messages.map(renderValue).join('') + '</div>',
811
+ entry.data);
812
+ }
813
+
814
+ function renderNetworkDetails(entry) {
815
+ var request = readObject(entry.request) || entry;
816
+ var response = readObject(entry.response);
817
+ var method = (request.method || 'GET').toUpperCase();
818
+ var methodClass = 'method-' + method.toLowerCase();
819
+
820
+ var hero = '<div class="network-hero"><span class="method-badge ' + methodClass + '">' + escapeHtml(method) + '</span>' +
821
+ '<span class="network-url">' + escapeHtml(request.url || '-') + '</span></div>';
822
+
823
+ var reqRows = [
824
+ ['Time', entry.timestamp ? formatTime(new Date(entry.timestamp).toISOString()) : ''],
825
+ ['Duration', entry.duration !== undefined ? entry.duration + 'ms' : ''],
826
+ ];
827
+ if (request.headers) reqRows.push(['Headers', request.headers]);
828
+ if (request.body) reqRows.push(['Body', request.body]);
829
+
830
+ var html = renderSection('Request', hero + renderRows(reqRows), request);
831
+
832
+ if (response) {
833
+ var resRows = [
834
+ ['Status', response.status !== undefined ? response.status + (response.statusText ? ' ' + response.statusText : '') : ''],
835
+ ['Success', response.success !== undefined ? String(response.success) : ''],
836
+ ];
837
+ if (response.headers) resRows.push(['Headers', response.headers]);
838
+ if (response.data) resRows.push(['Data', response.data]);
839
+ html += renderSection('Response', renderRows(resRows), response);
840
+ }
841
+ if (entry.error) {
842
+ html += renderSection('Error', renderValue(entry.error), entry.error);
843
+ }
844
+ return html;
845
+ }
846
+
847
+ function renderNavigationDetails(entry) {
848
+ var hero = '<div class="nav-hero">';
849
+ if (entry.from || entry.path) hero += '<span class="nav-from">' + escapeHtml(entry.from || entry.path || '-') + '</span>';
850
+ if (entry.to) hero += '<span class="nav-arrow">&rarr;</span><span class="nav-to">' + escapeHtml(entry.to) + '</span>';
851
+ hero += '</div>';
852
+ return renderSection('Navigation', hero + renderRows([
853
+ ['Action', entry.action],
854
+ ['Duration', entry.duration !== undefined ? entry.duration + 'ms' : ''],
855
+ ['Time', entry.timestamp ? formatTime(new Date(entry.timestamp).toISOString()) : ''],
856
+ ]));
857
+ }
858
+
859
+ function renderTrackDetails(entry) {
860
+ return renderSection('Event', renderRows([
861
+ ['Name', entry.eventName || entry.event],
862
+ ['Time', entry.timestamp ? formatTime(new Date(entry.timestamp).toISOString()) : ''],
863
+ ])) + renderSection('Payload', renderValue(entry), entry);
864
+ }
865
+
866
+ function renderStateDetails(entry) {
867
+ return renderSection('State', renderRows([
868
+ ['Store', entry.storeName],
869
+ ['Action', entry.action],
870
+ ['Time', entry.timestamp ? formatTime(new Date(entry.timestamp).toISOString()) : ''],
871
+ ])) +
872
+ (entry.prevState !== undefined ? renderSection('Prev', renderValueCompact(entry.prevState), entry.prevState) : '') +
873
+ (entry.nextState !== undefined ? renderSection('Next', renderValueCompact(entry.nextState), entry.nextState) : '');
874
+ }
875
+
876
+ function renderValueCompact(value) {
877
+ var parsed = parseJsonString(value);
878
+ if (parsed === null || parsed === undefined) {
879
+ return '<span class="value-pill">' + escapeHtml(String(parsed)) + '</span>';
880
+ }
881
+ if (typeof parsed === 'string' || typeof parsed === 'number' || typeof parsed === 'boolean') {
882
+ return '<span class="value-pill">' + escapeHtml(String(parsed)) + '</span>';
883
+ }
884
+ return '<pre class="json-block json-compact">' + highlightJson(parsed) + '</pre>';
885
+ }
886
+
887
+ function renderLogDetails(entry, type) {
888
+ var logType = type || getLogType(entry);
889
+ if (!entry || typeof entry !== 'object') {
890
+ return renderSection('Value', renderValue(entry));
891
+ }
892
+ if (logType === 'network') return renderNetworkDetails(entry);
893
+ if (logType === 'console') return renderConsoleDetails(entry);
894
+ if (logType === 'navigation') return renderNavigationDetails(entry);
895
+ if (logType === 'track') return renderTrackDetails(entry);
896
+ if (logType === 'zustand') return renderStateDetails(entry);
897
+ return renderObjectSection(labelForType(logType), entry) || renderSection('Raw', renderValue(entry));
898
+ }
899
+
900
+ // --- Search ---
901
+
902
+ function matchSearch(text) {
903
+ if (!searchTerm) return text;
904
+ var escaped = escapeHtml(text);
905
+ var re = new RegExp('(' + escapeRegex(searchTerm) + ')', 'gi');
906
+ return escaped.replace(re, '<mark>$1</mark>');
907
+ }
908
+
909
+ function escapeRegex(s) {
910
+ return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
911
+ }
912
+
913
+ function entryMatchesSearch(entry) {
914
+ if (!searchTerm) return true;
915
+ var term = searchTerm.toLowerCase();
916
+ var text = summarize(entry);
917
+ // Remove HTML for matching
918
+ var div = document.createElement('div');
919
+ div.innerHTML = text;
920
+ return div.textContent.toLowerCase().indexOf(term) >= 0;
921
+ }
922
+
455
923
  // --- Views ---
456
924
 
457
925
  function renderList() {
458
- currentSession = null;
926
+ currentDevice = null;
459
927
  expandedRows = {};
928
+ searchTerm = '';
929
+ focusedIndex = -1;
460
930
  pulseDot.style.background = 'var(--text3)';
461
931
  pulseDot.style.boxShadow = 'none';
462
932
  statusEl.textContent = 'fetching...';
463
- api('/sessions').then(function(data) {
933
+ api('/devices').then(function(data) {
464
934
  statusEl.textContent = '';
465
935
  pulseDot.style.background = 'var(--cyan)';
466
936
  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) {
937
+ var devices = data.devices || [];
938
+ if (!devices.length) {
469
939
  app.innerHTML =
470
940
  '<div class="empty">' +
471
941
  '<div class="empty-icon">_</div>' +
472
- '<p>No sessions received yet.</p>' +
942
+ '<p>No device logs received yet.</p>' +
473
943
  '<p style="margin-top:12px;font-size:13px">POST a report to <code>/report</code> to see data here.</p>' +
474
- '</div>';
944
+ '</div>' +
945
+ renderCurlPanel('Curl quick read', [
946
+ curlCommand('/health'),
947
+ curlCommand('/devices'),
948
+ curlCommand('/devices/latest'),
949
+ ]);
475
950
  return;
476
951
  }
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>';
952
+ var html = '<div class="section-title">Devices <span style="color:var(--text3);font-weight:400">(' + devices.length + ')</span></div>';
953
+ html += renderCurlPanel('Curl quick read', [
954
+ curlCommand('/health'),
955
+ curlCommand('/devices'),
956
+ curlCommand('/devices/latest'),
957
+ ]);
958
+ html += '<div class="device-grid">';
959
+ devices.forEach(function(deviceLog, i) {
960
+ var lc = deviceLog.logCount || {};
961
+ var deviceText = formatDevice(deviceLog.device);
962
+ var ipText = formatIp(deviceLog.source);
963
+ html += '<div class="device-card" data-device-id="' + escapeHtml(deviceLog.deviceId) + '" style="animation-delay:' + (i * 40) + 'ms" onclick="location.hash=\'device/' + encodeURIComponent(deviceLog.deviceId) + '\'">';
964
+ html += '<div><div class="device-title">' + escapeHtml(deviceText) + '</div>';
965
+ html += '<div class="device-subtitle">IP ' + escapeHtml(ipText) + '</div></div>';
966
+ html += '<div class="device-meta-group">';
967
+ html += '<div class="device-meta-line"><strong>Device</strong>' + escapeHtml(deviceLog.deviceId) + '</div>';
968
+ html += '<div class="device-meta-line"><strong>Last seen</strong>' + formatTime(deviceLog.lastSeenAt || deviceLog.receivedAt) + '</div>';
969
+ html += '</div>';
970
+ html += '<div class="device-tags">' + renderDeviceTags(lc) + '</div>';
971
+ html += '<div class="device-arrow">&rsaquo;</div>';
486
972
  html += '</div>';
487
973
  });
488
974
  html += '</div>';
@@ -495,14 +981,18 @@ header h1 span{color:var(--text3);font-weight:400}
495
981
  });
496
982
  }
497
983
 
498
- function renderDetail(sessionId) {
984
+ function renderDetail(deviceId) {
499
985
  expandedRows = {};
986
+ searchTerm = '';
987
+ focusedIndex = -1;
988
+ window._currentFilterType = '';
989
+ window._failedOnly = false;
500
990
  statusEl.textContent = 'loading...';
501
- api('/sessions/' + encodeURIComponent(sessionId)).then(function(data) {
991
+ api('/devices/' + encodeURIComponent(deviceId)).then(function(data) {
502
992
  statusEl.textContent = '';
503
993
  pulseDot.style.background = 'var(--cyan)';
504
994
  pulseDot.style.boxShadow = '0 0 8px var(--cyan),0 0 20px rgba(0,229,255,.3)';
505
- currentSession = data;
995
+ currentDevice = data;
506
996
  var report = data.report || {};
507
997
  var logs = report.logs || {};
508
998
  var logTypes = Object.keys(logs);
@@ -512,17 +1002,20 @@ header h1 span{color:var(--text3);font-weight:400}
512
1002
  // Back link
513
1003
  html += '<a href="#" class="back-link" onclick="location.hash=\'\';return false">';
514
1004
  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>';
1005
+ html += 'All devices</a>';
516
1006
 
517
- // Session header card
1007
+ // Device header card
518
1008
  html += '<div class="detail-header">';
519
- html += '<div class="detail-id">' + escapeHtml(data.sessionId) + '</div>';
1009
+ html += '<div class="detail-id">' + escapeHtml(data.deviceId) + '</div>';
520
1010
  html += '<div class="detail-meta">';
521
- html += '<span class="detail-meta-item"><strong>Received</strong> ' + formatTime(data.receivedAt) + '</span>';
1011
+ html += '<span class="detail-meta-item"><strong>Last seen</strong> ' + formatTime(data.lastSeenAt || data.receivedAt) + '</span>';
522
1012
  var totalLogs = Object.values(data.logCount || {}).reduce(function(a, b) { return a + b; }, 0);
523
1013
  html += '<span class="detail-meta-item" data-type="Entries"><strong>Entries</strong> ' + totalLogs + '</span>';
1014
+ if (data.source && data.source.ip) {
1015
+ html += '<span class="detail-meta-item"><strong>IP</strong> ' + escapeHtml(data.source.ip) + '</span>';
1016
+ }
524
1017
  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>';
1018
+ html += '<span class="detail-meta-item" data-type="' + e[0] + '"><strong>' + escapeHtml(labelForType(e[0])) + '</strong> ' + e[1] + '</span>';
526
1019
  });
527
1020
  html += '</div>';
528
1021
 
@@ -543,17 +1036,29 @@ header h1 span{color:var(--text3);font-weight:400}
543
1036
 
544
1037
  html += '</div>';
545
1038
 
1039
+ html += renderCurlPanel('Curl this device', [
1040
+ curlCommand('/devices/' + encodeURIComponent(deviceId)),
1041
+ curlCommand('/devices/' + encodeURIComponent(deviceId) + '/logs?limit=100'),
1042
+ curlCommand('/devices/' + encodeURIComponent(deviceId) + '/logs?type=network&failedOnly=true&limit=50'),
1043
+ curlCommand('/devices/' + encodeURIComponent(deviceId) + '/logs?type=console&limit=100'),
1044
+ ]);
1045
+
546
1046
  // Tabs
547
1047
  html += '<div class="tabs">';
548
1048
  html += '<button class="tab active" data-type="" onclick="filterType(this,\'\')">All<span class="count">' + totalLogs + '</span></button>';
549
1049
  logTypes.forEach(function(t) {
550
1050
  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>';
1051
+ html += '<button class="tab" data-type="' + t + '" onclick="filterType(this,\'' + t + '\')">' + escapeHtml(labelForType(t)) + '<span class="count">' + count + '</span></button>';
552
1052
  });
553
1053
  html += '</div>';
554
1054
 
555
- // Toolbar
1055
+ // Toolbar with search
556
1056
  html += '<div class="toolbar">';
1057
+ html += '<div class="search-wrap">';
1058
+ html += '<span class="search-icon">&#9906;</span>';
1059
+ html += '<input class="search-input" id="searchInput" type="text" placeholder="Search logs..." autocomplete="off">';
1060
+ html += '<button class="search-clear" id="searchClear" onclick="clearSearch()">&times;</button>';
1061
+ html += '</div>';
557
1062
  html += '<label>Failed only <div class="toggle" id="failedToggle" onclick="toggleFailed()"></div></label>';
558
1063
  html += '<label>Limit <input type="number" id="limitInput" value="50" min="1" max="500"></label>';
559
1064
  html += '</div>';
@@ -566,9 +1071,21 @@ header h1 span{color:var(--text3);font-weight:400}
566
1071
  html += '<button class="btn" onclick="copyJSON()">';
567
1072
  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
1073
  html += 'Copy JSON</button>';
1074
+ html += '<span style="flex:1"></span>';
1075
+ 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
1076
  html += '</div>';
570
1077
 
571
1078
  app.innerHTML = html;
1079
+
1080
+ // Wire up search
1081
+ var searchInput = document.getElementById('searchInput');
1082
+ searchInput.addEventListener('input', function() {
1083
+ searchTerm = this.value.trim();
1084
+ var clearBtn = document.getElementById('searchClear');
1085
+ if (clearBtn) clearBtn.classList.toggle('visible', searchTerm.length > 0);
1086
+ applyFilters();
1087
+ });
1088
+
572
1089
  document.getElementById('limitInput').addEventListener('change', applyFilters);
573
1090
  renderLogs(logs, '', 50, false);
574
1091
  }).catch(function(err) {
@@ -581,7 +1098,7 @@ header h1 span{color:var(--text3);font-weight:400}
581
1098
  var entries = [];
582
1099
  if (type && logs[type]) {
583
1100
  entries = Array.isArray(logs[type])
584
- ? logs[type].map(function(entry) { return { type: type, entry: entry }; })
1101
+ ? logs[type].map(function(entry, index) { return { type: type, entry: entry, order: index }; })
585
1102
  : [];
586
1103
  } else {
587
1104
  Object.entries(logs).forEach(function(logGroup) {
@@ -589,7 +1106,7 @@ header h1 span{color:var(--text3);font-weight:400}
589
1106
  var value = logGroup[1];
590
1107
  if (Array.isArray(value)) {
591
1108
  value.forEach(function(entry) {
592
- entries.push({ type: logType, entry: entry });
1109
+ entries.push({ type: logType, entry: entry, order: entries.length });
593
1110
  });
594
1111
  }
595
1112
  });
@@ -599,14 +1116,28 @@ header h1 span{color:var(--text3);font-weight:400}
599
1116
  entries = entries.filter(function(item) { return isFailedEntry(item.entry); });
600
1117
  }
601
1118
 
602
- entries = entries.slice(-limit);
1119
+ // Search filter
1120
+ if (searchTerm) {
1121
+ entries = entries.filter(function(item) { return entryMatchesSearch(item.entry); });
1122
+ }
1123
+
1124
+ entries = entries
1125
+ .slice()
1126
+ .sort(function(a, b) {
1127
+ var byTime = readTimestamp(b.entry) - readTimestamp(a.entry);
1128
+ return byTime || b.order - a.order;
1129
+ })
1130
+ .slice(0, limit);
603
1131
 
604
1132
  var container = document.getElementById('logsContainer');
605
1133
  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>';
1134
+ container.innerHTML = '<div class="empty" style="padding:40px"><div class="empty-icon" style="font-size:24px">0</div><p style="font-size:13px">' +
1135
+ (searchTerm ? 'No logs match "' + escapeHtml(searchTerm) + '"' : 'No logs match filters.') +
1136
+ '</p></div>';
607
1137
  return;
608
1138
  }
609
1139
 
1140
+ focusedIndex = -1;
610
1141
  var html = '<div class="log-list">';
611
1142
  entries.forEach(function(item, i) {
612
1143
  var entry = item.entry;
@@ -614,12 +1145,31 @@ header h1 span{color:var(--text3);font-weight:400}
614
1145
  var lt = item.type || getLogType(entry);
615
1146
  var typeClass = toKeyPart(lt);
616
1147
  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>';
1148
+ var ts = readTimestamp(entry);
1149
+
1150
+ html += '<div class="log-entry' + (isExpanded ? ' expanded' : '') + '" id="entry-' + rowId + '" data-index="' + i + '">';
1151
+ html += '<div class="log-row" onclick="toggleRow(\'' + rowId + '\')">';
1152
+ html += '<div class="log-type log-type-' + typeClass + '">' + escapeHtml(labelForType(lt)) + '</div>';
1153
+ html += '<div class="log-summary-col">';
1154
+ html += '<div class="log-summary">' + matchSearch(summarize(entry)) + '</div>';
1155
+ if (ts) {
1156
+ html += '<div class="log-timestamp">' + formatTimeShort(new Date(ts).toISOString()) + '</div>';
1157
+ }
1158
+ html += '</div>';
620
1159
  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>';
1160
+ html += '<div class="log-copy" onclick="event.stopPropagation();copyEntryJSON(\'' + rowId + '\')"><button class="copy-btn" title="Copy entry JSON">&#9112;</button></div>';
1161
+ html += '<div class="log-expand">' + (isExpanded ? '&#9654;' : '&#9654;') + '</div>';
1162
+ html += '</div>';
1163
+
1164
+ // Detail panel
1165
+ html += '<div class="log-detail' + (isExpanded ? '' : '') + '" id="detail-' + rowId + '">';
1166
+ html += '<div class="log-detail-inner"><div class="detail-sections">';
1167
+ html += renderLogDetails(entry, lt);
1168
+ html += '<div class="entry-footer">';
1169
+ html += '<button class="btn btn-sm" onclick="event.stopPropagation();copyEntryJSON(\'' + rowId + '\')">&#9112; Copy JSON</button>';
1170
+ html += '</div>';
1171
+ html += '</div></div></div>';
1172
+
623
1173
  html += '</div>';
624
1174
  });
625
1175
  html += '</div>';
@@ -637,25 +1187,26 @@ header h1 span{color:var(--text3);font-weight:400}
637
1187
  }
638
1188
 
639
1189
  function rerenderVisibleLogs() {
640
- if (!currentSession) return;
641
- var logs = currentSession.report ? currentSession.report.logs : {};
1190
+ if (!currentDevice) return;
1191
+ var logs = currentDevice.report ? currentDevice.report.logs : {};
642
1192
  var options = readVisibleLogOptions();
643
1193
  renderLogs(logs, options.type, options.limit, options.failedOnly);
644
1194
  }
645
1195
 
646
- function refreshCurrentSession() {
647
- if (!currentSession) {
1196
+ function refreshCurrentDevice() {
1197
+ if (!currentDevice) {
648
1198
  renderList();
649
1199
  return Promise.resolve();
650
1200
  }
651
1201
 
652
1202
  statusEl.textContent = 'refreshing...';
653
- return api('/sessions/' + encodeURIComponent(currentSession.sessionId)).then(function(data) {
1203
+ return api('/devices/' + encodeURIComponent(currentDevice.deviceId)).then(function(data) {
654
1204
  statusEl.textContent = '';
655
1205
  if (!data) return;
656
- currentSession.report = data.report;
657
- currentSession.logCount = data.logCount;
658
- currentSession.receivedAt = data.receivedAt;
1206
+ currentDevice.report = data.report;
1207
+ currentDevice.logCount = data.logCount;
1208
+ currentDevice.receivedAt = data.receivedAt;
1209
+ currentDevice.lastSeenAt = data.lastSeenAt;
659
1210
  rerenderVisibleLogs();
660
1211
  updateTabCounts();
661
1212
  }).catch(function(err) {
@@ -665,7 +1216,7 @@ header h1 span{color:var(--text3);font-weight:400}
665
1216
  }
666
1217
 
667
1218
  function refreshCurrentView() {
668
- return refreshCurrentSession();
1219
+ return refreshCurrentDevice();
669
1220
  }
670
1221
 
671
1222
  window.refresh = function() { refreshCurrentView(); };
@@ -685,31 +1236,43 @@ header h1 span{color:var(--text3);font-weight:400}
685
1236
  };
686
1237
 
687
1238
  window.applyFilters = function() {
688
- if (!currentSession) return;
1239
+ if (!currentDevice) return;
689
1240
  expandedRows = {};
690
1241
  rerenderVisibleLogs();
691
1242
  };
692
1243
 
1244
+ window.clearSearch = function() {
1245
+ var input = document.getElementById('searchInput');
1246
+ if (input) input.value = '';
1247
+ searchTerm = '';
1248
+ var clearBtn = document.getElementById('searchClear');
1249
+ if (clearBtn) clearBtn.classList.remove('visible');
1250
+ applyFilters();
1251
+ };
1252
+
693
1253
  window.toggleRow = function(rowId) {
694
1254
  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;
1255
+ var detail = document.getElementById('detail-' + rowId);
1256
+ if (!entry || !detail) return;
698
1257
  expandedRows[rowId] = !expandedRows[rowId];
699
1258
  if (expandedRows[rowId]) {
700
1259
  entry.classList.add('expanded');
701
- json.classList.add('open');
702
- if (expand) expand.innerHTML = '&#9660;';
703
1260
  } else {
704
1261
  entry.classList.remove('expanded');
705
- json.classList.remove('open');
706
- if (expand) expand.innerHTML = '&#9654;';
707
1262
  }
708
1263
  };
709
1264
 
1265
+ window.toggleCurl = function(id) {
1266
+ var body = document.getElementById(id);
1267
+ var toggle = document.getElementById('toggle-' + id);
1268
+ if (!body) return;
1269
+ body.classList.toggle('open');
1270
+ if (toggle) toggle.classList.toggle('open');
1271
+ };
1272
+
710
1273
  window.copyJSON = function() {
711
- if (!currentSession) return;
712
- var text = JSON.stringify(currentSession.report, null, 2);
1274
+ if (!currentDevice) return;
1275
+ var text = JSON.stringify(currentDevice.report, null, 2);
713
1276
  navigator.clipboard.writeText(text).then(function() {
714
1277
  showToast('Copied to clipboard');
715
1278
  }).catch(function() {
@@ -725,6 +1288,135 @@ header h1 span{color:var(--text3);font-weight:400}
725
1288
  });
726
1289
  };
727
1290
 
1291
+ window.copyEntryJSON = function(rowId) {
1292
+ // Find the entry data from current device logs
1293
+ if (!currentDevice) return;
1294
+ var el = document.getElementById('entry-' + rowId);
1295
+ if (!el) return;
1296
+
1297
+ // Re-derive the entry from current logs
1298
+ var logs = currentDevice.report ? currentDevice.report.logs : {};
1299
+ var entries = [];
1300
+ Object.entries(logs).forEach(function(logGroup) {
1301
+ var logType = logGroup[0];
1302
+ var value = logGroup[1];
1303
+ if (Array.isArray(value)) {
1304
+ value.forEach(function(entry, index) {
1305
+ entries.push({ type: logType, entry: entry, order: entries.length });
1306
+ });
1307
+ }
1308
+ });
1309
+
1310
+ var options = readVisibleLogOptions();
1311
+ if (options.type) {
1312
+ entries = entries.filter(function(item) { return item.type === options.type; });
1313
+ }
1314
+ if (options.failedOnly) {
1315
+ entries = entries.filter(function(item) { return isFailedEntry(item.entry); });
1316
+ }
1317
+ if (searchTerm) {
1318
+ entries = entries.filter(function(item) { return entryMatchesSearch(item.entry); });
1319
+ }
1320
+ entries = entries.slice().sort(function(a, b) {
1321
+ var byTime = readTimestamp(b.entry) - readTimestamp(a.entry);
1322
+ return byTime || b.order - a.order;
1323
+ }).slice(0, options.limit);
1324
+
1325
+ var idx = parseInt(el.getAttribute('data-index'), 10);
1326
+ if (isNaN(idx) || idx < 0 || idx >= entries.length) return;
1327
+ var text = JSON.stringify(entries[idx].entry, null, 2);
1328
+ navigator.clipboard.writeText(text).then(function() {
1329
+ showToast('Entry copied');
1330
+ }).catch(function() {
1331
+ var ta = document.createElement('textarea');
1332
+ ta.value = text;
1333
+ ta.style.position = 'fixed';
1334
+ ta.style.opacity = '0';
1335
+ document.body.appendChild(ta);
1336
+ ta.select();
1337
+ document.execCommand('copy');
1338
+ document.body.removeChild(ta);
1339
+ showToast('Entry copied');
1340
+ });
1341
+ };
1342
+
1343
+ window.copySectionData = function(btn) {
1344
+ var data = btn.getAttribute('data-copy');
1345
+ if (!data) return;
1346
+ navigator.clipboard.writeText(data).then(function() {
1347
+ showToast('Section copied');
1348
+ }).catch(function() {
1349
+ var ta = document.createElement('textarea');
1350
+ ta.value = data;
1351
+ ta.style.position = 'fixed';
1352
+ ta.style.opacity = '0';
1353
+ document.body.appendChild(ta);
1354
+ ta.select();
1355
+ document.execCommand('copy');
1356
+ document.body.removeChild(ta);
1357
+ showToast('Section copied');
1358
+ });
1359
+ };
1360
+
1361
+ // --- Keyboard shortcuts ---
1362
+
1363
+ document.addEventListener('keydown', function(e) {
1364
+ // Don't intercept when typing in inputs
1365
+ if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA') {
1366
+ if (e.key === 'Escape') {
1367
+ e.target.blur();
1368
+ if (e.target.id === 'searchInput' && searchTerm) {
1369
+ clearSearch();
1370
+ }
1371
+ }
1372
+ return;
1373
+ }
1374
+
1375
+ if (e.key === '/') {
1376
+ e.preventDefault();
1377
+ var searchInput = document.getElementById('searchInput');
1378
+ if (searchInput) searchInput.focus();
1379
+ } else if (e.key === 'Escape') {
1380
+ if (currentDevice) {
1381
+ location.hash = '';
1382
+ }
1383
+ } else if (e.key === 'j' || e.key === 'k') {
1384
+ e.preventDefault();
1385
+ navigateEntries(e.key === 'j' ? 1 : -1);
1386
+ } else if (e.key === 'Enter') {
1387
+ e.preventDefault();
1388
+ toggleFocusedEntry();
1389
+ }
1390
+ });
1391
+
1392
+ function navigateEntries(direction) {
1393
+ var list = document.querySelector('.log-list');
1394
+ if (!list) return;
1395
+ var items = list.querySelectorAll('.log-entry');
1396
+ if (!items.length) return;
1397
+
1398
+ // Remove previous focus
1399
+ if (focusedIndex >= 0 && focusedIndex < items.length) {
1400
+ items[focusedIndex].classList.remove('focused');
1401
+ }
1402
+
1403
+ focusedIndex += direction;
1404
+ if (focusedIndex < 0) focusedIndex = 0;
1405
+ if (focusedIndex >= items.length) focusedIndex = items.length - 1;
1406
+
1407
+ items[focusedIndex].classList.add('focused');
1408
+ items[focusedIndex].scrollIntoView({ block: 'nearest', behavior: 'smooth' });
1409
+ }
1410
+
1411
+ function toggleFocusedEntry() {
1412
+ if (focusedIndex < 0) return;
1413
+ var list = document.querySelector('.log-list');
1414
+ if (!list) return;
1415
+ var items = list.querySelectorAll('.log-entry');
1416
+ if (focusedIndex >= items.length) return;
1417
+ items[focusedIndex].click();
1418
+ }
1419
+
728
1420
  // --- Routing ---
729
1421
 
730
1422
  window._currentFilterType = '';
@@ -732,8 +1424,8 @@ header h1 span{color:var(--text3);font-weight:400}
732
1424
 
733
1425
  function route() {
734
1426
  var hash = location.hash.replace('#', '');
735
- if (hash.startsWith('session/')) {
736
- renderDetail(decodeURIComponent(hash.substring(8)));
1427
+ if (hash.startsWith('device/')) {
1428
+ renderDetail(decodeURIComponent(hash.substring(7)));
737
1429
  } else {
738
1430
  renderList();
739
1431
  }
@@ -741,63 +1433,84 @@ header h1 span{color:var(--text3);font-weight:400}
741
1433
 
742
1434
  // --- Incremental DOM updates ---
743
1435
 
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');
1436
+ function isFailedEntry(e) {
1437
+ return e && typeof e === 'object' && (
1438
+ Boolean(e.error) ||
1439
+ e.level === 'error' ||
1440
+ (e.response && (e.response.success === false || e.response.status >= 400))
1441
+ );
1442
+ }
1443
+
1444
+ function renderDeviceTags(logCount) {
1445
+ var html = '';
1446
+ Object.entries(logCount || {}).forEach(function(e) {
1447
+ var type = String(e[0]);
1448
+ html += '<span class="tag tag-' + toKeyPart(type) + '">' + escapeHtml(labelForType(type)) + ' ' + escapeHtml(String(e[1])) + '</span>';
1449
+ });
1450
+ return html;
1451
+ }
1452
+
1453
+ function appendDeviceCard(payload) {
1454
+ var grid = document.querySelector('.device-grid');
763
1455
  if (!grid) { renderList(); return; }
764
1456
 
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
- }
1457
+ var deviceId = payload.deviceId;
1458
+ if (!deviceId) return;
1459
+ var existing = grid.querySelector('[data-device-id="' + CSS.escape(deviceId) + '"]');
1460
+ if (existing) {
1461
+ var tags = existing.querySelector('.device-tags');
1462
+ if (tags) tags.innerHTML = renderDeviceTags(payload.logCount || {});
1463
+ return;
1464
+ }
772
1465
 
773
- var lc = payload.logCount || {};
1466
+ var lc = payload.logCount || {};
1467
+ var deviceText = formatDevice(payload.device);
1468
+ var ipText = formatIp(payload.source);
774
1469
  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>';
1470
+ card.className = 'device-card';
1471
+ card.setAttribute('data-device-id', deviceId);
1472
+ card.setAttribute('onclick', "location.hash='device/" + encodeURIComponent(deviceId) + "'");
1473
+ var html = '<div><div class="device-title">' + escapeHtml(deviceText) + '</div>';
1474
+ html += '<div class="device-subtitle">IP ' + escapeHtml(ipText) + '</div></div>';
1475
+ html += '<div class="device-meta-group">';
1476
+ html += '<div class="device-meta-line"><strong>Device</strong>' + escapeHtml(deviceId) + '</div>';
1477
+ html += '<div class="device-meta-line"><strong>Last seen</strong> just now</div>';
1478
+ html += '</div>';
1479
+ html += '<div class="device-tags">' + renderDeviceTags(lc) + '</div>';
1480
+ html += '<div class="device-arrow">&rsaquo;</div>';
782
1481
  card.innerHTML = html;
783
1482
  grid.prepend(card);
784
1483
 
785
- // Update session count
786
1484
  var titleEl = document.querySelector('.section-title');
787
1485
  if (titleEl) {
788
- var count = grid.querySelectorAll('.session-card').length;
789
- titleEl.innerHTML = 'Sessions <span style="color:var(--text3);font-weight:400">(' + count + ')</span>';
1486
+ var count = grid.querySelectorAll('.device-card').length;
1487
+ titleEl.innerHTML = 'Devices <span style="color:var(--text3);font-weight:400">(' + count + ')</span>';
790
1488
  }
791
1489
  }
792
1490
 
793
- function buildLogEntryHtml(entry, rowId, type) {
1491
+ function buildLogEntryHtml(entry, rowId, type, index) {
794
1492
  var lt = type || getLogType(entry);
795
1493
  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>';
1494
+ var ts = readTimestamp(entry);
1495
+ var html = '<div class="log-row">';
1496
+ html += '<div class="log-type log-type-' + typeClass + '">' + escapeHtml(labelForType(lt)) + '</div>';
1497
+ html += '<div class="log-summary-col">';
1498
+ html += '<div class="log-summary">' + matchSearch(summarize(entry)) + '</div>';
1499
+ if (ts) {
1500
+ html += '<div class="log-timestamp">' + formatTimeShort(new Date(ts).toISOString()) + '</div>';
1501
+ }
1502
+ html += '</div>';
798
1503
  html += '<div class="log-status">' + statusBadge(entry) + '</div>';
1504
+ html += '<div class="log-copy" onclick="event.stopPropagation();copyEntryJSON(\'' + rowId + '\')"><button class="copy-btn" title="Copy entry JSON">&#9112;</button></div>';
799
1505
  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>';
1506
+ html += '</div>';
1507
+ html += '<div class="log-detail" id="detail-' + rowId + '">';
1508
+ html += '<div class="log-detail-inner"><div class="detail-sections">';
1509
+ html += renderLogDetails(entry, lt);
1510
+ html += '<div class="entry-footer">';
1511
+ html += '<button class="btn btn-sm" onclick="event.stopPropagation();copyEntryJSON(\'' + rowId + '\')">&#9112; Copy JSON</button>';
1512
+ html += '</div>';
1513
+ html += '</div></div></div>';
801
1514
  return html;
802
1515
  }
803
1516
 
@@ -823,6 +1536,7 @@ header h1 span{color:var(--text3);font-weight:400}
823
1536
  entries.forEach(function(e) {
824
1537
  if (failedOnly && !isFailedEntry(e)) return;
825
1538
  if (type && t !== type) return;
1539
+ if (searchTerm && !entryMatchesSearch(e)) return;
826
1540
  allNewEntries.push({ type: t, entry: e });
827
1541
  });
828
1542
  });
@@ -834,24 +1548,26 @@ header h1 span{color:var(--text3);font-weight:400}
834
1548
  var div = document.createElement('div');
835
1549
  div.className = 'log-entry';
836
1550
  div.id = 'entry-' + rowId;
837
- div.setAttribute('onclick', "toggleRow('" + rowId + "')");
838
- div.innerHTML = buildLogEntryHtml(entry, rowId, item.type);
839
- list.appendChild(div);
1551
+ div.setAttribute('data-index', count - 1);
1552
+ div.setAttribute('onclick', '');
1553
+ div.querySelector('.log-row').setAttribute('onclick', "toggleRow('" + rowId + "')");
1554
+ div.innerHTML = buildLogEntryHtml(entry, rowId, item.type, count - 1);
1555
+ list.prepend(div);
840
1556
  });
841
1557
 
842
- // Trim from top if over limit, skip expanded entries
1558
+ // Trim oldest rows from bottom if over limit, skip expanded entries.
843
1559
  var entries = list.querySelectorAll('.log-entry');
844
1560
  while (entries.length > limit) {
845
- var first = entries[0];
846
- if (first && first.classList.contains('expanded')) break;
847
- list.removeChild(first);
1561
+ var last = entries[entries.length - 1];
1562
+ if (last && last.classList.contains('expanded')) break;
1563
+ list.removeChild(last);
848
1564
  entries = list.querySelectorAll('.log-entry');
849
1565
  }
850
1566
  }
851
1567
 
852
1568
  function updateTabCounts() {
853
- if (!currentSession) return;
854
- var logs = currentSession.report ? currentSession.report.logs : {};
1569
+ if (!currentDevice) return;
1570
+ var logs = currentDevice.report ? currentDevice.report.logs : {};
855
1571
  var totalLogs = Object.values(logs).reduce(function(a, v) { return a + (Array.isArray(v) ? v.length : 0); }, 0);
856
1572
 
857
1573
  document.querySelectorAll('.tab').forEach(function(tab) {
@@ -877,7 +1593,7 @@ header h1 span{color:var(--text3);font-weight:400}
877
1593
 
878
1594
  function connectSSE() {
879
1595
  if (eventSource) { try { eventSource.close(); } catch {} }
880
- eventSource = new EventSource(withAuth('/events'));
1596
+ eventSource = new EventSource(withAuth('/events'));
881
1597
 
882
1598
  eventSource.addEventListener('logs', function(e) {
883
1599
  try {
@@ -886,17 +1602,15 @@ header h1 span{color:var(--text3);font-weight:400}
886
1602
  pulseDot.style.background = 'var(--cyan)';
887
1603
  pulseDot.style.boxShadow = '0 0 8px var(--cyan),0 0 20px rgba(0,229,255,.3)';
888
1604
 
889
- // Session list page — append new session card
890
- if (!currentSession) {
891
- appendSessionCard(payload);
1605
+ if (!currentDevice) {
1606
+ appendDeviceCard(payload);
892
1607
  return;
893
1608
  }
894
1609
 
895
- // Detail page merge delta if same session
896
- if (payload.sessionId === currentSession.sessionId) {
1610
+ if (payload.deviceId === currentDevice.deviceId) {
897
1611
  if (payload.type === 'delta' && payload.delta) {
898
1612
  var deltaLogs = payload.delta.logs || {};
899
- var report = currentSession.report || { version: 2, logs: {} };
1613
+ var report = currentDevice.report || { version: 2, logs: {} };
900
1614
  if (!report.logs) report.logs = {};
901
1615
 
902
1616
  Object.entries(deltaLogs).forEach(function(entry) {
@@ -907,15 +1621,15 @@ header h1 span{color:var(--text3);font-weight:400}
907
1621
  report.logs[type] = report.logs[type].concat(entries);
908
1622
  });
909
1623
 
910
- currentSession.report = report;
911
- if (payload.logCount) currentSession.logCount = payload.logCount;
1624
+ currentDevice.report = report;
1625
+ if (payload.logCount) currentDevice.logCount = payload.logCount;
912
1626
  appendDeltaLogs(deltaLogs);
913
1627
  updateTabCounts();
914
1628
  } else if (payload.type === 'full') {
915
- refreshCurrentSession();
1629
+ refreshCurrentDevice();
916
1630
  }
917
- } else if (!location.hash.startsWith('session/')) {
918
- appendSessionCard(payload);
1631
+ } else if (!location.hash.startsWith('device/')) {
1632
+ appendDeviceCard(payload);
919
1633
  }
920
1634
  } catch {}
921
1635
  });
@@ -930,6 +1644,18 @@ header h1 span{color:var(--text3);font-weight:400}
930
1644
  window.addEventListener('hashchange', route);
931
1645
  connectSSE();
932
1646
  route();
1647
+
1648
+ // Fetch LAN IPs from /health and display in header
1649
+ api('/health').then(function(data) {
1650
+ var ips = data && data.ips;
1651
+ if (Array.isArray(ips) && ips.length) {
1652
+ var ipEl = document.getElementById('ipHint');
1653
+ if (ipEl) {
1654
+ ipEl.textContent = 'LAN ' + ips.join(' / ');
1655
+ ipEl.style.display = '';
1656
+ }
1657
+ }
1658
+ }).catch(function() {});
933
1659
  })();
934
1660
  </script>
935
1661
  </body>