mobygate 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/index.html ADDED
@@ -0,0 +1,805 @@
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="utf-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1">
6
+ <title>mobygate</title>
7
+ <link rel="preconnect" href="https://fonts.googleapis.com">
8
+ <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
9
+ <link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;700&family=VT323&display=swap" rel="stylesheet">
10
+ <script src="https://cdn.tailwindcss.com"></script>
11
+ <script>
12
+ /* Design tokens transcribed from the Paper artboard C1-0.
13
+ Using arbitrary Tailwind values for exact fidelity with the design. */
14
+ tailwind.config = {
15
+ theme: {
16
+ extend: {
17
+ fontFamily: {
18
+ display: ['VT323', 'system-ui', 'monospace'],
19
+ mono: ['"JetBrains Mono"', 'ui-monospace', 'SFMono-Regular', 'Menlo', 'monospace'],
20
+ },
21
+ },
22
+ },
23
+ };
24
+ </script>
25
+ <style>
26
+ html, body { background: #0B0B09; color: #F3EFE4; font-family: 'JetBrains Mono', ui-monospace, Menlo, monospace; }
27
+ .whale-ascii {
28
+ color: #B7E56D;
29
+ font-family: 'JetBrains Mono', ui-monospace, monospace;
30
+ white-space: pre;
31
+ font-size: 8px;
32
+ line-height: 10px;
33
+ letter-spacing: 0;
34
+ margin: 0;
35
+ }
36
+ .display { font-family: 'VT323', monospace; letter-spacing: 0.02em; }
37
+ .pulse-dot { animation: pulse 1.8s cubic-bezier(0.4, 0, 0.6, 1) infinite; }
38
+ @keyframes pulse { 0%,100%{opacity:1} 50%{opacity:.5} }
39
+ .row-enter { animation: rowIn .2s ease-out; }
40
+ @keyframes rowIn { from{opacity:0;transform:translateY(-3px)} to{opacity:1;transform:translateY(0)} }
41
+ .req-row:hover { background: rgba(255,255,255,0.02); cursor: pointer; }
42
+ /* Scrollbar styling to match */
43
+ ::-webkit-scrollbar { width: 8px; height: 8px; }
44
+ ::-webkit-scrollbar-track { background: #0B0B09; }
45
+ ::-webkit-scrollbar-thumb { background: #2A2A1F; border-radius: 0; }
46
+ ::-webkit-scrollbar-thumb:hover { background: #5A5F54; }
47
+ </style>
48
+ </head>
49
+ <body class="antialiased">
50
+ <div class="mx-auto px-12 pt-8 pb-7 flex flex-col gap-6 max-w-[1440px] min-h-screen">
51
+
52
+ <!-- ===== Header ===== -->
53
+ <header class="flex justify-between items-center shrink-0">
54
+ <div class="flex items-center gap-[22px]">
55
+ <pre class="whale-ascii"> ## .
56
+ ## ## ## ==
57
+ ## ## ## ## ===
58
+ /"""""""""""\__/ ==
59
+ \______ o __/
60
+ \______\___,/ </pre>
61
+ <div class="flex flex-col gap-1">
62
+ <div class="flex items-baseline gap-2.5">
63
+ <div class="display text-[#B7E56D] text-4xl leading-8 tracking-[0.06em]">mobygate</div>
64
+ <div class="uppercase text-[#5A5F54] font-medium text-[11px] leading-[14px] tracking-[0.14em]" id="h-version">v0.1.0</div>
65
+ </div>
66
+ <div class="text-[#8A9A6A] text-xs leading-4">
67
+ OpenAI → Claude Max · local gateway · <span id="h-tty">tty0</span>
68
+ </div>
69
+ </div>
70
+ </div>
71
+ <div class="flex items-center gap-2.5">
72
+ <!-- Health pill -->
73
+ <div id="healthPill" class="flex items-center rounded-full py-2 px-3.5 gap-2 bg-[#121210] border border-[#2A2A1F]">
74
+ <span class="pulse-dot rounded-full bg-[#5A5F54] w-2 h-2"></span>
75
+ <span class="pulse-dot opacity-50 -ml-3 rounded-full bg-[#5A5F54] w-2 h-2"></span>
76
+ <span id="healthText" class="text-[#5A5F54] font-medium text-xs tracking-[0.08em]">connecting</span>
77
+ <span id="healthStream" class="text-[#5A5F54] text-[11px] leading-[14px]">· …</span>
78
+ </div>
79
+ <button id="clearLogBtn" class="rounded-full py-2 px-3.5 border border-[#2A2A1F] text-[#C9D9A8] font-medium text-xs tracking-[0.04em] hover:border-[#5A5F54] transition">clear log</button>
80
+ <button id="authRefreshBtn" class="flex items-center rounded-full py-2 px-3.5 gap-1.5 bg-[#E89B2E] hover:brightness-110 transition">
81
+ <span class="rounded-full bg-[#0B0B09] w-1.5 h-1.5"></span>
82
+ <span class="text-[#0B0B09] font-bold text-xs tracking-[0.04em]">force refresh auth</span>
83
+ </button>
84
+ </div>
85
+ </header>
86
+
87
+ <!-- ===== KPI strip ===== -->
88
+ <section class="grid grid-cols-4 gap-4 shrink-0">
89
+
90
+ <!-- Uptime -->
91
+ <div class="flex flex-col py-[22px] px-6 gap-3 bg-[#121210] border-t border-b border-r border-[#1A1A15] border-l-2 border-l-[#B7E56D]">
92
+ <div class="flex justify-between items-center">
93
+ <div class="uppercase text-[#8A9A6A] font-medium text-[10px] leading-3 tracking-[0.22em]">Uptime</div>
94
+ <div id="k-uptimeSince" class="text-[#5A5F54] text-[10px] leading-3">—</div>
95
+ </div>
96
+ <div class="flex items-baseline gap-2.5">
97
+ <div class="display text-[#F3EFE4] text-[64px] leading-[56px]" id="k-uptime">0:00</div>
98
+ <div class="text-[#8A9A6A] text-xs leading-4" id="k-uptimeUnit">m:ss</div>
99
+ </div>
100
+ <div class="flex items-center gap-1">
101
+ <span class="rounded-full bg-[#B7E56D] w-1.5 h-1.5"></span>
102
+ <span class="text-[#B7E56D] text-[11px] leading-[14px]" id="k-uptimeStatus">no restarts · port 3456</span>
103
+ </div>
104
+ </div>
105
+
106
+ <!-- Requests -->
107
+ <div class="flex flex-col py-[22px] px-6 gap-3 bg-[#121210] border-t border-b border-r border-[#1A1A15] border-l-2 border-l-[#E89B2E]">
108
+ <div class="flex justify-between items-center">
109
+ <div class="uppercase text-[#8A9A6A] font-medium text-[10px] leading-3 tracking-[0.22em]">Requests</div>
110
+ <div class="text-[#5A5F54] text-[10px] leading-3">since boot</div>
111
+ </div>
112
+ <div class="flex items-baseline gap-2.5">
113
+ <div class="display text-[#F3EFE4] text-[64px] leading-[56px]" id="k-total">0</div>
114
+ <div class="text-[#8A9A6A] text-xs leading-4" id="k-totalSub">total</div>
115
+ </div>
116
+ <div class="flex items-center gap-2.5 text-[11px] leading-[14px]">
117
+ <span class="flex items-center gap-1 text-[#C9D9A8]">
118
+ <span class="bg-[#4EA4C4] w-1.5 h-1.5"></span>
119
+ <span>stream <span id="k-stream">0</span></span>
120
+ </span>
121
+ <span class="flex items-center gap-1 text-[#C9D9A8]">
122
+ <span class="bg-[#E89B2E] w-1.5 h-1.5"></span>
123
+ <span>tools <span id="k-tools">0</span></span>
124
+ </span>
125
+ <span class="flex items-center gap-1 text-[#C9D9A8]">
126
+ <span class="bg-[#5A5F54] w-1.5 h-1.5"></span>
127
+ <span>img <span id="k-images">0</span></span>
128
+ </span>
129
+ </div>
130
+ </div>
131
+
132
+ <!-- Success rate -->
133
+ <div class="flex flex-col py-[22px] px-6 gap-3 bg-[#121210] border-t border-b border-r border-[#1A1A15] border-l-2 border-l-[#B7E56D]">
134
+ <div class="flex justify-between items-center">
135
+ <div class="uppercase text-[#8A9A6A] font-medium text-[10px] leading-3 tracking-[0.22em]">Success rate</div>
136
+ <div id="k-successMeta" class="text-[#5A5F54] text-[10px] leading-3">0 of 0</div>
137
+ </div>
138
+ <div class="flex items-baseline gap-2.5">
139
+ <div class="display text-[#B7E56D] text-[64px] leading-[56px]" id="k-successPct">—</div>
140
+ </div>
141
+ <div id="k-successBar" class="flex items-center h-1.5 gap-0.5 shrink-0">
142
+ <!-- filled dynamically -->
143
+ </div>
144
+ <div class="text-[#8A9A6A] text-[11px] leading-[14px]" id="k-successSub">no traffic yet</div>
145
+ </div>
146
+
147
+ <!-- Avg latency -->
148
+ <div class="flex flex-col py-[22px] px-6 gap-3 bg-[#121210] border-t border-b border-r border-[#1A1A15] border-l-2 border-l-[#4EA4C4]">
149
+ <div class="flex justify-between items-center">
150
+ <div class="uppercase text-[#8A9A6A] font-medium text-[10px] leading-3 tracking-[0.22em]">Avg latency</div>
151
+ <div id="k-latMeta" class="text-[#5A5F54] text-[10px] leading-3">p50 / p95</div>
152
+ </div>
153
+ <div class="flex items-baseline gap-2.5">
154
+ <div class="display text-[#F3EFE4] text-[64px] leading-[56px]" id="k-latP50">—</div>
155
+ <div class="text-[#4EA4C4] text-xs leading-4" id="k-latP95">p95 —</div>
156
+ </div>
157
+ <div id="k-latSpark" class="flex items-end h-[22px] gap-[3px] shrink-0">
158
+ <!-- sparkline filled dynamically -->
159
+ </div>
160
+ </div>
161
+ </section>
162
+
163
+ <!-- ===== Info row: Server | Auth | Traffic ===== -->
164
+ <section class="grid grid-cols-3 gap-4 shrink-0">
165
+
166
+ <!-- Server -->
167
+ <div class="flex flex-col py-[22px] px-6 gap-4 bg-[#121210] border border-[#1A1A15]">
168
+ <div class="flex justify-between items-center">
169
+ <div class="flex items-center gap-2">
170
+ <span class="bg-[#B7E56D] w-1.5 h-1.5"></span>
171
+ <span class="uppercase text-[#C9D9A8] font-medium text-[11px] leading-[14px] tracking-[0.22em]">Server</span>
172
+ </div>
173
+ <div class="text-[#5A5F54] text-[10px] leading-3" id="s-addr">127.0.0.1:3456</div>
174
+ </div>
175
+ <div class="flex flex-col gap-2.5">
176
+ <div class="flex justify-between items-baseline pb-2 border-b border-[#2A2A1F]">
177
+ <div class="text-[#8A9A6A] text-xs leading-4">default model</div>
178
+ <div class="text-[#F3EFE4] text-xs leading-4" id="s-model">—</div>
179
+ </div>
180
+ <div class="flex justify-between items-baseline pb-2 border-b border-[#2A2A1F]">
181
+ <div class="text-[#8A9A6A] text-xs leading-4">active sessions</div>
182
+ <div class="flex items-baseline gap-2">
183
+ <div class="text-[#F3EFE4] text-xs leading-4" id="s-sessions">0</div>
184
+ <div class="text-[#5A5F54] text-[10px] leading-3" id="s-sessionsSub">· idle</div>
185
+ </div>
186
+ </div>
187
+ <div class="flex justify-between items-baseline pb-2 border-b border-[#2A2A1F]">
188
+ <div class="text-[#8A9A6A] text-xs leading-4">context window</div>
189
+ <div class="text-[#F3EFE4] text-xs leading-4" id="s-ctx">—</div>
190
+ </div>
191
+ <div class="flex justify-between items-baseline">
192
+ <div class="text-[#8A9A6A] text-xs leading-4">build</div>
193
+ <div class="text-[#F3EFE4] text-xs leading-4" id="s-build">—</div>
194
+ </div>
195
+ </div>
196
+ </div>
197
+
198
+ <!-- Auth -->
199
+ <div class="flex flex-col py-[22px] px-6 gap-4 bg-[#121210] border border-[#1A1A15]">
200
+ <div class="flex justify-between items-center">
201
+ <div class="flex items-center gap-2">
202
+ <span class="bg-[#B7E56D] w-1.5 h-1.5"></span>
203
+ <span class="uppercase text-[#C9D9A8] font-medium text-[11px] leading-[14px] tracking-[0.22em]">Auth</span>
204
+ </div>
205
+ <div id="a-badge" class="py-0.5 px-2 bg-[#B7E56D1A] border border-[#B7E56D]">
206
+ <span class="uppercase text-[#B7E56D] font-bold text-[10px] leading-3 tracking-[0.12em]">checking</span>
207
+ </div>
208
+ </div>
209
+ <div class="flex flex-col gap-2.5">
210
+ <div class="flex justify-between items-baseline pb-2 border-b border-[#2A2A1F]">
211
+ <div class="text-[#8A9A6A] text-xs leading-4">email</div>
212
+ <div class="text-[#F3EFE4] text-xs leading-4" id="a-email">—</div>
213
+ </div>
214
+ <div class="flex justify-between items-baseline pb-2 border-b border-[#2A2A1F]">
215
+ <div class="text-[#8A9A6A] text-xs leading-4">plan</div>
216
+ <div class="flex items-center gap-1.5">
217
+ <div class="py-0.5 px-1.5 bg-[#E89B2E]" id="a-planBadge">
218
+ <span class="uppercase text-[#0B0B09] font-bold text-[10px] leading-3 tracking-widest" id="a-plan">—</span>
219
+ </div>
220
+ <div class="text-[#F3EFE4] text-xs leading-4" id="a-method">—</div>
221
+ </div>
222
+ </div>
223
+ <div class="flex justify-between items-baseline pb-2 border-b border-[#2A2A1F]">
224
+ <div class="text-[#8A9A6A] text-xs leading-4">last probe</div>
225
+ <div class="text-[#5A5F54] text-xs leading-4" id="a-probe">not probed ← try force refresh</div>
226
+ </div>
227
+ <div class="flex justify-between items-baseline">
228
+ <div class="text-[#8A9A6A] text-xs leading-4">token refreshes</div>
229
+ <div class="text-[#F3EFE4] text-xs leading-4" id="a-refreshes">0 · healthy</div>
230
+ </div>
231
+ </div>
232
+ </div>
233
+
234
+ <!-- Traffic -->
235
+ <div class="flex flex-col py-[22px] px-6 gap-4 bg-[#121210] border border-[#1A1A15]">
236
+ <div class="flex justify-between items-center">
237
+ <div class="flex items-center gap-2">
238
+ <span class="bg-[#4EA4C4] w-1.5 h-1.5"></span>
239
+ <span class="uppercase text-[#C9D9A8] font-medium text-[11px] leading-[14px] tracking-[0.22em]">Traffic</span>
240
+ </div>
241
+ <div class="text-[#5A5F54] text-[10px] leading-3">last 15 min · req/min</div>
242
+ </div>
243
+ <div class="flex items-end h-[110px] gap-1 shrink-0">
244
+ <div id="t-axis" class="flex flex-col items-end h-full justify-between pr-1 gap-2">
245
+ <!-- y-axis labels injected -->
246
+ </div>
247
+ <div id="t-bars" class="flex items-end grow h-full gap-[5px]">
248
+ <!-- traffic bars injected -->
249
+ </div>
250
+ </div>
251
+ <div class="flex justify-between items-baseline">
252
+ <div class="text-[#5A5F54] text-[10px] leading-3">-15m</div>
253
+ <div class="text-[#B7E56D] text-[10px] leading-3">now</div>
254
+ </div>
255
+ </div>
256
+ </section>
257
+
258
+ <!-- ===== Live requests ===== -->
259
+ <section class="flex flex-col shrink-0 bg-[#121210] border border-[#1A1A15]">
260
+ <div class="flex items-center justify-between py-[18px] px-6 border-b border-[#2A2A1F]">
261
+ <div class="flex items-center gap-2.5">
262
+ <span class="rounded-full bg-[#B7E56D] w-1.5 h-1.5"></span>
263
+ <span class="uppercase text-[#C9D9A8] font-medium text-[11px] leading-[14px] tracking-[0.22em]">Live requests</span>
264
+ <span class="text-[#5A5F54] text-[11px] leading-[14px]" id="r-meta">· idle</span>
265
+ </div>
266
+ <div class="flex items-center gap-2" id="r-filters">
267
+ <button data-filter="all" class="py-1 px-2.5 border border-[#2A2A1F] uppercase text-[10px] leading-3 tracking-[0.12em] text-[#C9D9A8]">all</button>
268
+ <button data-filter="errors" class="py-1 px-2.5 border border-[#2A2A1F] uppercase text-[10px] leading-3 tracking-[0.12em] text-[#5A5F54] hover:text-[#C9D9A8]">errors</button>
269
+ <button data-filter="slow" class="py-1 px-2.5 border border-[#2A2A1F] uppercase text-[10px] leading-3 tracking-[0.12em] text-[#5A5F54] hover:text-[#C9D9A8]">slow (&gt;15s)</button>
270
+ </div>
271
+ </div>
272
+ <!-- Column header -->
273
+ <div class="flex items-center py-2.5 px-6 gap-4 border-b border-[#2A2A1F] uppercase text-[10px] leading-3 tracking-[0.18em] text-[#5A5F54]">
274
+ <div class="w-[72px] shrink-0">time</div>
275
+ <div class="w-[100px] shrink-0">kind</div>
276
+ <div class="w-[180px] shrink-0">model</div>
277
+ <div class="w-[110px] shrink-0">session</div>
278
+ <div class="grow">latency</div>
279
+ <div class="w-[100px] text-right shrink-0">tokens in/out</div>
280
+ <div class="w-[70px] text-right shrink-0">status</div>
281
+ </div>
282
+ <!-- Rows container -->
283
+ <div id="r-rows" class="max-h-[52vh] overflow-auto">
284
+ <div class="py-10 text-center text-[#5A5F54] text-xs" id="r-empty">
285
+ no requests yet — make one from Hermes/OpenClaw and it'll appear here live
286
+ </div>
287
+ </div>
288
+ </section>
289
+
290
+ <!-- ===== Sessions panel ===== -->
291
+ <section class="flex flex-col shrink-0 bg-[#121210] border border-[#1A1A15]">
292
+ <div class="flex items-center justify-between py-[18px] px-6 border-b border-[#2A2A1F]">
293
+ <div class="flex items-center gap-2.5">
294
+ <span class="bg-[#E89B2E] w-1.5 h-1.5"></span>
295
+ <span class="uppercase text-[#C9D9A8] font-medium text-[11px] leading-[14px] tracking-[0.22em]">Sessions</span>
296
+ <span class="text-[#5A5F54] text-[11px] leading-[14px]">· <span id="sess-count">0</span> active</span>
297
+ </div>
298
+ <div class="flex items-center gap-2 text-[11px] leading-[14px]">
299
+ <button id="sessionsRefreshBtn" class="text-[#5A5F54] hover:text-[#C9D9A8]">refresh</button>
300
+ <button id="sessionsClearBtn" class="text-[#E89B2E] hover:brightness-110">expire all</button>
301
+ </div>
302
+ </div>
303
+ <div id="sess-rows" class="max-h-[240px] overflow-auto">
304
+ <div class="py-6 text-center text-[#5A5F54] text-xs" id="sess-empty">no active sessions</div>
305
+ </div>
306
+ </section>
307
+
308
+ <!-- ===== Log tail ===== -->
309
+ <section class="flex flex-col shrink-0 bg-[#121210] border border-[#1A1A15]">
310
+ <div class="flex items-center justify-between py-[18px] px-6 border-b border-[#2A2A1F]">
311
+ <div class="flex items-center gap-2.5">
312
+ <span class="bg-[#4EA4C4] w-1.5 h-1.5"></span>
313
+ <span class="uppercase text-[#C9D9A8] font-medium text-[11px] leading-[14px] tracking-[0.22em]">Server log</span>
314
+ <span class="text-[#5A5F54] text-[11px] leading-[14px]" id="logs-meta">—</span>
315
+ </div>
316
+ <div class="flex items-center gap-3 text-[11px] leading-[14px]">
317
+ <label class="flex items-center gap-1.5 cursor-pointer text-[#5A5F54]">
318
+ <input type="checkbox" id="logsAutoRefresh" checked class="accent-[#4EA4C4]">
319
+ <span>auto-refresh</span>
320
+ </label>
321
+ <button id="logsRefreshBtn" class="text-[#5A5F54] hover:text-[#C9D9A8]">refresh</button>
322
+ </div>
323
+ </div>
324
+ <pre id="logs-body" class="text-[11px] leading-[18px] text-[#C9D9A8] overflow-auto max-h-[40vh] p-4 whitespace-pre-wrap"></pre>
325
+ </section>
326
+
327
+ <!-- ===== Footer / status line ===== -->
328
+ <footer class="flex justify-between items-center shrink-0 mt-auto pt-2">
329
+ <div class="flex items-center gap-1.5">
330
+ <span class="uppercase text-[#5A5F54] text-[10px] leading-3 tracking-[0.12em]">endpoints</span>
331
+ <span class="text-[#5A5F54] text-[11px] leading-[14px]">/</span>
332
+ <a href="/health" class="py-[3px] px-2.5 border border-[#2A2A1F] text-[#C9D9A8] text-[11px] leading-[14px] hover:border-[#5A5F54]">/health</a>
333
+ <a href="/auth/status?quick=1" class="py-[3px] px-2.5 border border-[#2A2A1F] text-[#C9D9A8] text-[11px] leading-[14px] hover:border-[#5A5F54]">/auth/status</a>
334
+ <a href="/v1/models" class="py-[3px] px-2.5 border border-[#2A2A1F] text-[#C9D9A8] text-[11px] leading-[14px] hover:border-[#5A5F54]">/v1/models</a>
335
+ <a href="/events" class="py-[3px] px-2.5 border border-[#2A2A1F] text-[#C9D9A8] text-[11px] leading-[14px] hover:border-[#5A5F54]">/events</a>
336
+ </div>
337
+ <div class="flex items-center gap-3">
338
+ <span class="uppercase text-[#5A5F54] text-[10px] leading-3 tracking-[0.12em]" id="f-stream">stream · connecting</span>
339
+ <span class="w-px h-3 bg-[#2A2A1F]"></span>
340
+ <span class="text-[#B7E56D] text-[11px] leading-[14px]">mobygate</span>
341
+ <span class="text-[#5A5F54] text-[11px] leading-[14px]">· <span id="f-tty">tty0</span> · <span id="f-ver">0.1.0</span></span>
342
+ </div>
343
+ </footer>
344
+ </div>
345
+
346
+ <!-- ===== Details modal ===== -->
347
+ <div id="detailsModal" class="hidden fixed inset-0 bg-black/70 z-50 p-6 flex items-start justify-center overflow-auto">
348
+ <div class="bg-[#121210] border border-[#2A2A1F] max-w-3xl w-full mt-10">
349
+ <div class="flex items-center justify-between px-4 py-3 border-b border-[#2A2A1F]">
350
+ <div class="uppercase text-[#C9D9A8] font-medium text-[11px] tracking-[0.22em]">Request details</div>
351
+ <button id="detailsCloseBtn" class="text-[#5A5F54] hover:text-[#C9D9A8]">close ✕</button>
352
+ </div>
353
+ <pre id="detailsBody" class="text-[11px] leading-[16px] text-[#C9D9A8] p-4 overflow-auto max-h-[70vh] whitespace-pre-wrap"></pre>
354
+ </div>
355
+ </div>
356
+
357
+ <script type="module">
358
+ // ───────────────────────── helpers
359
+ const $ = (id) => document.getElementById(id);
360
+ const fmt = {
361
+ time(ts) { return new Date(ts).toLocaleTimeString([], { hour12: false }); },
362
+ ms(n) { return n == null ? '—' : `${n}`; },
363
+ ctx(n) { if (!n) return '—'; return n >= 1000 ? `${Math.floor(n / 1000)}k` : String(n); },
364
+ secs(ms) {
365
+ if (ms == null || !Number.isFinite(ms)) return '—';
366
+ if (ms < 1000) return `${ms}ms`;
367
+ return `${(ms / 1000).toFixed(1)}s`;
368
+ },
369
+ uptime(sec) {
370
+ if (sec == null) return '0:00';
371
+ const d = Math.floor(sec / 86400), h = Math.floor((sec % 86400) / 3600);
372
+ const m = Math.floor((sec % 3600) / 60), s = sec % 60;
373
+ if (d) return `${d}d ${String(h).padStart(2,'0')}:${String(m).padStart(2,'0')}`;
374
+ if (h) return `${h}:${String(m).padStart(2,'0')}:${String(s).padStart(2,'0')}`;
375
+ return `${m}:${String(s).padStart(2,'0')}`;
376
+ },
377
+ uptimeSince(iso) {
378
+ if (!iso) return '—';
379
+ const d = new Date(iso);
380
+ const mon = d.toLocaleString('en-US', { month: 'short' });
381
+ const day = d.getDate();
382
+ const hh = String(d.getHours()).padStart(2,'0');
383
+ const mm = String(d.getMinutes()).padStart(2,'0');
384
+ return `since ${day} ${mon} ${hh}:${mm}`;
385
+ },
386
+ short(s, n=12) { return !s ? '—' : (s.length > n ? s.slice(0, n) + '…' : s); },
387
+ modelBase(model) {
388
+ // "claude-opus-4-6" → "opus"; "claude-opus-4-7[1m]" → "opus"
389
+ const m = /claude-(opus|sonnet|haiku)/i.exec(model || '');
390
+ return m ? m[1] : 'model';
391
+ },
392
+ modelCtx(resolved) {
393
+ // Guess context window from resolved model name
394
+ if (!resolved) return '200k ctx';
395
+ if (resolved.includes('[1m]')) return '1M ctx';
396
+ return '200k ctx';
397
+ },
398
+ };
399
+
400
+ // ───────────────────────── state
401
+ const inflight = new Map();
402
+ const completed = new Map(); // id → { startEv, endEv }
403
+ const allRows = []; // DOM row data: { id, startEv, endEv?, el }
404
+ const MAX_ROWS = 200;
405
+ let currentFilter = 'all';
406
+ let stats = null;
407
+ let serverPort = 3456;
408
+
409
+ // ───────────────────────── KPI rendering
410
+ function renderStats(s, boot) {
411
+ stats = s;
412
+ // Uptime
413
+ $('k-uptime').textContent = fmt.uptime(s.uptimeSec);
414
+ $('k-uptimeUnit').textContent = s.uptimeSec >= 3600 ? 'h:mm:ss' : 'm:ss';
415
+ $('k-uptimeSince').textContent = fmt.uptimeSince(s.startedAt);
416
+ $('k-uptimeStatus').textContent = `no restarts · port ${serverPort}`;
417
+ // Requests
418
+ $('k-total').textContent = s.totalRequests;
419
+ $('k-stream').textContent = s.streamingRequests;
420
+ $('k-tools').textContent = s.toolRequests;
421
+ $('k-images').textContent = s.multimodalRequests;
422
+ // Success rate
423
+ const total = s.totalRequests || 0;
424
+ if (total === 0) {
425
+ $('k-successPct').textContent = '—';
426
+ $('k-successPct').classList.remove('text-[#B7E56D]','text-[#E89B2E]','text-[#F3EFE4]');
427
+ $('k-successPct').classList.add('text-[#5A5F54]');
428
+ $('k-successMeta').textContent = '0 of 0';
429
+ $('k-successSub').textContent = 'no traffic yet';
430
+ $('k-successBar').innerHTML = Array(7).fill('<div class="grow h-full bg-[#1A1A15]"></div>').join('');
431
+ } else {
432
+ const pct = Math.round((s.succeeded / total) * 100);
433
+ $('k-successPct').textContent = pct + '%';
434
+ $('k-successPct').classList.remove('text-[#5A5F54]');
435
+ $('k-successPct').classList.add(pct === 100 ? 'text-[#B7E56D]' : (pct >= 80 ? 'text-[#F3EFE4]' : 'text-[#E89B2E]'));
436
+ $('k-successMeta').textContent = `${s.succeeded} of ${total}`;
437
+ $('k-successSub').textContent = s.failed === 0 ? 'zero failures · no 5xx observed' : `${s.failed} failed · check logs`;
438
+ // 7-bar progress
439
+ const filled = Math.round((pct / 100) * 7);
440
+ const bar = Array.from({length: 7}, (_, i) =>
441
+ `<div class="grow h-full ${i < filled ? 'bg-[#B7E56D]' : 'bg-[#1A1A15]'}"></div>`
442
+ ).join('');
443
+ $('k-successBar').innerHTML = bar;
444
+ }
445
+ // Latency (combined stream + sync for p50/p95; show split via sub)
446
+ const lat = s.latency || { stream: {}, sync: {} };
447
+ const combined = [...(lat.stream.recent || []), ...(lat.sync.recent || [])];
448
+ if (!combined.length) {
449
+ $('k-latP50').textContent = '—';
450
+ $('k-latP95').textContent = 'no data';
451
+ $('k-latMeta').textContent = 'p50 / p95';
452
+ } else {
453
+ const sorted = [...combined].sort((a,b)=>a-b);
454
+ const p50 = sorted[Math.min(sorted.length-1, Math.floor(0.5*sorted.length))];
455
+ const p95 = sorted[Math.min(sorted.length-1, Math.floor(0.95*sorted.length))];
456
+ $('k-latP50').textContent = fmt.secs(p50);
457
+ $('k-latP95').textContent = `p95 ${fmt.secs(p95)}`;
458
+ $('k-latMeta').textContent = `n=${combined.length}`;
459
+ renderLatencySparkline(combined);
460
+ }
461
+ // Traffic chart
462
+ renderTrafficChart(s.traffic || []);
463
+ }
464
+
465
+ function renderLatencySparkline(values) {
466
+ const max = Math.max(...values, 1);
467
+ const bars = values.slice(-14).map((v) => {
468
+ const pct = Math.max(4, Math.round((v / max) * 100));
469
+ return `<div class="w-[6px] bg-[#4EA4C4]" style="height:${pct}%"></div>`;
470
+ }).join('');
471
+ $('k-latSpark').innerHTML = bars || '';
472
+ }
473
+
474
+ function renderTrafficChart(series) {
475
+ const maxCount = Math.max(1, ...series.map(b => b.count));
476
+ // y-axis: max, mid, zero
477
+ const axis = [maxCount, Math.ceil(maxCount / 2), 0].map(n =>
478
+ `<div class="text-[#5A5F54] text-[9px] leading-3">${n}</div>`
479
+ ).join('');
480
+ $('t-axis').innerHTML = axis;
481
+ // bars
482
+ const bars = series.map(b => {
483
+ const pct = b.count > 0 ? Math.max(8, Math.round((b.count / maxCount) * 100)) : 0;
484
+ const cls = b.count > 0 ? 'bg-[#4EA4C4]' : 'bg-[#1A1A15]';
485
+ const hstyle = b.count > 0 ? `height:${pct}%` : 'height:2px';
486
+ return `<div class="grow shrink basis-0 ${cls}" style="${hstyle}" title="${b.count} req · ${b.minute}"></div>`;
487
+ }).join('');
488
+ $('t-bars').innerHTML = bars;
489
+ }
490
+
491
+ function renderServerCard({ port, defaultModel, activeSessions, build }) {
492
+ serverPort = port;
493
+ $('s-addr').textContent = `127.0.0.1:${port}`;
494
+ $('s-model').textContent = defaultModel || '—';
495
+ $('s-sessions').textContent = activeSessions;
496
+ $('s-sessionsSub').textContent = activeSessions > 0 ? '· active' : '· idle';
497
+ if (build) {
498
+ $('s-ctx').textContent = build.contextWindow ? `${Number(build.contextWindow).toLocaleString()} tok` : '—';
499
+ $('s-build').textContent = `v${build.version} · ${build.platform}`;
500
+ $('h-version').textContent = `v${build.version}`;
501
+ $('f-ver').textContent = build.version;
502
+ }
503
+ }
504
+
505
+ // ───────────────────────── Live requests
506
+ function statusPill(endEv) {
507
+ if (!endEv) return '<span class="text-[#5A5F54] text-[11px]">running…</span>';
508
+ const code = endEv.httpStatus || 200;
509
+ const ok = endEv.status === 'ok';
510
+ const color = ok ? '#B7E56D' : '#E89B2E';
511
+ const bg = ok ? '#B7E56D1F' : '#E89B2E26';
512
+ return `<div class="py-0.5 px-2 inline-block" style="background:${bg}"><span class="font-bold text-[10px] leading-3 tracking-widest" style="color:${color}">${code}</span></div>`;
513
+ }
514
+
515
+ function latencyBar(endEv) {
516
+ if (!endEv) return `
517
+ <div class="h-1.5 relative flex bg-[#1A1A15]"><div class="w-[20%] h-full bg-[#5A5F54] animate-pulse"></div></div>
518
+ <div class="text-[#5A5F54] text-[10px] leading-3 mt-1">running…</div>`;
519
+ const ms = endEv.durationMs || 0;
520
+ // Bar scales against 30s reference; >15s = slow (orange), otherwise blue, or green for <3s
521
+ const pct = Math.min(100, Math.max(5, (ms / 30000) * 100));
522
+ const slow = ms > 15000;
523
+ const color = slow ? '#E89B2E' : (ms < 3000 ? '#B7E56D' : '#4EA4C4');
524
+ const label = slow ? `${fmt.secs(ms)} · slow` : `${fmt.secs(ms)} · ok`;
525
+ return `
526
+ <div class="h-1.5 relative flex bg-[#1A1A15]"><div class="h-full" style="width:${pct.toFixed(1)}%;background:${color}"></div></div>
527
+ <div class="text-[10px] leading-3 mt-1" style="color:${color}">${label}</div>`;
528
+ }
529
+
530
+ function kindChips(ev) {
531
+ const chips = [];
532
+ if (ev.stream) chips.push(`<div class="py-0.5 px-1.5 border" style="background:#4EA4C426;border-color:#4EA4C4"><span class="uppercase text-[9px] leading-3 tracking-[0.08em]" style="color:#4EA4C4">stream</span></div>`);
533
+ if (ev.tools) chips.push(`<div class="py-0.5 px-1.5 border" style="background:#E89B2E26;border-color:#E89B2E"><span class="uppercase text-[9px] leading-3 tracking-[0.08em]" style="color:#E89B2E">tool</span></div>`);
534
+ if (ev.images) chips.push(`<div class="py-0.5 px-1.5 border" style="background:#5A5F5426;border-color:#5A5F54"><span class="uppercase text-[9px] leading-3 tracking-[0.08em]" style="color:#8A9A6A">img</span></div>`);
535
+ if (!chips.length) chips.push(`<div class="py-0.5 px-1.5 border" style="background:#8A9A6A1F;border-color:#2A2A1F"><span class="uppercase text-[9px] leading-3 tracking-[0.08em] text-[#8A9A6A]">sync</span></div>`);
536
+ return chips.join('');
537
+ }
538
+
539
+ function requestMatchesFilter(row) {
540
+ if (currentFilter === 'all') return true;
541
+ if (!row.endEv) return currentFilter !== 'errors'; // still running
542
+ if (currentFilter === 'errors') return row.endEv.status !== 'ok';
543
+ if (currentFilter === 'slow') return (row.endEv.durationMs || 0) > 15000;
544
+ return true;
545
+ }
546
+
547
+ function rebuildRowEl(row) {
548
+ const { startEv, endEv, id } = row;
549
+ row.el.id = `req-${id}`;
550
+ row.el.className = 'req-row flex items-center py-[14px] px-6 gap-4 border-b border-[#1A1A15] row-enter';
551
+ row.el.onclick = () => openDetails(id);
552
+ row.el.innerHTML = `
553
+ <div class="w-[72px] shrink-0 text-[#C9D9A8] text-xs leading-4">${fmt.time(startEv.ts)}</div>
554
+ <div class="w-[100px] flex shrink-0 gap-1">${kindChips(startEv)}</div>
555
+ <div class="w-[180px] flex flex-col shrink-0 gap-0.5">
556
+ <div class="text-[#F3EFE4] text-xs leading-4 truncate">${startEv.model || '—'}</div>
557
+ <div class="text-[#5A5F54] text-[10px] leading-3">${fmt.modelBase(startEv.model)} · ${fmt.modelCtx(startEv.resolvedModel)}</div>
558
+ </div>
559
+ <div class="w-[110px] shrink-0 text-[#8A9A6A] text-xs leading-4 truncate" title="${startEv.session || ''}">${startEv.session ? fmt.short(startEv.session) : '—'}</div>
560
+ <div class="grow flex flex-col gap-1">${latencyBar(endEv)}</div>
561
+ <div class="w-[100px] text-right shrink-0 text-[#8A9A6A] text-[11px] leading-[14px]">${endEv && (endEv.inputTokens || endEv.outputTokens) ? `${endEv.inputTokens || 0}/${endEv.outputTokens || 0}` : '—'}</div>
562
+ <div class="w-[70px] flex justify-end shrink-0">${statusPill(endEv)}</div>
563
+ `;
564
+ row.el.style.display = requestMatchesFilter(row) ? '' : 'none';
565
+ }
566
+
567
+ function upsertRequestRow(startEv, endEv) {
568
+ let row = allRows.find(r => r.id === startEv.id);
569
+ if (!row) {
570
+ const el = document.createElement('div');
571
+ row = { id: startEv.id, startEv, endEv, el };
572
+ allRows.unshift(row);
573
+ const container = $('r-rows');
574
+ $('r-empty').style.display = 'none';
575
+ container.prepend(el);
576
+ while (allRows.length > MAX_ROWS) {
577
+ const doomed = allRows.pop();
578
+ doomed.el.remove();
579
+ completed.delete(doomed.id);
580
+ }
581
+ } else {
582
+ row.startEv = startEv;
583
+ if (endEv) row.endEv = endEv;
584
+ }
585
+ rebuildRowEl(row);
586
+ if (row.endEv) completed.set(row.id, { startEv: row.startEv, endEv: row.endEv });
587
+ updateRequestMeta();
588
+ }
589
+
590
+ function updateRequestMeta() {
591
+ const inflightCount = [...allRows].filter(r => !r.endEv).length;
592
+ const visible = allRows.filter(requestMatchesFilter).length;
593
+ const total = allRows.length;
594
+ const status = inflightCount > 0 ? 'streaming' : 'idle';
595
+ $('r-meta').textContent = `· showing ${visible} of ${total} · ${status}`;
596
+ $('f-stream').textContent = inflightCount > 0 ? 'stream · active' : 'stream · connected';
597
+ }
598
+
599
+ // Filter buttons
600
+ for (const btn of $('r-filters').querySelectorAll('button')) {
601
+ btn.addEventListener('click', () => {
602
+ currentFilter = btn.dataset.filter;
603
+ for (const b of $('r-filters').querySelectorAll('button')) {
604
+ const active = b.dataset.filter === currentFilter;
605
+ b.classList.toggle('text-[#C9D9A8]', active);
606
+ b.classList.toggle('text-[#5A5F54]', !active);
607
+ }
608
+ // Rebuild visibility
609
+ for (const row of allRows) rebuildRowEl(row);
610
+ updateRequestMeta();
611
+ });
612
+ }
613
+ $('clearLogBtn').addEventListener('click', () => {
614
+ for (const row of allRows) row.el.remove();
615
+ allRows.length = 0;
616
+ completed.clear();
617
+ $('r-empty').style.display = '';
618
+ updateRequestMeta();
619
+ });
620
+
621
+ // ───────────────────────── Details modal
622
+ function openDetails(reqId) {
623
+ const pair = completed.get(reqId) || { startEv: inflight.get(reqId), endEv: null };
624
+ if (!pair.startEv) return;
625
+ $('detailsBody').textContent = JSON.stringify({
626
+ start: pair.startEv,
627
+ end: pair.endEv || '(still running — end event not received yet)',
628
+ }, null, 2);
629
+ $('detailsModal').classList.remove('hidden');
630
+ }
631
+ $('detailsCloseBtn').addEventListener('click', () => $('detailsModal').classList.add('hidden'));
632
+ $('detailsModal').addEventListener('click', (e) => {
633
+ if (e.target.id === 'detailsModal') $('detailsModal').classList.add('hidden');
634
+ });
635
+
636
+ // ───────────────────────── Auth
637
+ async function loadAuth({ verify = false } = {}) {
638
+ try {
639
+ const r = await fetch('/auth/status' + (verify ? '' : '?quick=1'));
640
+ const j = await r.json();
641
+ // Badge
642
+ const badgeText = j.loggedIn ? 'logged in' : 'not logged in';
643
+ const color = j.loggedIn ? '#B7E56D' : '#E89B2E';
644
+ $('a-badge').style.background = j.loggedIn ? '#B7E56D1A' : '#E89B2E1A';
645
+ $('a-badge').style.borderColor = color;
646
+ $('a-badge').firstElementChild.style.color = color;
647
+ $('a-badge').firstElementChild.textContent = badgeText;
648
+ $('a-email').textContent = j.email || '—';
649
+ $('a-plan').textContent = (j.subscriptionType || j.plan || '—').toUpperCase();
650
+ $('a-method').textContent = j.authMethod ? `via ${j.authMethod.replace('_', ' ').replace('oauth token', 'claude.ai')}` : '—';
651
+ $('a-probe').textContent = j.probeMs != null ? `${j.probeMs}ms · ${j.verified ? 'ok' : 'failed'}` : (verify ? 'failed' : 'not probed ← try force refresh');
652
+ } catch {
653
+ $('a-badge').firstElementChild.textContent = 'offline';
654
+ }
655
+ }
656
+
657
+ $('authRefreshBtn').addEventListener('click', async () => {
658
+ const btn = $('authRefreshBtn');
659
+ btn.disabled = true;
660
+ const label = btn.querySelector('span:last-child');
661
+ const orig = label.textContent;
662
+ label.textContent = 'probing…';
663
+ try { await fetch('/auth/refresh', { method: 'POST' }); await loadAuth(); }
664
+ finally { btn.disabled = false; label.textContent = orig; }
665
+ });
666
+
667
+ // ───────────────────────── Sessions
668
+ async function loadSessions() {
669
+ try {
670
+ const r = await fetch('/dashboard/sessions');
671
+ const j = await r.json();
672
+ $('sess-count').textContent = j.sessions.length;
673
+ const host = $('sess-rows');
674
+ if (!j.sessions.length) {
675
+ host.innerHTML = '<div class="py-6 text-center text-[#5A5F54] text-xs">no active sessions</div>';
676
+ return;
677
+ }
678
+ host.innerHTML = '';
679
+ for (const s of j.sessions) {
680
+ const row = document.createElement('div');
681
+ row.className = 'flex items-center py-3 px-6 gap-4 border-b border-[#1A1A15]';
682
+ row.innerHTML = `
683
+ <div class="grow min-w-0 text-[#F3EFE4] text-xs leading-4 truncate" title="${s.key}">${s.key}</div>
684
+ <div class="w-[160px] shrink-0 text-[#8A9A6A] text-xs leading-4 truncate">${s.model || '—'}</div>
685
+ <div class="w-[60px] text-right shrink-0 text-[#8A9A6A] text-xs">${s.messageCount}</div>
686
+ <div class="w-[80px] text-right shrink-0 text-[#5A5F54] text-[11px]">${fmt.uptime(s.idleSec)}</div>
687
+ <div class="w-[80px] text-right shrink-0 text-[#5A5F54] text-[11px]">${fmt.uptime(s.ttlRemainingSec)} left</div>
688
+ <button class="text-[#E89B2E] text-[11px] hover:brightness-110 shrink-0" data-key="${s.key}">expire</button>
689
+ `;
690
+ row.querySelector('button').addEventListener('click', async () => {
691
+ await fetch('/sessions/' + encodeURIComponent(s.key), { method: 'DELETE' });
692
+ loadSessions();
693
+ });
694
+ host.appendChild(row);
695
+ }
696
+ } catch {}
697
+ }
698
+ $('sessionsRefreshBtn').addEventListener('click', loadSessions);
699
+ $('sessionsClearBtn').addEventListener('click', async () => {
700
+ if (!confirm('Expire all sessions?')) return;
701
+ await fetch('/sessions', { method: 'DELETE' });
702
+ loadSessions();
703
+ });
704
+
705
+ // ───────────────────────── Logs
706
+ let logsAutoTimer = null;
707
+ async function loadLogs() {
708
+ try {
709
+ const r = await fetch('/dashboard/logs?lines=200');
710
+ const j = await r.json();
711
+ const pane = $('logs-body');
712
+ const wasAtBottom = pane.scrollTop + pane.clientHeight >= pane.scrollHeight - 10;
713
+ pane.textContent = (j.lines || []).join('\n');
714
+ if (wasAtBottom) pane.scrollTop = pane.scrollHeight;
715
+ $('logs-meta').textContent = j.sizeBytes != null
716
+ ? `· ${(j.sizeBytes / 1024).toFixed(1)} KB · ${j.totalLines} lines`
717
+ : (j.note || '');
718
+ } catch (e) {
719
+ $('logs-body').textContent = '(logs unreadable: ' + e.message + ')';
720
+ }
721
+ }
722
+ function armLogAutoRefresh() {
723
+ if (logsAutoTimer) clearInterval(logsAutoTimer);
724
+ if ($('logsAutoRefresh').checked) logsAutoTimer = setInterval(loadLogs, 2500);
725
+ }
726
+ $('logsRefreshBtn').addEventListener('click', loadLogs);
727
+ $('logsAutoRefresh').addEventListener('change', armLogAutoRefresh);
728
+
729
+ // ───────────────────────── Health pill
730
+ function setHealth(ok) {
731
+ const dots = $('healthPill').querySelectorAll('.pulse-dot');
732
+ const color = ok ? '#B7E56D' : '#E89B2E';
733
+ for (const d of dots) { d.classList.remove('bg-[#5A5F54]', 'bg-[#B7E56D]', 'bg-[#E89B2E]'); d.style.background = color; }
734
+ $('healthText').textContent = ok ? 'healthy' : 'disconnected';
735
+ $('healthText').style.color = color;
736
+ $('healthStream').textContent = ok ? '· live' : '· offline';
737
+ }
738
+
739
+ // ───────────────────────── Snapshot + stream
740
+ async function loadSnapshot() {
741
+ try {
742
+ const r = await fetch('/dashboard/recent?limit=100');
743
+ const j = await r.json();
744
+ renderServerCard({ port: j.port, defaultModel: j.defaultModel, activeSessions: j.activeSessions, build: j.build });
745
+ renderStats(j.stats);
746
+ // Replay request events in chronological order
747
+ const starts = new Map();
748
+ const chronological = [...j.recent].reverse();
749
+ for (const ev of chronological) {
750
+ if (ev.type === 'request.start') { starts.set(ev.id, ev); upsertRequestRow(ev, null); }
751
+ else if (ev.type === 'request.end' && starts.has(ev.id)) {
752
+ upsertRequestRow(starts.get(ev.id), ev);
753
+ }
754
+ }
755
+ } catch (e) { console.warn('snapshot failed', e); }
756
+ }
757
+
758
+ function connectStream() {
759
+ const es = new EventSource('/events');
760
+ es.onopen = () => setHealth(true);
761
+ es.onerror = () => setHealth(false);
762
+ es.onmessage = (m) => {
763
+ let ev; try { ev = JSON.parse(m.data); } catch { return; }
764
+ if (ev.type === 'request.start') {
765
+ inflight.set(ev.id, ev);
766
+ upsertRequestRow(ev, null);
767
+ if (stats) { stats.totalRequests++; renderStats(stats); }
768
+ } else if (ev.type === 'request.end') {
769
+ const startEv = inflight.get(ev.id) || allRows.find(r => r.id === ev.id)?.startEv;
770
+ inflight.delete(ev.id);
771
+ if (startEv) upsertRequestRow(startEv, ev);
772
+ // Re-fetch stats to pick up new p50/p95 and traffic bucket
773
+ fetch('/dashboard/recent?limit=1').then(r => r.json()).then(j => renderStats(j.stats)).catch(() => {});
774
+ } else if (ev.type === 'auth.refresh') {
775
+ loadAuth({ verify: false });
776
+ } else if (ev.type === 'session.created' || ev.type === 'session.updated' || ev.type === 'session.expired') {
777
+ loadSessions();
778
+ } else if (ev.type === 'server.boot') {
779
+ loadSnapshot();
780
+ loadAuth({ verify: false });
781
+ loadSessions();
782
+ loadLogs();
783
+ }
784
+ };
785
+ }
786
+
787
+ // Uptime ticker
788
+ setInterval(() => {
789
+ if (stats) {
790
+ stats.uptimeSec++;
791
+ $('k-uptime').textContent = fmt.uptime(stats.uptimeSec);
792
+ $('k-uptimeUnit').textContent = stats.uptimeSec >= 3600 ? 'h:mm:ss' : 'm:ss';
793
+ }
794
+ }, 1000);
795
+
796
+ // Kick off
797
+ loadSnapshot();
798
+ loadAuth({ verify: false });
799
+ loadSessions();
800
+ loadLogs();
801
+ armLogAutoRefresh();
802
+ connectStream();
803
+ </script>
804
+ </body>
805
+ </html>