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/CHANGELOG.md +207 -0
- package/LICENSE +21 -0
- package/README.md +429 -0
- package/bin/mobygate.js +443 -0
- package/index.html +805 -0
- package/launchd/ai.mobygate.auth-refresh.plist +83 -0
- package/lib/ascii.js +108 -0
- package/lib/config.js +131 -0
- package/lib/dashboard-bus.js +158 -0
- package/lib/platform.js +584 -0
- package/lib/session-store.js +112 -0
- package/mcp-inspect.mjs +186 -0
- package/package.json +62 -0
- package/scripts/auth-helper.js +198 -0
- package/scripts/auth-refresh.js +41 -0
- package/scripts/auth-status.js +36 -0
- package/server.js +1076 -0
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 (>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>
|