Monorepo for Aesthetic.Computer
aesthetic.computer
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.0">
6<title>silo · Aesthetic Computer</title>
7<link rel="icon" href="https://aesthetic.computer/icon/128x128/prompt.png" type="image/png" />
8<link rel="stylesheet" href="https://aesthetic.computer/type/webfonts/berkeley-mono-variable.css" />
9<style>
10:root {
11 --bg: #111; --bg2: #1a1a1a; --fg: #ccc; --fg2: #888;
12 --accent: #6c6; --border: #333; --hover: #222;
13 --ok: #6c6; --err: #c66; --warn: #cc6;
14}
15[data-theme="light"] {
16 --bg: #f4f4f4; --bg2: #fff; --fg: #222; --fg2: #777;
17 --accent: #396; --border: #ccc; --hover: #eee;
18 --ok: #396; --err: #c44; --warn: #a80;
19}
20* { margin: 0; padding: 0; box-sizing: border-box; }
21html, body { height: 100%; overflow: hidden; }
22body {
23 font-family: 'Berkeley Mono Variable', monospace;
24 font-size: 13px; line-height: 1.4;
25 background: var(--bg); color: var(--fg);
26 cursor: url('https://aesthetic.computer/aesthetic.computer/cursors/precise.svg') 12 12, auto;
27}
28a { color: var(--accent); text-decoration: none; }
29a:hover { text-decoration: underline; }
30
31/* login */
32#login {
33 display: flex; position: fixed; inset: 0; z-index: 100;
34 background: var(--bg); align-items: center; justify-content: center;
35 flex-direction: column; gap: 8px;
36}
37#login h1 { font-size: 18px; font-weight: normal; color: var(--fg); letter-spacing: 3px; }
38#login p { font-size: 11px; color: var(--fg2); }
39#loginBtn {
40 font-family: inherit; font-size: 12px; padding: 6px 16px; margin-top: 8px;
41 background: var(--bg2); color: var(--fg); border: 1px solid var(--border);
42 cursor: pointer;
43}
44#loginBtn:hover { border-color: var(--accent); }
45#authStatus { font-size: 11px; color: var(--fg2); }
46
47/* layout */
48#dashboard {
49 display: none; flex-direction: column; height: 100vh; overflow: hidden;
50}
51#dashboard.visible { display: flex; }
52
53/* header */
54.bar {
55 display: flex; align-items: center; gap: 6px; padding: 6px 8px;
56 border-bottom: 1px solid var(--border);
57 background: var(--bg); z-index: 10; flex-shrink: 0; flex-wrap: wrap;
58}
59.bar-title { font-size: 14px; color: var(--fg); cursor: pointer; letter-spacing: 2px; }
60.bar-title:hover { color: var(--accent); }
61.bar-sep { color: var(--border); }
62.dot { width: 7px; height: 7px; border-radius: 50%; background: var(--fg2); display: inline-block; flex-shrink: 0; }
63.dot.ok { background: var(--ok); }
64.dot.err { background: var(--err); }
65.bar-dim { font-size: 11px; color: var(--fg2); }
66.bar-right { margin-left: auto; display: flex; align-items: center; gap: 6px; }
67.btn {
68 font-family: inherit; font-size: 11px; padding: 2px 8px;
69 background: var(--bg2); color: var(--fg2); border: 1px solid var(--border);
70 cursor: pointer;
71}
72.btn:hover { color: var(--fg); border-color: var(--fg2); }
73
74/* tabs */
75.tab-bar {
76 display: flex; overflow-x: auto; white-space: nowrap;
77 gap: 0; border-bottom: 1px solid var(--border);
78 padding: 0 6px; background: var(--bg); flex-shrink: 0;
79}
80.tab-btn {
81 font-family: inherit; font-size: 11px; padding: 5px 10px;
82 background: none; color: var(--fg2); border: none; border-bottom: 2px solid transparent;
83 cursor: pointer; white-space: nowrap;
84}
85.tab-btn:hover { color: var(--fg); }
86.tab-btn.active { color: var(--accent); border-bottom-color: var(--accent); }
87
88/* panels */
89.panels { flex: 1; overflow: hidden; position: relative; }
90.panel {
91 display: none; position: absolute; inset: 0;
92 overflow-y: auto; padding: 8px;
93}
94.panel.active { display: block; }
95
96/* overview two-col on wide screens */
97.overview-grid { display: grid; grid-template-columns: 1fr; gap: 6px; }
98@media (min-width: 700px) {
99 .overview-grid { grid-template-columns: 1fr 1fr; }
100}
101
102.card {
103 border: 1px solid var(--border); background: var(--bg2); padding: 8px;
104}
105.card-hd {
106 font-size: 11px; color: var(--fg2); text-transform: uppercase;
107 letter-spacing: 1px; padding-bottom: 4px; margin-bottom: 6px;
108 border-bottom: 1px solid var(--border);
109 display: flex; justify-content: space-between; align-items: center;
110}
111.card-hd b { color: var(--fg); font-weight: normal; }
112
113/* stats */
114.kv { display: flex; justify-content: space-between; padding: 2px 0; font-size: 12px; }
115.kv .k { color: var(--fg2); }
116.kv .v { color: var(--fg); font-variant-numeric: tabular-nums; }
117.kv .v.ok { color: var(--ok); }
118.kv .v.err { color: var(--err); }
119.kv .v.warn { color: var(--warn); }
120
121/* table */
122.tbl { width: 100%; border-collapse: collapse; font-size: 12px; }
123.tbl th { text-align: left; font-weight: normal; color: var(--fg2); font-size: 10px;
124 text-transform: uppercase; letter-spacing: 1px; padding: 2px 4px;
125 border-bottom: 1px solid var(--border); }
126.tbl td { padding: 2px 4px; border-bottom: 1px solid color-mix(in srgb, var(--border) 50%, transparent); }
127.tbl td.r { text-align: right; font-variant-numeric: tabular-nums; }
128.tbl tr:hover td { background: var(--hover); }
129
130/* storage bars */
131.sbar { display: flex; align-items: center; gap: 6px; padding: 3px 0; font-size: 12px; }
132.sbar-name { min-width: 100px; color: var(--fg); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
133.sbar-track { flex: 1; height: 6px; background: var(--border); overflow: hidden; }
134.sbar-fill { height: 100%; background: var(--accent); }
135.sbar-val { min-width: 80px; text-align: right; color: var(--fg2); font-size: 11px; font-variant-numeric: tabular-nums; }
136
137/* log */
138.log-panel { display: none; position: absolute; inset: 0; flex-direction: column; }
139.log-panel.active { display: flex; }
140.log-hd {
141 font-size: 11px; color: var(--fg2); text-transform: uppercase;
142 letter-spacing: 1px; padding: 8px 8px 4px; flex-shrink: 0;
143}
144.log-hd b { color: var(--fg); font-weight: normal; }
145.log-scroll { flex: 1; overflow-y: auto; padding: 0 8px 8px; }
146.log-row { display: flex; gap: 8px; padding: 1px 0; font-size: 11px; }
147.log-t { color: var(--fg2); min-width: 60px; font-variant-numeric: tabular-nums; }
148.log-m { color: var(--fg); word-break: break-word; }
149.log-m.error { color: var(--err); }
150.log-m.warn { color: var(--warn); }
151
152/* compare */
153.sync-badge { font-size: 10px; padding: 1px 4px; border: 1px solid; display: inline-block; }
154.sync-badge.synced { color: var(--ok); border-color: var(--ok); }
155.sync-badge.unsynced { color: var(--err); border-color: var(--err); }
156.active-badge { font-size: 10px; padding: 1px 4px; background: var(--accent); color: var(--bg); }
157
158.loading { color: var(--fg2); }
159
160/* sync button */
161.sync-btn {
162 font-family: inherit; font-size: 10px; padding: 1px 6px;
163 background: var(--bg); color: var(--fg2); border: 1px solid var(--border);
164 cursor: pointer; margin-left: 4px;
165}
166.sync-btn:hover { color: var(--fg); border-color: var(--fg2); }
167.sync-btn:disabled { opacity: 0.5; cursor: not-allowed; }
168
169/* firehose */
170.firehose-panel { padding: 0 !important; overflow: hidden !important; }
171.firehose-panel canvas { display: block; width: 100%; height: 100%; }
172.firehose-hud {
173 position: absolute; top: 8px; right: 12px; z-index: 2;
174 display: flex; gap: 12px; font-size: 11px; color: var(--fg2);
175 pointer-events: none;
176}
177.firehose-counter { color: var(--fg); }
178.firehose-rate { color: var(--accent); }
179.firehose-sidebar {
180 position: absolute; bottom: 8px; left: 8px; z-index: 2;
181 font-size: 10px; pointer-events: none;
182 max-height: calc(100% - 40px); overflow: hidden;
183}
184.fh-counter-row {
185 display: flex; align-items: center; gap: 4px; padding: 1px 0;
186 opacity: 0.7;
187}
188.fh-counter-dot { width: 6px; height: 6px; border-radius: 50%; flex-shrink: 0; }
189.fh-counter-ns { color: var(--fg); min-width: 80px; }
190.fh-counter-val { color: var(--fg); font-variant-numeric: tabular-nums; min-width: 30px; text-align: right; }
191.fh-counter-ops { color: var(--fg2); margin-left: 2px; }
192
193/* sub-tabs */
194.sub-tab-bar {
195 display: flex; gap: 0; margin-bottom: 8px;
196 border-bottom: 1px solid var(--border); padding: 0 2px;
197}
198.sub-tab-btn {
199 font-family: inherit; font-size: 11px; padding: 4px 10px;
200 background: none; color: var(--fg2); border: none; border-bottom: 2px solid transparent;
201 cursor: pointer; white-space: nowrap;
202}
203.sub-tab-btn:hover { color: var(--fg); }
204.sub-tab-btn.active { color: var(--accent); border-bottom-color: var(--accent); }
205.sub-tab-btn.deprecated { opacity: 0.6; }
206.dep-badge {
207 font-size: 9px; padding: 1px 3px; background: var(--warn);
208 color: var(--bg); vertical-align: middle; margin-left: 2px;
209}
210.sub-panel { display: none; }
211.sub-panel.active { display: block; }
212
213/* data grid */
214.data-grid { display: grid; grid-template-columns: 1fr; gap: 8px; }
215@media (min-width: 700px) {
216 .data-grid { grid-template-columns: 220px 1fr; }
217}
218
219/* pie chart */
220#pieChart { display: block; margin: 4px auto; }
221.pie-legend-row { display: flex; align-items: center; gap: 4px; padding: 2px 0; font-size: 11px; }
222.pie-dot { width: 8px; height: 8px; border-radius: 50%; flex-shrink: 0; }
223.pie-label { color: var(--fg); flex: 1; }
224.pie-val { color: var(--fg2); font-variant-numeric: tabular-nums; }
225
226/* category groups */
227.cat-group { margin-bottom: 8px; }
228.cat-hd {
229 font-size: 11px; color: var(--fg); padding: 3px 6px;
230 display: flex; justify-content: space-between; align-items: center;
231 background: color-mix(in srgb, var(--bg2) 80%, var(--border));
232}
233.cat-meta { color: var(--fg2); font-size: 10px; }
234
235/* deprecated card */
236.card.deprecated { border-color: var(--warn); opacity: 0.7; }
237.card.deprecated:hover { opacity: 0.9; }
238
239/* backup empty */
240.backup-empty { color: var(--fg2); font-size: 12px; padding: 8px 0; }
241.backup-detail { font-size: 11px; color: var(--fg2); margin-top: 4px; }
242
243/* browse */
244.browse-layout { display: grid; grid-template-columns: 1fr; gap: 6px; }
245@media (min-width: 700px) {
246 .browse-layout { grid-template-columns: 180px 1fr; }
247}
248.browse-sidebar { max-height: 60vh; overflow-y: auto; }
249.browse-coll {
250 padding: 3px 6px; font-size: 12px; cursor: pointer; color: var(--fg2);
251 border-left: 2px solid transparent;
252}
253.browse-coll:hover { color: var(--fg); background: var(--hover); }
254.browse-coll.active { color: var(--accent); border-left-color: var(--accent); }
255.browse-coll-count { float: right; font-size: 10px; color: var(--fg2); }
256.browse-input {
257 font-family: inherit; font-size: 11px; padding: 2px 6px;
258 background: var(--bg); color: var(--fg); border: 1px solid var(--border);
259}
260.browse-input:focus { border-color: var(--accent); outline: none; }
261.browse-json {
262 font-family: 'Berkeley Mono Variable', monospace; font-size: 11px;
263 background: var(--bg); color: var(--fg); padding: 8px;
264 border: 1px solid var(--border); overflow: auto; max-height: 50vh;
265 white-space: pre-wrap; word-break: break-word; line-height: 1.4;
266 margin-top: 4px;
267}
268.browse-json.editing {
269 border-color: var(--accent); outline: none;
270}
271.browse-row { cursor: pointer; }
272.browse-row:hover td { background: var(--hover); }
273
274@media (max-width: 640px) {
275 .bar { gap: 4px; padding: 4px 6px; }
276 body { font-size: 12px; }
277 .card { padding: 6px; }
278}
279</style>
280</head>
281<body>
282<div id="login">
283 <h1>silo</h1>
284 <p>aesthetic.computer data dashboard</p>
285 <button id="loginBtn">sign in</button>
286 <p id="authStatus">loading...</p>
287</div>
288
289<div id="dashboard">
290 <div class="bar">
291 <span class="bar-title" id="logo">silo</span>
292 <span class="bar-sep">/</span>
293 <span class="dot" id="wsStatus"></span>
294 <span class="bar-dim" id="wsStatusText">ws</span>
295 <span class="bar-sep">/</span>
296 <span class="dot" id="mongoStatus"></span>
297 <span class="bar-dim" id="mongoStatusText">db</span>
298 <span class="bar-sep">/</span>
299 <span class="dot" id="redisStatus"></span>
300 <span class="bar-dim" id="redisStatusText">redis</span>
301 <span class="bar-dim" id="uptimeText"></span>
302 <div class="bar-right">
303 <button class="btn" id="themeBtn">light</button>
304 <button class="btn" id="logoutBtn" style="display:none">logout</button>
305 </div>
306 </div>
307
308 <div class="tab-bar" id="tabBar">
309 <button class="tab-btn active" data-tab="0">overview</button>
310 <button class="tab-btn" data-tab="1">data</button>
311 <button class="tab-btn" data-tab="2">services</button>
312 <button class="tab-btn" data-tab="3">storage</button>
313 <button class="tab-btn" data-tab="4">log</button>
314 <button class="tab-btn" data-tab="5">firehose</button>
315 <button class="tab-btn" data-tab="6">feed</button>
316 <button class="tab-btn" data-tab="7">telemetry</button>
317 <button class="tab-btn" data-tab="8">lith</button>
318 </div>
319
320 <div class="panels">
321 <!-- overview -->
322 <div class="panel active" data-panel="0">
323 <div class="overview-grid">
324 <div class="card">
325 <div class="card-hd">stats</div>
326 <div class="kv"><span class="k">users</span><span class="v" id="s-users">-</span></div>
327 <div class="kv"><span class="k">paintings</span><span class="v" id="s-paintings">-</span></div>
328 <div class="kv"><span class="k">kidlisp</span><span class="v" id="s-kidlisp">-</span></div>
329 <div class="kv"><span class="k">moods</span><span class="v" id="s-moods">-</span></div>
330 <div class="kv"><span class="k">chat</span><span class="v" id="s-chat">-</span></div>
331 <div class="kv"><span class="k">collections</span><span class="v" id="s-cols">-</span></div>
332 <div class="kv"><span class="k">total docs</span><span class="v" id="s-docs">-</span></div>
333 <div class="kv"><span class="k">db size</span><span class="v" id="s-dbsize">-</span></div>
334 <div class="kv"><span class="k">storage</span><span class="v" id="s-storage">-</span></div>
335 <div class="kv"><span class="k">billing est</span><span class="v" id="s-billing">-</span></div>
336 </div>
337 <div class="card">
338 <div class="card-hd">services</div>
339 <div class="kv"><span class="k"><span class="dot" id="svc-oven-dot"></span> oven</span><span class="v" id="svc-oven-meta">...</span></div>
340 <div class="kv"><span class="k"><span class="dot" id="svc-session-dot"></span> session</span><span class="v" id="svc-session-meta">...</span></div>
341 <div class="kv"><span class="k"><span class="dot" id="svc-feed-dot"></span> feed</span><span class="v" id="svc-feed-meta">...</span></div>
342 <div class="kv"><span class="k"><span class="dot" id="svc-insta-dot"></span> instagram</span><span class="v" id="svc-insta-meta">...</span></div>
343 <div class="kv"><span class="k"><span class="dot" id="svc-tiktok-dot"></span> tiktok</span><span class="v" id="svc-tiktok-meta">...</span></div>
344 <div style="margin-top:8px">
345 <div class="card-hd">redis</div>
346 <div id="redisTile" class="loading">loading...</div>
347 </div>
348 <div style="margin-top:8px">
349 <div class="card-hd">billing</div>
350 <div id="billingTile" class="loading">loading...</div>
351 </div>
352 </div>
353 </div>
354 </div>
355
356 <!-- data (collections, backups, atlas) -->
357 <div class="panel" data-panel="1">
358 <div class="sub-tab-bar">
359 <button class="sub-tab-btn active" data-subtab="collections">collections</button>
360 <button class="sub-tab-btn" data-subtab="browse">browse</button>
361 <button class="sub-tab-btn" data-subtab="backups">backups</button>
362 <button class="sub-tab-btn deprecated" data-subtab="atlas">atlas <span class="dep-badge">deprecated</span></button>
363 </div>
364
365 <div class="sub-panel active" data-subpanel="collections">
366 <div class="data-grid">
367 <div class="card">
368 <div class="card-hd">size by category</div>
369 <canvas id="pieChart"></canvas>
370 <div id="pieLegend"></div>
371 </div>
372 <div class="card">
373 <div class="card-hd">all collections <b id="colCount"></b></div>
374 <div id="categorizedCollections" class="loading">loading...</div>
375 </div>
376 </div>
377 </div>
378
379 <div class="sub-panel" data-subpanel="browse">
380 <div class="browse-layout">
381 <div class="browse-sidebar card">
382 <div class="card-hd">collections</div>
383 <div id="browseCollList" class="loading">loading...</div>
384 </div>
385 <div class="browse-main card">
386 <div class="card-hd">
387 <span id="browseCollName">select a collection</span>
388 <span style="display:flex;gap:4px;align-items:center">
389 <input id="browseSearch" placeholder="search..." class="browse-input" style="width:140px">
390 <button class="btn" id="browseSearchBtn">go</button>
391 </span>
392 </div>
393 <div id="browseDocs" style="font-size:12px;color:var(--fg2)">click a collection to browse</div>
394 <div id="browsePager" style="margin-top:6px;font-size:11px;display:flex;gap:6px;align-items:center">
395 <button class="btn" id="browsePrev" disabled>prev</button>
396 <span id="browseRange" style="color:var(--fg2)"></span>
397 <button class="btn" id="browseNext" disabled>next</button>
398 </div>
399 </div>
400 </div>
401 <div id="browseDocDetail" style="display:none">
402 <div class="card" style="margin-top:6px">
403 <div class="card-hd">
404 <span>document <b id="browseDocId"></b></span>
405 <span style="display:flex;gap:4px">
406 <button class="btn" id="browseDocEdit">edit</button>
407 <button class="btn" id="browseDocSave" style="display:none">save</button>
408 <button class="btn" id="browseDocCancel" style="display:none">cancel</button>
409 <button class="btn" id="browseDocDelete">delete</button>
410 <button class="btn" id="browseDocClose">close</button>
411 </span>
412 </div>
413 <pre id="browseDocJson" class="browse-json"></pre>
414 </div>
415 </div>
416 </div>
417
418 <div class="sub-panel" data-subpanel="backups">
419 <div class="card">
420 <div class="card-hd">database backups</div>
421 <div id="backupsList" class="loading">loading...</div>
422 </div>
423 </div>
424
425 <div class="sub-panel" data-subpanel="atlas">
426 <div class="card deprecated" style="margin-bottom:6px">
427 <div class="card-hd"><span style="color:var(--warn)">atlas</span> <b id="dbSyncStatus"></b> <button class="sync-btn" id="syncBtn" title="Sync Atlas to Primary">sync from atlas</button></div>
428 <div style="font-size:11px; color:var(--fg2); margin-bottom:6px;">Atlas is the legacy database. Silo is now the source of truth. This view is kept for reference and emergency sync only.</div>
429 <div id="dbCompare" class="loading">loading...</div>
430 </div>
431 </div>
432 </div>
433
434 <!-- services detail -->
435 <div class="panel" data-panel="2">
436 <div class="card">
437 <div class="card-hd">services</div>
438 <div class="kv"><span class="k"><span class="dot" id="svc-oven-dot2"></span> oven</span><span class="v" id="svc-oven-meta2">...</span></div>
439 <div class="kv"><span class="k"><span class="dot" id="svc-session-dot2"></span> session</span><span class="v" id="svc-session-meta2">...</span></div>
440 <div class="kv"><span class="k"><span class="dot" id="svc-feed-dot2"></span> feed</span><span class="v" id="svc-feed-meta2">...</span></div>
441 </div>
442 <div class="card" style="margin-top:6px">
443 <div class="card-hd">redis</div>
444 <div id="redisTile2" class="loading">loading...</div>
445 </div>
446 <div class="card" style="margin-top:6px">
447 <div class="card-hd">billing</div>
448 <div id="billingTile2" class="loading">loading...</div>
449 </div>
450 <div class="card" style="margin-top:6px">
451 <div class="card-hd"><span class="dot" id="insta-dot"></span> instagram</div>
452 <div class="kv"><span class="k">status</span><span class="v" id="insta-status">...</span></div>
453 <div class="kv"><span class="k">account</span><span class="v" id="insta-account">-</span></div>
454 <div class="kv"><span class="k">session age</span><span class="v" id="insta-age">-</span></div>
455 <div id="insta-actions" style="margin-top:6px">
456 <button class="btn" id="instaLoginBtn">login</button>
457 <button class="btn" id="instaLogoutBtn" style="display:none">logout</button>
458 <span id="insta-challenge" style="display:none">
459 <input id="instaCode" placeholder="verification code" style="font-family:inherit;font-size:11px;padding:2px 6px;background:var(--bg);color:var(--fg);border:1px solid var(--border);width:120px;">
460 <button class="btn" id="instaVerifyBtn">verify</button>
461 </span>
462 <span id="insta-msg" style="font-size:11px;color:var(--fg2);margin-left:6px"></span>
463 </div>
464 </div>
465 <div class="card" style="margin-top:6px">
466 <div class="card-hd"><span class="dot" id="tiktok-dot"></span> tiktok</div>
467 <div class="kv"><span class="k">status</span><span class="v" id="tiktok-status">...</span></div>
468 <div class="kv"><span class="k">account</span><span class="v" id="tiktok-account">-</span></div>
469 <div class="kv"><span class="k">token age</span><span class="v" id="tiktok-age">-</span></div>
470 <div class="kv"><span class="k">expires</span><span class="v" id="tiktok-expires">-</span></div>
471 <div id="tiktok-stats" style="display:none;margin-top:4px">
472 <div class="kv"><span class="k">followers</span><span class="v" id="tiktok-followers">-</span></div>
473 <div class="kv"><span class="k">likes</span><span class="v" id="tiktok-likes">-</span></div>
474 <div class="kv"><span class="k">videos</span><span class="v" id="tiktok-videos">-</span></div>
475 </div>
476 <div id="tiktok-actions" style="margin-top:6px">
477 <button class="btn" id="tiktokConnectBtn">connect</button>
478 <button class="btn" id="tiktokDisconnectBtn" style="display:none">disconnect</button>
479 <button class="btn" id="tiktokRefreshBtn" style="display:none">refresh token</button>
480 <span id="tiktok-msg" style="font-size:11px;color:var(--fg2);margin-left:6px"></span>
481 </div>
482 </div>
483 </div>
484
485 <!-- storage -->
486 <div class="panel" data-panel="3">
487 <div class="card">
488 <div class="card-hd">storage <b id="stoTotal"></b></div>
489 <div id="storageBuckets" class="loading">loading...</div>
490 </div>
491 </div>
492
493 <!-- log -->
494 <div class="log-panel" data-panel="4">
495 <div class="log-hd">log <b id="logCount">0</b></div>
496 <div class="log-scroll" id="logEntries"></div>
497 </div>
498
499 <!-- firehose -->
500 <div class="panel firehose-panel" data-panel="5">
501 <div class="firehose-hud">
502 <span class="firehose-counter"><span id="firehoseCount">0</span> events</span>
503 <span class="firehose-rate" id="firehoseRate">0/s</span>
504 </div>
505 <div class="firehose-sidebar" id="firehoseCounters"></div>
506 <canvas id="firehoseCanvas"></canvas>
507 </div>
508
509 <!-- feed (dp1-feed) -->
510 <div class="panel" data-panel="6">
511 <div class="overview-grid">
512 <div class="card">
513 <div class="card-hd">dp1-feed <b id="feed-runtime"></b></div>
514 <div class="kv"><span class="k">status</span><span class="v" id="feed-status">...</span></div>
515 <div class="kv"><span class="k">version</span><span class="v" id="feed-version">-</span></div>
516 <div class="kv"><span class="k">deployment</span><span class="v" id="feed-deployment">-</span></div>
517 <div class="kv"><span class="k">runtime</span><span class="v" id="feed-runtime-val">-</span></div>
518 <div class="kv"><span class="k">response</span><span class="v" id="feed-response">-</span></div>
519 </div>
520 <div class="card">
521 <div class="card-hd">endpoints</div>
522 <div id="feed-endpoints" style="font-size:11px;color:var(--fg2)">loading...</div>
523 </div>
524 </div>
525 <div class="card" style="margin-top:6px">
526 <div class="card-hd">playlists <b id="feed-playlist-count"></b></div>
527 <div id="feed-playlists" class="loading">loading...</div>
528 </div>
529 <div class="card" style="margin-top:6px">
530 <div class="card-hd">channels <b id="feed-channel-count"></b></div>
531 <div id="feed-channels" class="loading">loading...</div>
532 </div>
533 </div>
534
535 <!-- telemetry -->
536 <div class="panel" data-panel="7">
537 <div class="overview-grid">
538 <div class="card">
539 <div class="card-hd">GPU / KidLisp Logs</div>
540 <div class="kv"><span class="k">total events</span><span class="v" id="tl-kl-total">-</span></div>
541 <div class="kv"><span class="k">est. size</span><span class="v" id="tl-kl-size">-</span></div>
542 <div class="kv"><span class="k">by type</span><span class="v" id="tl-kl-types" style="white-space:pre-wrap">-</span></div>
543 <div class="kv"><span class="k">by effect</span><span class="v" id="tl-kl-effects" style="white-space:pre-wrap">-</span></div>
544 <div class="kv"><span class="k">by GPU</span><span class="v" id="tl-kl-gpus" style="white-space:pre-wrap">-</span></div>
545 <div style="margin-top:8px">
546 <button onclick="purgeKidlispLogs()" style="background:#c33;color:#fff;border:none;padding:4px 12px;border-radius:3px;cursor:pointer;font-size:11px">purge all</button>
547 </div>
548 </div>
549 <div class="card">
550 <div class="card-hd">Boot Logs</div>
551 <div class="kv"><span class="k">total boots</span><span class="v" id="tl-bt-total">-</span></div>
552 <div class="kv"><span class="k">by status</span><span class="v" id="tl-bt-status" style="white-space:pre-wrap">-</span></div>
553 </div>
554 </div>
555 <div class="card" style="margin-top:6px">
556 <div class="card-hd">recent GPU events</div>
557 <div id="tl-recent" style="font-size:11px;color:var(--fg2);max-height:400px;overflow-y:auto">loading...</div>
558 </div>
559 </div>
560
561 <!-- lith panel -->
562 <div class="panel" data-panel="8">
563 <div class="overview-grid" id="lith-overview">
564 <div class="card">
565 <div class="card-hd">lith status</div>
566 <div class="kv"><span class="k">uptime</span><span class="v" id="lith-uptime">-</span></div>
567 <div class="kv"><span class="k">boot</span><span class="v" id="lith-boot">-</span></div>
568 <div class="kv"><span class="k">functions loaded</span><span class="v" id="lith-fn-count">-</span></div>
569 <div class="kv"><span class="k">memory (RSS / heap)</span><span class="v" id="lith-mem">-</span></div>
570 <div class="kv"><span class="k">total calls</span><span class="v" id="lith-total-calls">-</span></div>
571 <div class="kv"><span class="k">total errors</span><span class="v" id="lith-total-errors">-</span></div>
572 </div>
573 <div class="card">
574 <div class="card-hd">top functions (by calls)</div>
575 <div id="lith-top-fns" style="font-size:11px;max-height:300px;overflow-y:auto">loading...</div>
576 </div>
577 </div>
578
579 <div style="display:flex;gap:4px;padding:4px 6px;border-bottom:1px solid var(--border)">
580 <button class="sub-tab-btn active" onclick="lithSubTab(this,'lith-errors-panel')">errors</button>
581 <button class="sub-tab-btn" onclick="lithSubTab(this,'lith-requests-panel')">recent requests</button>
582 <button class="sub-tab-btn" onclick="lithSubTab(this,'lith-traffic-panel')">traffic</button>
583 </div>
584
585 <div id="lith-errors-panel" class="sub-panel active" style="display:block;padding:6px;overflow-y:auto;max-height:calc(100vh - 360px)">
586 <table style="width:100%;font-size:11px;border-collapse:collapse" id="lith-errors-table">
587 <thead><tr style="color:var(--fg2);text-align:left">
588 <th style="padding:2px 6px">time</th>
589 <th style="padding:2px 6px">function</th>
590 <th style="padding:2px 6px">status</th>
591 <th style="padding:2px 6px">path</th>
592 <th style="padding:2px 6px">error</th>
593 </tr></thead>
594 <tbody id="lith-errors-body"></tbody>
595 </table>
596 </div>
597
598 <div id="lith-requests-panel" class="sub-panel" style="display:none;padding:6px;overflow-y:auto;max-height:calc(100vh - 360px)">
599 <table style="width:100%;font-size:11px;border-collapse:collapse" id="lith-requests-table">
600 <thead><tr style="color:var(--fg2);text-align:left">
601 <th style="padding:2px 6px">time</th>
602 <th style="padding:2px 6px">function</th>
603 <th style="padding:2px 6px">ms</th>
604 <th style="padding:2px 6px">status</th>
605 <th style="padding:2px 6px">path</th>
606 </tr></thead>
607 <tbody id="lith-requests-body"></tbody>
608 </table>
609 </div>
610
611 <div id="lith-traffic-panel" class="sub-panel" style="display:none;padding:6px;overflow-y:auto;max-height:calc(100vh - 360px)">
612 <div style="display:flex;gap:12px;flex-wrap:wrap">
613 <div style="flex:1;min-width:250px">
614 <div style="font-size:12px;color:var(--fg2);margin-bottom:4px">by path (last 500 requests)</div>
615 <div id="lith-traffic-paths" style="font-size:11px">loading...</div>
616 </div>
617 <div style="flex:1;min-width:200px">
618 <div style="font-size:12px;color:var(--fg2);margin-bottom:4px">by host</div>
619 <div id="lith-traffic-hosts" style="font-size:11px">loading...</div>
620 </div>
621 <div style="min-width:120px">
622 <div style="font-size:12px;color:var(--fg2);margin-bottom:4px">by status</div>
623 <div id="lith-traffic-status" style="font-size:11px">loading...</div>
624 </div>
625 </div>
626 </div>
627
628 </div>
629
630 </div>
631</div>
632
633<script>
634let auth0Client = null, accessToken = null, logCount = 0;
635let darkMode = localStorage.getItem('silo-theme') !== 'light';
636applyTheme();
637
638function applyTheme() {
639 document.documentElement.setAttribute('data-theme', darkMode ? 'dark' : 'light');
640 const btn = document.getElementById('themeBtn');
641 if (btn) btn.textContent = darkMode ? 'light' : 'dark';
642}
643
644document.getElementById('themeBtn').onclick = () => {
645 darkMode = !darkMode;
646 localStorage.setItem('silo-theme', darkMode ? 'dark' : 'light');
647 applyTheme();
648};
649
650async function authFetch(url) {
651 const resp = await fetch(url, {
652 headers: accessToken ? { Authorization: 'Bearer ' + accessToken } : {},
653 });
654 if (resp.status === 401 && auth0Client) {
655 try {
656 accessToken = await auth0Client.getTokenSilently({ cacheMode: 'off' });
657 return fetch(url, { headers: { Authorization: 'Bearer ' + accessToken } });
658 } catch { auth0Client.loginWithRedirect(); }
659 }
660 return resp;
661}
662
663async function initAuth() {
664 document.getElementById('authStatus').textContent = 'loading...';
665 try {
666 await new Promise((resolve, reject) => {
667 const s = document.createElement('script');
668 s.src = 'https://cdn.auth0.com/js/auth0-spa-js/2.1/auth0-spa-js.production.js';
669 s.onload = resolve; s.onerror = reject;
670 document.head.appendChild(s);
671 });
672 const config = await fetch('/auth/config').then(r => r.json());
673 auth0Client = await window.auth0.createAuth0Client({
674 domain: config.domain, clientId: config.clientId,
675 cacheLocation: 'localstorage', useRefreshTokens: true,
676 authorizationParams: { redirect_uri: location.origin + location.pathname },
677 });
678 if (location.search.includes('state=') && location.search.includes('code=')) {
679 await auth0Client.handleRedirectCallback();
680 history.replaceState({}, '', location.pathname);
681 }
682 if (await auth0Client.isAuthenticated()) {
683 accessToken = await auth0Client.getTokenSilently();
684 const me = await authFetch('/auth/me').then(r => r.json());
685 if (!me.isAdmin) {
686 document.getElementById('authStatus').textContent = 'access denied [@' + (me.handle || '?') + ']';
687 document.getElementById('loginBtn').textContent = 'sign out';
688 document.getElementById('loginBtn').onclick = () =>
689 auth0Client.logout({ logoutParams: { returnTo: location.origin + location.pathname } });
690 return;
691 }
692 document.getElementById('login').style.display = 'none';
693 document.getElementById('dashboard').classList.add('visible');
694 document.getElementById('logoutBtn').style.display = 'inline';
695 connectWS(); loadAll();
696 } else {
697 document.getElementById('authStatus').textContent = '';
698 document.getElementById('loginBtn').onclick = () => auth0Client.loginWithRedirect();
699 }
700 } catch (err) {
701 console.error('Auth init failed:', err);
702 document.getElementById('authStatus').textContent = 'error: ' + err.message;
703 }
704 document.getElementById('logoutBtn').onclick = () => {
705 auth0Client?.logout({ logoutParams: { returnTo: location.origin + location.pathname } });
706 };
707}
708
709// ws
710const protocol = location.protocol === 'https:' ? 'wss:' : 'ws:';
711const wsUrl = protocol + '//' + location.host + '/ws';
712let ws = null;
713function connectWS() {
714 ws = new WebSocket(wsUrl);
715 ws.onopen = () => dot('wsStatus', true);
716 ws.onclose = () => { dot('wsStatus', false); setTimeout(connectWS, 2000); };
717 ws.onmessage = (e) => {
718 const data = JSON.parse(e.data);
719 if (data.logEntry) addLog(data.logEntry);
720 if (data.firehose) fhIngest(data.firehose);
721 if (data.fairyPoint) fhFirefly(data.fairyPoint);
722 };
723}
724function dot(id, ok) {
725 const el = document.getElementById(id);
726 if (el) el.className = 'dot ' + (ok ? 'ok' : 'err');
727}
728
729// data
730async function loadOverview() {
731 try {
732 const d = await authFetch('/api/overview').then(r => r.json());
733 const db = d.db || {};
734 document.getElementById('s-users').textContent = fmt(db.users);
735 document.getElementById('s-paintings').textContent = fmt(db.paintings);
736 document.getElementById('s-kidlisp').textContent = fmt(db.kidlisp);
737 document.getElementById('s-moods').textContent = fmt(db.moods);
738 document.getElementById('s-chat').textContent = fmt((db.chatSystem||0) + (db.chatClock||0));
739 document.getElementById('s-cols').textContent = fmt(db.totalCollections);
740 document.getElementById('s-docs').textContent = fmt(db.totalDocuments);
741 if (db.size) {
742 document.getElementById('s-dbsize').textContent = fmtBytes(db.size.dataSize);
743 }
744 document.getElementById('s-storage').textContent = (d.storage?.totalGB || '0') + ' GB';
745 dot('mongoStatus', true);
746
747 // redis (update both overview and detail tabs)
748 if (d.redis?.connected) {
749 dot('redisStatus', true);
750 const redisHtml =
751 kv('memory', d.redis.usedMemory || '-') +
752 kv('peak', d.redis.peakMemory || '-') +
753 kv('keys', fmt(d.redis.totalKeys)) +
754 kv('clients', d.redis.connectedClients) +
755 kv('hit rate', d.redis.hitRate + '%') +
756 kv('uptime', fmtDuration(d.redis.uptimeSeconds)) +
757 kv('version', d.redis.version || '-');
758 document.getElementById('redisTile').innerHTML = redisHtml;
759 const r2 = document.getElementById('redisTile2');
760 if (r2) r2.innerHTML = redisHtml;
761 } else {
762 dot('redisStatus', false);
763 const msg = '<span class="loading">not connected</span>';
764 document.getElementById('redisTile').innerHTML = msg;
765 const r2 = document.getElementById('redisTile2');
766 if (r2) r2.innerHTML = msg;
767 }
768
769 // uptime
770 const up = Math.floor((d.uptime || 0) / 1000);
771 document.getElementById('uptimeText').textContent =
772 Math.floor(up/3600) + 'h' + Math.floor((up%3600)/60) + 'm';
773 } catch (e) { console.error(e); }
774}
775
776async function loadCompare() {
777 try {
778 const d = await authFetch('/api/db/compare').then(r => r.json());
779 const el = document.getElementById('dbCompare');
780 const badge = document.getElementById('dbSyncStatus');
781
782 if (d.sameConnection) {
783 badge.innerHTML = '<span class="sync-badge synced">same db</span>';
784 el.innerHTML =
785 kv('primary', d.primaryLabel) +
786 kv('active', '<span class="active-badge">' + d.activeDb + '</span>') +
787 kv('status', 'single connection') +
788 (d.primarySize ? kv('data', fmtBytes(d.primarySize.dataSize)) + kv('indexes', fmtBytes(d.primarySize.indexSize)) : '');
789 } else {
790 badge.innerHTML = d.allSynced
791 ? '<span class="sync-badge synced">synced</span>'
792 : '<span class="sync-badge unsynced">out of sync</span>';
793
794 let html = kv('primary', d.primaryLabel + (d.primaryConnected ? '' : ' (down)')) +
795 kv('atlas', d.atlasLabel + (d.atlasConnected ? '' : ' (down)')) +
796 kv('active', '<span class="active-badge">' + d.activeDb + '</span>');
797
798 if (d.primarySize) html += kv('primary size', fmtBytes(d.primarySize.dataSize));
799 if (d.atlasSize) html += kv('atlas size', fmtBytes(d.atlasSize.dataSize));
800
801 // show mismatched collections
802 const mismatched = (d.collections || []).filter(c => !c.synced);
803 if (mismatched.length > 0) {
804 html += '<div style="margin-top:6px; font-size:11px; color:var(--fg2)">mismatched:</div>';
805 html += '<table class="tbl" style="margin-top:2px"><thead><tr><th>collection</th><th class="r">primary</th><th class="r">atlas</th></tr></thead><tbody>';
806 for (const c of mismatched) {
807 html += '<tr><td>' + esc(c.name) + '</td><td class="r">' + (c.primary ?? '-') + '</td><td class="r">' + (c.atlas ?? '-') + '</td></tr>';
808 }
809 html += '</tbody></table>';
810 }
811 el.innerHTML = html;
812 }
813 } catch (e) {
814 document.getElementById('dbCompare').innerHTML = '<span class="loading">error loading</span>';
815 }
816}
817
818let cachedCollections = null, cachedCatMeta = {};
819async function loadCollections() {
820 try {
821 const resp = await authFetch('/api/db/collections').then(r => r.json());
822 const cols = resp.collections || resp;
823 const cats = resp.categories || {};
824 cachedCollections = cols;
825 cachedCatMeta = cats;
826 document.getElementById('colCount').textContent = cols.length;
827 renderCategorizedCollections(cols, cats);
828 drawPieChart(cols, cats);
829 } catch (e) {
830 const el = document.getElementById('categorizedCollections');
831 if (el) el.innerHTML = '<span class="loading">error loading</span>';
832 }
833}
834
835function renderCategorizedCollections(collections, catMeta) {
836 const el = document.getElementById('categorizedCollections');
837 if (!el) return;
838 const catOrder = ['identity', 'content', 'communication', 'system', 'other'];
839 const groups = {};
840 for (const c of collections) {
841 const cat = c.category || 'other';
842 if (!groups[cat]) groups[cat] = [];
843 groups[cat].push(c);
844 }
845 let html = '';
846 for (const cat of catOrder) {
847 const items = groups[cat];
848 if (!items || items.length === 0) continue;
849 const meta = catMeta[cat] || { label: cat, color: '#888' };
850 const catTotal = items.reduce((s, c) => s + c.count, 0);
851 const catSize = items.reduce((s, c) => s + (c.size || 0), 0);
852 html += '<div class="cat-group">';
853 html += '<div class="cat-hd" style="border-left:3px solid ' + meta.color + '">';
854 html += '<span>' + esc(meta.label) + '</span>';
855 html += '<span class="cat-meta">' + fmt(catTotal) + ' docs / ' + fmtBytes(catSize) + '</span>';
856 html += '</div>';
857 html += '<table class="tbl"><thead><tr><th>name</th><th style="text-align:right">docs</th><th style="text-align:right">size</th></tr></thead><tbody>';
858 for (const c of items.sort((a, b) => b.count - a.count)) {
859 html += '<tr><td>' + esc(c.name) + '</td><td class="r">' + fmt(c.count) + '</td><td class="r">' + fmtBytes(c.size) + '</td></tr>';
860 }
861 html += '</tbody></table></div>';
862 }
863 el.innerHTML = html;
864 el.classList.remove('loading');
865}
866
867function drawPieChart(collections, catMeta) {
868 const canvas = document.getElementById('pieChart');
869 if (!canvas) return;
870 const ctx = canvas.getContext('2d');
871 const dpr = window.devicePixelRatio || 1;
872 const container = canvas.parentElement;
873 const size = Math.min(container.clientWidth - 16, 200);
874 canvas.width = size * dpr;
875 canvas.height = size * dpr;
876 canvas.style.width = size + 'px';
877 canvas.style.height = size + 'px';
878
879 // Aggregate by category
880 const catTotals = {};
881 for (const c of collections) {
882 const cat = c.category || 'other';
883 catTotals[cat] = (catTotals[cat] || 0) + (c.size || c.count || 0);
884 }
885 const total = Object.values(catTotals).reduce((s, v) => s + v, 0);
886 if (total === 0) {
887 ctx.clearRect(0, 0, canvas.width, canvas.height);
888 ctx.fillStyle = getComputedStyle(document.documentElement).getPropertyValue('--fg2').trim();
889 ctx.font = (11 * dpr) + 'px "Berkeley Mono Variable", monospace';
890 ctx.textAlign = 'center'; ctx.textBaseline = 'middle';
891 ctx.fillText('no data', canvas.width / 2, canvas.height / 2);
892 return;
893 }
894
895 const cx = canvas.width / 2, cy = canvas.height / 2;
896 const radius = Math.min(cx, cy) * 0.9;
897 let startAngle = -Math.PI / 2;
898 const slices = Object.entries(catTotals).sort((a, b) => b[1] - a[1]);
899
900 ctx.clearRect(0, 0, canvas.width, canvas.height);
901 for (const [cat, val] of slices) {
902 const sliceAngle = (val / total) * Math.PI * 2;
903 ctx.beginPath();
904 ctx.moveTo(cx, cy);
905 ctx.arc(cx, cy, radius, startAngle, startAngle + sliceAngle);
906 ctx.closePath();
907 ctx.fillStyle = (catMeta[cat] || { color: '#888' }).color;
908 ctx.fill();
909 startAngle += sliceAngle;
910 }
911 // Donut hole
912 const bg2 = getComputedStyle(document.documentElement).getPropertyValue('--bg2').trim() || '#1a1a1a';
913 ctx.beginPath();
914 ctx.arc(cx, cy, radius * 0.55, 0, Math.PI * 2);
915 ctx.fillStyle = bg2;
916 ctx.fill();
917 // Center text
918 ctx.fillStyle = getComputedStyle(document.documentElement).getPropertyValue('--fg').trim() || '#ccc';
919 ctx.font = (12 * dpr) + 'px "Berkeley Mono Variable", monospace';
920 ctx.textAlign = 'center'; ctx.textBaseline = 'middle';
921 ctx.fillText(fmtBytes(total), cx, cy);
922
923 // Legend
924 const legend = document.getElementById('pieLegend');
925 if (legend) {
926 legend.innerHTML = slices.map(([cat, val]) => {
927 const pct = ((val / total) * 100).toFixed(0);
928 const meta = catMeta[cat] || { label: cat, color: '#888' };
929 return '<div class="pie-legend-row">' +
930 '<span class="pie-dot" style="background:' + meta.color + '"></span>' +
931 '<span class="pie-label">' + esc(meta.label) + '</span>' +
932 '<span class="pie-val">' + pct + '%</span></div>';
933 }).join('');
934 }
935}
936
937async function loadBackups() {
938 const el = document.getElementById('backupsList');
939 if (!el) return;
940 el.innerHTML = '<span class="loading">loading...</span>';
941 try {
942 const data = await authFetch('/api/db/backups').then(r => r.json());
943 if (!data.backups || data.backups.length === 0) {
944 let html = '<div class="backup-empty">No backups found.';
945 html += '<div class="backup-detail">Local path: ' + esc(data.backupPath || '/var/backups/mongodb') + '</div>';
946 if (data.backupBucket) {
947 html += '<div class="backup-detail">S3 bucket: ' + esc(data.backupBucket) + '</div>';
948 } else {
949 html += '<div class="backup-detail">S3 bucket: not configured (set BACKUP_BUCKET)</div>';
950 }
951 html += '</div>';
952 el.innerHTML = html;
953 el.classList.remove('loading');
954 return;
955 }
956 let html = '<table class="tbl"><thead><tr><th>name</th><th>source</th><th style="text-align:right">size</th><th style="text-align:right">date</th></tr></thead><tbody>';
957 for (const b of data.backups) {
958 const date = b.date ? new Date(b.date).toLocaleString() : '-';
959 const srcBadge = b.source === 's3'
960 ? '<span style="color:var(--accent)">s3</span>'
961 : '<span style="color:var(--fg2)">local</span>';
962 html += '<tr><td>' + esc(b.name) + '</td><td>' + srcBadge + '</td><td class="r">' + fmtBytes(b.size) + '</td><td class="r">' + date + '</td></tr>';
963 }
964 html += '</tbody></table>';
965 el.innerHTML = html;
966 el.classList.remove('loading');
967 } catch (e) {
968 el.innerHTML = '<span class="loading">error loading backups</span>';
969 }
970}
971
972async function loadStorage() {
973 try {
974 const data = await authFetch('/api/storage/buckets').then(r => r.json());
975 const el = document.getElementById('storageBuckets');
976 if (!data.length) { el.textContent = 'none'; return; }
977 const maxGB = Math.max(...data.map(b => parseFloat(b.gb)), 0.01);
978 const totalGB = data.reduce((s, b) => s + parseFloat(b.gb), 0).toFixed(2);
979 document.getElementById('stoTotal').textContent = totalGB + ' GB';
980 el.innerHTML = data.map(b => {
981 const pct = (parseFloat(b.gb) / maxGB * 100).toFixed(0);
982 return '<div class="sbar"><span class="sbar-name">' + esc(b.bucket) + '</span>' +
983 '<div class="sbar-track"><div class="sbar-fill" style="width:' + pct + '%"></div></div>' +
984 '<span class="sbar-val">' + b.gb + ' GB / ' + fmt(b.objects) + '</span></div>';
985 }).join('');
986 el.classList.remove('loading');
987 } catch (e) {
988 document.getElementById('storageBuckets').textContent = 'error';
989 }
990}
991
992async function loadServices() { checkSvc('oven'); checkSvc('session'); checkSvc('feed'); }
993async function checkSvc(name) {
994 try {
995 const d = await authFetch('/api/services/' + name).then(r => r.json());
996 const ok = d.status === 'ok';
997 dot('svc-' + name + '-dot', ok);
998 dot('svc-' + name + '-dot2', ok);
999 const txt = ok ? d.responseMs + 'ms' : 'down';
1000 const cls = 'v ' + (ok ? 'ok' : 'err');
1001 for (const suffix of ['', '2']) {
1002 const el = document.getElementById('svc-' + name + '-meta' + suffix);
1003 if (el) { el.textContent = txt; el.className = cls; }
1004 }
1005 } catch (e) {
1006 dot('svc-' + name + '-dot', false);
1007 dot('svc-' + name + '-dot2', false);
1008 for (const suffix of ['', '2']) {
1009 const m = document.getElementById('svc-' + name + '-meta' + suffix);
1010 if (m) { m.textContent = 'error'; m.className = 'v err'; }
1011 }
1012 }
1013}
1014
1015async function loadBilling() {
1016 const setTiles = (html) => {
1017 const b1 = document.getElementById('billingTile');
1018 const b2 = document.getElementById('billingTile2');
1019 if (b1) b1.innerHTML = html;
1020 if (b2) b2.innerHTML = html;
1021 };
1022 try {
1023 const d = await authFetch('/api/services/billing').then(r => r.json());
1024 if (d.status !== 'ok') {
1025 setTiles('<span class="loading">unavailable</span>');
1026 document.getElementById('s-billing').textContent = '-';
1027 return;
1028 }
1029
1030 const providers = Array.isArray(d.providers) ? d.providers : [];
1031 const top = providers
1032 .filter(p => p && !p.skipped)
1033 .slice(0, 4)
1034 .map(p => p.name || p.provider)
1035 .join(', ') || 'none';
1036
1037 setTiles(
1038 kv('estimate', d.summary?.monthlyEstimate || '-') +
1039 kv('providers', fmt(providers.length)) +
1040 kv('top', esc(top)) +
1041 kv('updated', d.generated ? new Date(d.generated).toLocaleTimeString() : '-')
1042 );
1043
1044 document.getElementById('s-billing').textContent = d.summary?.monthlyEstimate || '-';
1045 } catch (e) {
1046 setTiles('<span class="loading">error</span>');
1047 document.getElementById('s-billing').textContent = '-';
1048 }
1049}
1050
1051// --- Instagram ---
1052let instaTwoFactorId = null, instaTwoFactorMethod = null;
1053
1054async function loadInstaStatus() {
1055 try {
1056 const d = await authFetch('/api/insta/status').then(r => r.json());
1057 const ok = d.loggedIn;
1058 dot('insta-dot', ok);
1059 dot('svc-insta-dot', ok);
1060 const el = document.getElementById('insta-status');
1061 const metaEl = document.getElementById('svc-insta-meta');
1062 if (ok) {
1063 el.textContent = 'active'; el.className = 'v ok';
1064 if (metaEl) { metaEl.textContent = '@' + (d.username || '?'); metaEl.className = 'v ok'; }
1065 document.getElementById('instaLoginBtn').style.display = 'none';
1066 document.getElementById('instaLogoutBtn').style.display = '';
1067 } else if (d.challengeInProgress) {
1068 el.textContent = 'challenge'; el.className = 'v warn';
1069 if (metaEl) { metaEl.textContent = 'challenge'; metaEl.className = 'v warn'; }
1070 document.getElementById('insta-challenge').style.display = '';
1071 } else {
1072 el.textContent = 'not logged in'; el.className = 'v err';
1073 if (metaEl) { metaEl.textContent = 'offline'; metaEl.className = 'v err'; }
1074 document.getElementById('instaLoginBtn').style.display = '';
1075 document.getElementById('instaLogoutBtn').style.display = 'none';
1076 }
1077 document.getElementById('insta-account').textContent = d.username ? '@' + d.username : '-';
1078 document.getElementById('insta-age').textContent = d.sessionAge != null ? fmtDuration(d.sessionAge) : '-';
1079 } catch (e) {
1080 dot('insta-dot', false);
1081 dot('svc-insta-dot', false);
1082 const metaEl = document.getElementById('svc-insta-meta');
1083 if (metaEl) { metaEl.textContent = 'error'; metaEl.className = 'v err'; }
1084 }
1085}
1086
1087document.getElementById('instaLoginBtn').onclick = async () => {
1088 const msg = document.getElementById('insta-msg');
1089 msg.textContent = 'logging in...';
1090 try {
1091 const resp = await fetch('/api/insta/login', {
1092 method: 'POST',
1093 headers: {
1094 'Content-Type': 'application/json',
1095 ...(accessToken ? { Authorization: 'Bearer ' + accessToken } : {}),
1096 },
1097 });
1098 const d = await resp.json();
1099 if (d.ok) {
1100 msg.textContent = 'logged in!';
1101 loadInstaStatus();
1102 } else if (d.challenge) {
1103 msg.textContent = d.message || 'check email/SMS';
1104 document.getElementById('insta-challenge').style.display = '';
1105 instaTwoFactorId = null;
1106 } else if (d.twoFactor) {
1107 msg.textContent = d.message || 'enter 2FA code';
1108 document.getElementById('insta-challenge').style.display = '';
1109 instaTwoFactorId = d.twoFactorIdentifier;
1110 instaTwoFactorMethod = d.method;
1111 } else {
1112 msg.textContent = d.error || 'failed';
1113 }
1114 } catch (e) {
1115 msg.textContent = 'error: ' + e.message;
1116 }
1117};
1118
1119document.getElementById('instaVerifyBtn').onclick = async () => {
1120 const code = document.getElementById('instaCode').value.trim();
1121 if (!code) return;
1122 const msg = document.getElementById('insta-msg');
1123 msg.textContent = 'verifying...';
1124 try {
1125 const body = { code };
1126 if (instaTwoFactorId) {
1127 body.twoFactorIdentifier = instaTwoFactorId;
1128 body.method = instaTwoFactorMethod;
1129 }
1130 const resp = await fetch('/api/insta/challenge', {
1131 method: 'POST',
1132 headers: {
1133 'Content-Type': 'application/json',
1134 ...(accessToken ? { Authorization: 'Bearer ' + accessToken } : {}),
1135 },
1136 body: JSON.stringify(body),
1137 });
1138 const d = await resp.json();
1139 if (d.ok) {
1140 msg.textContent = 'verified!';
1141 document.getElementById('insta-challenge').style.display = 'none';
1142 instaTwoFactorId = null;
1143 loadInstaStatus();
1144 } else {
1145 msg.textContent = d.error || 'verification failed';
1146 }
1147 } catch (e) {
1148 msg.textContent = 'error: ' + e.message;
1149 }
1150};
1151
1152document.getElementById('instaLogoutBtn').onclick = async () => {
1153 const msg = document.getElementById('insta-msg');
1154 msg.textContent = 'logging out...';
1155 try {
1156 await fetch('/api/insta/logout', {
1157 method: 'POST',
1158 headers: accessToken ? { Authorization: 'Bearer ' + accessToken } : {},
1159 });
1160 msg.textContent = '';
1161 loadInstaStatus();
1162 } catch (e) {
1163 msg.textContent = 'error: ' + e.message;
1164 }
1165};
1166
1167// --- TikTok ---
1168async function loadTiktokStatus() {
1169 try {
1170 const d = await authFetch('/api/tiktok/status').then(r => r.json());
1171 const ok = d.connected;
1172 dot('tiktok-dot', ok);
1173 dot('svc-tiktok-dot', ok);
1174 const el = document.getElementById('tiktok-status');
1175 const metaEl = document.getElementById('svc-tiktok-meta');
1176 if (ok) {
1177 el.textContent = 'connected'; el.className = 'v ok';
1178 if (metaEl) { metaEl.textContent = '@' + (d.username || '?'); metaEl.className = 'v ok'; }
1179 document.getElementById('tiktokConnectBtn').style.display = 'none';
1180 document.getElementById('tiktokDisconnectBtn').style.display = '';
1181 document.getElementById('tiktokRefreshBtn').style.display = '';
1182 // Fetch live stats
1183 try {
1184 const profile = await fetch('/tiktok?action=profile').then(r => r.json());
1185 if (profile.followerCount != null) {
1186 document.getElementById('tiktok-stats').style.display = '';
1187 document.getElementById('tiktok-followers').textContent = profile.followerCountFormatted;
1188 document.getElementById('tiktok-likes').textContent = profile.likesCountFormatted;
1189 document.getElementById('tiktok-videos').textContent = profile.videoCountFormatted;
1190 }
1191 } catch {}
1192 } else {
1193 el.textContent = 'not connected'; el.className = 'v err';
1194 if (metaEl) { metaEl.textContent = 'offline'; metaEl.className = 'v err'; }
1195 document.getElementById('tiktokConnectBtn').style.display = '';
1196 document.getElementById('tiktokDisconnectBtn').style.display = 'none';
1197 document.getElementById('tiktokRefreshBtn').style.display = 'none';
1198 document.getElementById('tiktok-stats').style.display = 'none';
1199 }
1200 document.getElementById('tiktok-account').textContent = d.username ? '@' + d.username : '-';
1201 document.getElementById('tiktok-age').textContent = d.sessionAge != null ? fmtDuration(d.sessionAge) : '-';
1202 document.getElementById('tiktok-expires').textContent = d.expiresAt ? new Date(d.expiresAt).toLocaleString() : '-';
1203 } catch (e) {
1204 dot('tiktok-dot', false);
1205 dot('svc-tiktok-dot', false);
1206 const metaEl = document.getElementById('svc-tiktok-meta');
1207 if (metaEl) { metaEl.textContent = 'error'; metaEl.className = 'v err'; }
1208 }
1209}
1210
1211document.getElementById('tiktokConnectBtn').onclick = async () => {
1212 const msg = document.getElementById('tiktok-msg');
1213 msg.textContent = 'redirecting...';
1214 try {
1215 const d = await authFetch('/api/tiktok/auth').then(r => r.json());
1216 if (d.url) {
1217 const popup = window.open(d.url, 'tiktok-auth', 'width=600,height=700');
1218 // Poll for popup close
1219 const check = setInterval(() => {
1220 if (!popup || popup.closed) {
1221 clearInterval(check);
1222 msg.textContent = '';
1223 loadTiktokStatus();
1224 }
1225 }, 1000);
1226 } else {
1227 msg.textContent = d.error || 'failed to get auth URL';
1228 }
1229 } catch (e) {
1230 msg.textContent = 'error: ' + e.message;
1231 }
1232};
1233
1234document.getElementById('tiktokDisconnectBtn').onclick = async () => {
1235 const msg = document.getElementById('tiktok-msg');
1236 msg.textContent = 'disconnecting...';
1237 try {
1238 await fetch('/api/tiktok/disconnect', {
1239 method: 'POST',
1240 headers: accessToken ? { Authorization: 'Bearer ' + accessToken } : {},
1241 });
1242 msg.textContent = '';
1243 loadTiktokStatus();
1244 } catch (e) {
1245 msg.textContent = 'error: ' + e.message;
1246 }
1247};
1248
1249document.getElementById('tiktokRefreshBtn').onclick = async () => {
1250 const msg = document.getElementById('tiktok-msg');
1251 msg.textContent = 'refreshing...';
1252 try {
1253 const d = await authFetch('/api/tiktok/refresh', { method: 'POST' }).then(r => r.json());
1254 msg.textContent = d.ok ? 'refreshed!' : 'refresh failed';
1255 loadTiktokStatus();
1256 } catch (e) {
1257 msg.textContent = 'error: ' + e.message;
1258 }
1259};
1260
1261// log (append at bottom, auto-scroll when near bottom)
1262function addLog(entry) {
1263 logCount++;
1264 document.getElementById('logCount').textContent = logCount;
1265 const el = document.getElementById('logEntries');
1266 const atBottom = el.scrollHeight - el.scrollTop - el.clientHeight < 40;
1267 const div = document.createElement('div');
1268 div.className = 'log-row';
1269 const t = new Date(entry.time);
1270 const ts = String(t.getHours()).padStart(2,'0') + ':' + String(t.getMinutes()).padStart(2,'0') + ':' + String(t.getSeconds()).padStart(2,'0');
1271 div.innerHTML = '<span class="log-t">' + ts + '</span><span class="log-m ' + (entry.type || '') + '">' + esc(entry.msg) + '</span>';
1272 el.appendChild(div);
1273 while (el.children.length > 200) el.removeChild(el.firstChild);
1274 if (atBottom) el.scrollTop = el.scrollHeight;
1275}
1276
1277// util
1278function fmt(n) { return n == null ? '-' : Number(n).toLocaleString(); }
1279function esc(t) { const d = document.createElement('div'); d.textContent = t; return d.innerHTML; }
1280function kv(k, v) { return '<div class="kv"><span class="k">' + k + '</span><span class="v">' + v + '</span></div>'; }
1281function fmtBytes(b) {
1282 if (b == null) return '-';
1283 if (b > 1e9) return (b/1e9).toFixed(2) + ' GB';
1284 if (b > 1e6) return (b/1e6).toFixed(1) + ' MB';
1285 if (b > 1e3) return (b/1e3).toFixed(0) + ' KB';
1286 return b + ' B';
1287}
1288function fmtDuration(s) {
1289 if (!s) return '-';
1290 const d = Math.floor(s/86400), h = Math.floor((s%86400)/3600), m = Math.floor((s%3600)/60);
1291 if (d > 0) return d + 'd ' + h + 'h';
1292 if (h > 0) return h + 'h ' + m + 'm';
1293 return m + 'm';
1294}
1295
1296document.getElementById('logo').onclick = () => {
1297 location.href = location.host === 'silo.aesthetic.computer' ? 'https://aesthetic.computer' : '/';
1298};
1299
1300// --- Firehose Visualization ---
1301const FIREHOSE_COLORS = {
1302 'chat-system': '#4a4', 'chat-clock': '#4c4', 'chat-sotce': '#6a4',
1303 'users': '#48f', '@handles': '#68c', 'verifications': '#cc4',
1304 'paintings': '#a6f', 'pieces': '#c6a', 'kidlisp': '#fa4',
1305 'moods': '#f8a', 'tapes': '#f80',
1306};
1307const FH_DEFAULT_COLOR = '#888';
1308const OP_SYM = { insert: '+', update: '~', replace: '=', delete: '-' };
1309
1310const fh = {
1311 particles: [], ambient: [], fireflies: [], canvas: null, ctx: null, running: false,
1312 totalEvents: 0, eventsThisSec: 0, eps: 0, lastRateReset: Date.now(),
1313 MAX_P: 200, MAX_AMB: 30, MAX_FLY: 60,
1314 counters: {},
1315};
1316
1317const FH_URL_COLOR = '#0ee';
1318const FH_TS_COLOR = '#777';
1319const FH_URL_RE = /(https?:\/\/[^\s"'<>]+|[a-z0-9-]+\.[a-z]{2,}(?:\/[^\s"'<>]*))/gi;
1320
1321function fhLabelSegments(label, baseColor) {
1322 const segs = [];
1323 let last = 0, m;
1324 FH_URL_RE.lastIndex = 0;
1325 while ((m = FH_URL_RE.exec(label)) !== null) {
1326 if (m.index > last) segs.push({ text: label.slice(last, m.index), color: baseColor });
1327 segs.push({ text: m[0], color: FH_URL_COLOR });
1328 last = FH_URL_RE.lastIndex;
1329 }
1330 if (last < label.length) segs.push({ text: label.slice(last), color: baseColor });
1331 return segs;
1332}
1333
1334function fhTimestamp(time) {
1335 return new Date(time).toLocaleTimeString('en-US', {
1336 timeZone: 'America/Los_Angeles',
1337 hour: '2-digit', minute: '2-digit', second: '2-digit', hour12: false,
1338 });
1339}
1340
1341function fhParticle(ev) {
1342 const c = fh.canvas; if (!c) return null;
1343 const dpr = window.devicePixelRatio || 1;
1344 const w = c.width / dpr, h = c.height / dpr;
1345 const color = FIREHOSE_COLORS[ev.ns] || FH_DEFAULT_COLOR;
1346 const ts = fhTimestamp(ev.time);
1347 const body = (OP_SYM[ev.op] || '?') + ' ' + ev.ns + (ev.summary ? ' ' + ev.summary : '');
1348 const bodySegs = fhLabelSegments(body, color);
1349 const segments = [{ text: ts + ' ', color: FH_TS_COLOR }, ...bodySegs];
1350 return {
1351 x: 40 + Math.random() * 60,
1352 y: 40 + Math.random() * (h - 80),
1353 vx: 0.5 + Math.random() * 1.5,
1354 vy: (Math.random() - 0.5) * 0.3,
1355 color,
1356 segments,
1357 label: ts + ' ' + body,
1358 alpha: 1, size: 4 + Math.random() * 3,
1359 life: 5000 + Math.random() * 3000, born: Date.now(),
1360 };
1361}
1362
1363function fhAmbient() {
1364 const c = fh.canvas; if (!c) return null;
1365 const dpr = window.devicePixelRatio || 1;
1366 return {
1367 x: Math.random() * (c.width / dpr),
1368 y: Math.random() * (c.height / dpr),
1369 vx: (Math.random() - 0.5) * 0.2,
1370 vy: (Math.random() - 0.5) * 0.15,
1371 alpha: 0.08 + Math.random() * 0.12,
1372 size: 1 + Math.random() * 2,
1373 color: darkMode ? '#444' : '#ccc',
1374 life: 8000 + Math.random() * 6000, born: Date.now(),
1375 };
1376}
1377
1378const FIREFLY_COLORS = ['#fa4', '#fc6', '#f80', '#fe8', '#fd4', '#ec6'];
1379
1380function fhFirefly(point) {
1381 if (!fh.canvas) return;
1382 const dpr = window.devicePixelRatio || 1;
1383 const w = fh.canvas.width / dpr, h = fh.canvas.height / dpr;
1384 const nx = Math.max(0, Math.min(1, point.x));
1385 const ny = Math.max(0, Math.min(1, point.y));
1386 if (fh.fireflies.length >= fh.MAX_FLY) fh.fireflies.shift();
1387 fh.fireflies.push({
1388 x: nx * w, y: ny * h,
1389 vx: (Math.random() - 0.5) * 0.3, vy: (Math.random() - 0.5) * 0.3,
1390 alpha: 0.9, size: 2 + Math.random() * 2,
1391 color: FIREFLY_COLORS[Math.floor(Math.random() * FIREFLY_COLORS.length)],
1392 life: 1500 + Math.random() * 1000, born: Date.now(),
1393 pulsePhase: Math.random() * Math.PI * 2,
1394 });
1395}
1396
1397function fhIngest(ev) {
1398 fh.totalEvents++;
1399 fh.eventsThisSec++;
1400 if (!fh.counters[ev.ns]) fh.counters[ev.ns] = {};
1401 fh.counters[ev.ns][ev.op] = (fh.counters[ev.ns][ev.op] || 0) + 1;
1402 const el = document.getElementById('firehoseCount');
1403 if (el) el.textContent = fh.totalEvents.toLocaleString();
1404 fhUpdateCounters();
1405 if (fh.particles.length >= fh.MAX_P) fh.particles.shift();
1406 const p = fhParticle(ev);
1407 if (p) fh.particles.push(p);
1408}
1409
1410function fhUpdateCounters() {
1411 const el = document.getElementById('firehoseCounters');
1412 if (!el) return;
1413 const entries = Object.entries(fh.counters)
1414 .map(([ns, ops]) => {
1415 const total = Object.values(ops).reduce((s, n) => s + n, 0);
1416 return { ns, total, ops };
1417 })
1418 .sort((a, b) => b.total - a.total);
1419 el.innerHTML = entries.map(e => {
1420 const color = FIREHOSE_COLORS[e.ns] || FH_DEFAULT_COLOR;
1421 const parts = Object.entries(e.ops).map(([op, n]) => (OP_SYM[op] || op) + n).join(' ');
1422 return '<div class="fh-counter-row">' +
1423 '<span class="fh-counter-dot" style="background:' + color + '"></span>' +
1424 '<span class="fh-counter-ns">' + e.ns + '</span>' +
1425 '<span class="fh-counter-val">' + e.total + '</span>' +
1426 '<span class="fh-counter-ops">' + parts + '</span></div>';
1427 }).join('');
1428}
1429
1430function fhTick() {
1431 if (!fh.running) return;
1432 requestAnimationFrame(fhTick);
1433 const { canvas, ctx, particles, ambient } = fh;
1434 if (!canvas || !ctx) return;
1435 const now = Date.now();
1436 const dpr = window.devicePixelRatio || 1;
1437
1438 if (now - fh.lastRateReset >= 1000) {
1439 fh.eps = fh.eventsThisSec; fh.eventsThisSec = 0; fh.lastRateReset = now;
1440 const rEl = document.getElementById('firehoseRate');
1441 if (rEl) rEl.textContent = fh.eps + '/s';
1442 }
1443
1444 ctx.clearRect(0, 0, canvas.width, canvas.height);
1445 ctx.fillStyle = darkMode ? '#111' : '#f4f4f4';
1446 ctx.fillRect(0, 0, canvas.width, canvas.height);
1447
1448 while (ambient.length < fh.MAX_AMB) { const a = fhAmbient(); if (a) ambient.push(a); }
1449 for (let i = ambient.length - 1; i >= 0; i--) {
1450 const p = ambient[i];
1451 const age = now - p.born;
1452 if (age > p.life) { ambient.splice(i, 1); continue; }
1453 p.x += p.vx; p.y += p.vy;
1454 const prog = age / p.life;
1455 const fade = prog < 0.1 ? prog / 0.1 : prog > 0.8 ? (1 - prog) / 0.2 : 1;
1456 ctx.globalAlpha = p.alpha * fade;
1457 ctx.beginPath();
1458 ctx.arc(p.x * dpr, p.y * dpr, p.size * dpr, 0, Math.PI * 2);
1459 ctx.fillStyle = p.color;
1460 ctx.fill();
1461 }
1462
1463 // Fireflies (fairy:point cursors from session server)
1464 for (let i = fh.fireflies.length - 1; i >= 0; i--) {
1465 const f = fh.fireflies[i];
1466 const age = now - f.born;
1467 if (age > f.life) { fh.fireflies.splice(i, 1); continue; }
1468 f.x += f.vx; f.y += f.vy;
1469 const prog = age / f.life;
1470 const fade = prog < 0.05 ? prog / 0.05 : prog > 0.6 ? (1 - prog) / 0.4 : 1;
1471 const pulse = 0.7 + 0.3 * Math.sin(age * 0.008 + f.pulsePhase);
1472 const alpha = f.alpha * fade * pulse;
1473 // Outer glow
1474 ctx.globalAlpha = alpha * 0.3;
1475 ctx.beginPath();
1476 ctx.arc(f.x * dpr, f.y * dpr, (f.size * 2.5) * dpr, 0, Math.PI * 2);
1477 ctx.fillStyle = f.color;
1478 ctx.fill();
1479 // Inner core
1480 ctx.globalAlpha = alpha;
1481 ctx.beginPath();
1482 ctx.arc(f.x * dpr, f.y * dpr, f.size * dpr, 0, Math.PI * 2);
1483 ctx.fillStyle = f.color;
1484 ctx.fill();
1485 }
1486
1487 ctx.textBaseline = 'middle';
1488 ctx.font = (11 * dpr) + 'px "Berkeley Mono Variable", monospace';
1489 for (let i = particles.length - 1; i >= 0; i--) {
1490 const p = particles[i];
1491 const age = now - p.born;
1492 if (age > p.life) { particles.splice(i, 1); continue; }
1493 p.x += p.vx; p.y += p.vy;
1494 const prog = age / p.life;
1495 p.alpha = prog < 0.05 ? prog / 0.05 : 1 - prog * prog;
1496 if (p.alpha <= 0) { particles.splice(i, 1); continue; }
1497 ctx.globalAlpha = p.alpha;
1498 ctx.beginPath();
1499 ctx.arc(p.x * dpr, p.y * dpr, p.size * dpr, 0, Math.PI * 2);
1500 ctx.fillStyle = p.color;
1501 ctx.fill();
1502 let textX = (p.x + p.size + 6) * dpr;
1503 const textY = p.y * dpr;
1504 if (p.segments) {
1505 for (const seg of p.segments) {
1506 ctx.globalAlpha = p.alpha * 0.85;
1507 ctx.fillStyle = seg.color;
1508 ctx.fillText(seg.text, textX, textY);
1509 textX += ctx.measureText(seg.text).width;
1510 }
1511 } else {
1512 ctx.globalAlpha = p.alpha * 0.85;
1513 ctx.fillStyle = p.color;
1514 ctx.fillText(p.label, textX, textY);
1515 }
1516 }
1517 ctx.globalAlpha = 1;
1518}
1519
1520function fhResize() {
1521 const c = fh.canvas; if (!c) return;
1522 const panel = c.parentElement; if (!panel) return;
1523 const dpr = window.devicePixelRatio || 1;
1524 const rect = panel.getBoundingClientRect();
1525 c.width = rect.width * dpr;
1526 c.height = rect.height * dpr;
1527 c.style.width = rect.width + 'px';
1528 c.style.height = rect.height + 'px';
1529}
1530
1531function fhInit() {
1532 fh.canvas = document.getElementById('firehoseCanvas');
1533 if (!fh.canvas) return;
1534 fh.ctx = fh.canvas.getContext('2d');
1535 fhResize();
1536 window.addEventListener('resize', fhResize);
1537}
1538function fhStart() { if (fh.running) return; fh.running = true; fhResize(); fhTick(); }
1539function fhStop() { fh.running = false; }
1540
1541// --- tabs (always visible, persisted) ---
1542let activeTab = parseInt(localStorage.getItem('silo-tab') || '0');
1543const tabBtns = document.querySelectorAll('.tab-btn');
1544const panels = document.querySelectorAll('[data-panel]');
1545
1546function setTab(idx) {
1547 activeTab = idx;
1548 localStorage.setItem('silo-tab', idx);
1549 tabBtns.forEach((b, i) => b.classList.toggle('active', i === idx));
1550 panels.forEach(p => p.classList.toggle('active', parseInt(p.dataset.panel) === idx));
1551 if (idx === 4) {
1552 const el = document.getElementById('logEntries');
1553 requestAnimationFrame(() => el.scrollTop = el.scrollHeight);
1554 }
1555 if (idx === 5) { fhStart(); } else { fhStop(); }
1556 if (idx === 6 && !feedLoaded) { loadFeed(); }
1557 if (idx === 7 && !telemetryLoaded) { loadTelemetry(); }
1558}
1559tabBtns.forEach(b => b.addEventListener('click', () => setTab(parseInt(b.dataset.tab))));
1560setTab(activeTab);
1561
1562// --- data sub-tabs ---
1563let backupsLoaded = false;
1564const subTabBtns = document.querySelectorAll('.sub-tab-btn');
1565const subPanels = document.querySelectorAll('.sub-panel');
1566subTabBtns.forEach(btn => {
1567 btn.addEventListener('click', () => {
1568 const target = btn.dataset.subtab;
1569 subTabBtns.forEach(b => b.classList.toggle('active', b.dataset.subtab === target));
1570 subPanels.forEach(p => p.classList.toggle('active', p.dataset.subpanel === target));
1571 if (target === 'backups' && !backupsLoaded) { backupsLoaded = true; loadBackups(); }
1572 if (target === 'browse' && !browseLoaded) { browseLoaded = true; loadBrowseCollections(); }
1573 });
1574});
1575
1576// --- sync button ---
1577document.getElementById('syncBtn').onclick = async () => {
1578 if (!confirm('Sync all collections from Atlas to primary?\nThis will overwrite primary data.')) return;
1579 const btn = document.getElementById('syncBtn');
1580 btn.disabled = true; btn.textContent = 'syncing...';
1581 try {
1582 const resp = await fetch('/api/db/sync', {
1583 method: 'POST',
1584 headers: accessToken ? { Authorization: 'Bearer ' + accessToken, 'Content-Type': 'application/json' } : {},
1585 });
1586 const data = await resp.json();
1587 if (data.ok) {
1588 btn.textContent = 'done (' + data.collections.length + ' collections)';
1589 loadCompare(); loadOverview(); loadCollections();
1590 setTimeout(() => { btn.textContent = 'sync from atlas'; btn.disabled = false; }, 4000);
1591 } else {
1592 alert('Sync failed: ' + (data.error || 'unknown'));
1593 btn.textContent = 'sync from atlas'; btn.disabled = false;
1594 }
1595 } catch (e) {
1596 alert('Sync failed: ' + e.message);
1597 btn.textContent = 'sync from atlas'; btn.disabled = false;
1598 }
1599};
1600
1601// --- feed tab ---
1602// --- telemetry tab ---
1603let telemetryLoaded = false;
1604async function loadTelemetry() {
1605 telemetryLoaded = true;
1606 try {
1607 const data = await authFetch('/api/telemetry').then(r => r.json());
1608 const kl = data.kidlispLogs || {};
1609 const bt = data.boots || {};
1610
1611 document.getElementById('tl-kl-total').textContent = (kl.total || 0).toLocaleString();
1612 document.getElementById('tl-kl-size').textContent = kl.estimatedSizeKB ? kl.estimatedSizeKB.toLocaleString() + ' KB' : '0 KB';
1613 document.getElementById('tl-kl-types').textContent = (kl.byType || []).map(t => `${t._id || 'unknown'}: ${t.count}`).join('\n') || 'none';
1614 document.getElementById('tl-kl-effects').textContent = (kl.byEffect || []).filter(e => e._id).map(e => `${e._id}: ${e.count}`).join('\n') || 'none';
1615 document.getElementById('tl-kl-gpus').textContent = (kl.byGpu || []).filter(g => g._id).map(g => `${g._id}: ${g.count}`).join('\n') || 'none';
1616
1617 document.getElementById('tl-bt-total').textContent = (bt.total || 0).toLocaleString();
1618 document.getElementById('tl-bt-status').textContent = (bt.byStatus || []).map(s => `${s._id || 'unknown'}: ${s.count}`).join('\n') || 'none';
1619
1620 // Recent events
1621 const recent = kl.recent || [];
1622 if (recent.length === 0) {
1623 document.getElementById('tl-recent').textContent = 'no events yet';
1624 } else {
1625 const rows = recent.map(e => {
1626 const when = e.createdAt ? new Date(e.createdAt).toLocaleString() : '?';
1627 const gpu = e.device?.gpu?.renderer || 'unknown GPU';
1628 const ua = e.device?.mobile ? 'mobile' : 'desktop';
1629 const country = e.server?.country || '';
1630 return `<div style="padding:2px 0;border-bottom:1px solid var(--border)">` +
1631 `<b>${e.type || '?'}</b> ${e.effect || ''} — ${gpu} (${ua}) ${country} <span style="color:var(--fg3)">${when}</span>` +
1632 (e.gpuStatus?.disabled ? `<br><span style="color:#f80">disabled: ${Object.keys(e.gpuStatus.disabled).filter(k => e.gpuStatus.disabled[k]).join(', ')}</span>` : '') +
1633 `</div>`;
1634 }).join('');
1635 document.getElementById('tl-recent').innerHTML = rows;
1636 }
1637 } catch (err) {
1638 document.getElementById('tl-recent').textContent = 'error: ' + err.message;
1639 }
1640}
1641
1642async function purgeKidlispLogs() {
1643 if (!confirm('Delete all kidlisp-logs? This cannot be undone.')) return;
1644 try {
1645 const res = await authFetch('/api/telemetry/kidlisp-logs', { method: 'DELETE' }).then(r => r.json());
1646 alert(`Purged ${res.deleted || 0} logs`);
1647 telemetryLoaded = false;
1648 loadTelemetry();
1649 } catch (err) {
1650 alert('Purge failed: ' + err.message);
1651 }
1652}
1653
1654let feedLoaded = false;
1655async function loadFeed() {
1656 feedLoaded = true;
1657 try {
1658 const info = await authFetch('/api/services/feed/info').then(r => r.json());
1659 if (info.status === 'ok') {
1660 document.getElementById('feed-status').textContent = 'online';
1661 document.getElementById('feed-status').className = 'v ok';
1662 document.getElementById('feed-version').textContent = info.version || '-';
1663 document.getElementById('feed-deployment').textContent = info.deployment || '-';
1664 document.getElementById('feed-runtime-val').textContent = info.runtime || '-';
1665 document.getElementById('feed-response').textContent = info.responseMs + 'ms';
1666 document.getElementById('feed-response').className = 'v ok';
1667 const eps = info.endpoints;
1668 if (eps) {
1669 document.getElementById('feed-endpoints').innerHTML = Object.entries(eps)
1670 .map(([k, v]) => '<div class="kv"><span class="k">' + esc(k) + '</span><span class="v">' + esc(v) + '</span></div>')
1671 .join('');
1672 }
1673 } else {
1674 document.getElementById('feed-status').textContent = 'unreachable';
1675 document.getElementById('feed-status').className = 'v err';
1676 }
1677 } catch (e) {
1678 document.getElementById('feed-status').textContent = 'error';
1679 document.getElementById('feed-status').className = 'v err';
1680 }
1681
1682 // Load playlists
1683 try {
1684 const data = await authFetch('/api/services/feed/playlists').then(r => r.json());
1685 const el = document.getElementById('feed-playlists');
1686 if (data.items && data.items.length > 0) {
1687 document.getElementById('feed-playlist-count').textContent = data.items.length;
1688 el.innerHTML = '<table class="tbl"><thead><tr><th>title</th><th>slug</th><th>items</th><th>created</th></tr></thead><tbody>' +
1689 data.items.map(p => '<tr><td>' + esc(p.title || '-') + '</td><td>' + esc(p.slug || '-') + '</td><td class="r">' + (p.items?.length || 0) + '</td><td>' + (p.created ? new Date(p.created).toLocaleDateString() : '-') + '</td></tr>').join('') +
1690 '</tbody></table>';
1691 } else {
1692 document.getElementById('feed-playlist-count').textContent = '0';
1693 el.innerHTML = '<span style="color:var(--fg2)">no playlists</span>';
1694 }
1695 el.classList.remove('loading');
1696 } catch (e) {
1697 document.getElementById('feed-playlists').innerHTML = '<span class="loading">error</span>';
1698 }
1699
1700 // Load channels
1701 try {
1702 const data = await authFetch('/api/services/feed/channels').then(r => r.json());
1703 const el = document.getElementById('feed-channels');
1704 if (data.items && data.items.length > 0) {
1705 document.getElementById('feed-channel-count').textContent = data.items.length;
1706 el.innerHTML = '<table class="tbl"><thead><tr><th>title</th><th>slug</th><th>playlists</th><th>created</th></tr></thead><tbody>' +
1707 data.items.map(c => '<tr><td>' + esc(c.title || '-') + '</td><td>' + esc(c.slug || '-') + '</td><td class="r">' + (c.playlists?.length || 0) + '</td><td>' + (c.created ? new Date(c.created).toLocaleDateString() : '-') + '</td></tr>').join('') +
1708 '</tbody></table>';
1709 } else {
1710 document.getElementById('feed-channel-count').textContent = '0';
1711 el.innerHTML = '<span style="color:var(--fg2)">no channels</span>';
1712 }
1713 el.classList.remove('loading');
1714 } catch (e) {
1715 document.getElementById('feed-channels').innerHTML = '<span class="loading">error</span>';
1716 }
1717}
1718
1719// --- Database Browser ---
1720let browseState = { collection: null, skip: 0, limit: 25, total: 0, q: '', docs: [], rawDoc: null };
1721
1722async function loadBrowseCollections() {
1723 const el = document.getElementById('browseCollList');
1724 if (!el) return;
1725 try {
1726 const resp = await authFetch('/api/db/collections').then(r => r.json());
1727 const cols = resp.collections || resp;
1728 el.innerHTML = cols.map(c =>
1729 '<div class="browse-coll" data-coll="' + esc(c.name) + '">' +
1730 esc(c.name) + '<span class="browse-coll-count">' + fmt(c.count) + '</span></div>'
1731 ).join('');
1732 el.classList.remove('loading');
1733 el.querySelectorAll('.browse-coll').forEach(item => {
1734 item.onclick = () => {
1735 browseState.collection = item.dataset.coll;
1736 browseState.skip = 0;
1737 browseState.q = '';
1738 document.getElementById('browseSearch').value = '';
1739 el.querySelectorAll('.browse-coll').forEach(x => x.classList.remove('active'));
1740 item.classList.add('active');
1741 loadBrowseDocs();
1742 };
1743 });
1744 } catch (e) {
1745 el.innerHTML = '<span class="loading">error</span>';
1746 }
1747}
1748
1749async function loadBrowseDocs() {
1750 const { collection, skip, limit, q } = browseState;
1751 if (!collection) return;
1752 document.getElementById('browseCollName').textContent = collection;
1753 const el = document.getElementById('browseDocs');
1754 el.innerHTML = '<span class="loading">loading...</span>';
1755 try {
1756 const params = new URLSearchParams({ skip, limit, sort: '_id', dir: 'desc' });
1757 if (q) params.set('q', q);
1758 const data = await authFetch('/api/db/browse/' + encodeURIComponent(collection) + '?' + params).then(r => r.json());
1759 browseState.total = data.total;
1760 browseState.docs = data.docs;
1761
1762 if (data.docs.length === 0) {
1763 el.innerHTML = '<span style="color:var(--fg2)">no documents' + (q ? ' matching "' + esc(q) + '"' : '') + '</span>';
1764 } else {
1765 // Discover columns from first few docs
1766 const keys = new Set();
1767 data.docs.slice(0, 10).forEach(d => Object.keys(d).forEach(k => keys.add(k)));
1768 const cols = ['_id', ...([...keys].filter(k => k !== '_id').slice(0, 5))];
1769
1770 let html = '<table class="tbl"><thead><tr>';
1771 for (const c of cols) html += '<th>' + esc(c) + '</th>';
1772 html += '</tr></thead><tbody>';
1773 for (const doc of data.docs) {
1774 html += '<tr class="browse-row" data-id="' + esc(String(doc._id)) + '">';
1775 for (const c of cols) {
1776 let val = doc[c];
1777 if (val === undefined || val === null) val = '';
1778 else if (typeof val === 'object') val = JSON.stringify(val).slice(0, 60);
1779 else val = String(val).slice(0, 60);
1780 html += '<td>' + esc(val) + '</td>';
1781 }
1782 html += '</tr>';
1783 }
1784 html += '</tbody></table>';
1785 el.innerHTML = html;
1786
1787 el.querySelectorAll('.browse-row').forEach(row => {
1788 row.onclick = () => openBrowseDoc(row.dataset.id);
1789 });
1790 }
1791
1792 // Pager
1793 const rangeEl = document.getElementById('browseRange');
1794 rangeEl.textContent = (skip + 1) + '-' + Math.min(skip + limit, data.total) + ' of ' + fmt(data.total);
1795 document.getElementById('browsePrev').disabled = skip === 0;
1796 document.getElementById('browseNext').disabled = skip + limit >= data.total;
1797 } catch (e) {
1798 el.innerHTML = '<span class="loading">error: ' + esc(e.message) + '</span>';
1799 }
1800}
1801
1802async function openBrowseDoc(id) {
1803 const detail = document.getElementById('browseDocDetail');
1804 const jsonEl = document.getElementById('browseDocJson');
1805 const idEl = document.getElementById('browseDocId');
1806 detail.style.display = 'block';
1807 idEl.textContent = id;
1808 jsonEl.textContent = 'loading...';
1809 jsonEl.contentEditable = 'false';
1810 jsonEl.classList.remove('editing');
1811 document.getElementById('browseDocEdit').style.display = '';
1812 document.getElementById('browseDocSave').style.display = 'none';
1813 document.getElementById('browseDocCancel').style.display = 'none';
1814
1815 try {
1816 const doc = await authFetch('/api/db/browse/' + encodeURIComponent(browseState.collection) + '/' + encodeURIComponent(id)).then(r => r.json());
1817 browseState.rawDoc = doc;
1818 jsonEl.textContent = JSON.stringify(doc, null, 2);
1819 } catch (e) {
1820 jsonEl.textContent = 'error: ' + e.message;
1821 }
1822}
1823
1824document.getElementById('browseDocEdit').onclick = () => {
1825 const jsonEl = document.getElementById('browseDocJson');
1826 jsonEl.contentEditable = 'true';
1827 jsonEl.classList.add('editing');
1828 jsonEl.focus();
1829 document.getElementById('browseDocEdit').style.display = 'none';
1830 document.getElementById('browseDocSave').style.display = '';
1831 document.getElementById('browseDocCancel').style.display = '';
1832};
1833
1834document.getElementById('browseDocCancel').onclick = () => {
1835 const jsonEl = document.getElementById('browseDocJson');
1836 jsonEl.contentEditable = 'false';
1837 jsonEl.classList.remove('editing');
1838 if (browseState.rawDoc) jsonEl.textContent = JSON.stringify(browseState.rawDoc, null, 2);
1839 document.getElementById('browseDocEdit').style.display = '';
1840 document.getElementById('browseDocSave').style.display = 'none';
1841 document.getElementById('browseDocCancel').style.display = 'none';
1842};
1843
1844document.getElementById('browseDocSave').onclick = async () => {
1845 const jsonEl = document.getElementById('browseDocJson');
1846 const id = document.getElementById('browseDocId').textContent;
1847 let parsed;
1848 try {
1849 parsed = JSON.parse(jsonEl.textContent);
1850 } catch (e) {
1851 alert('Invalid JSON: ' + e.message);
1852 return;
1853 }
1854 if (!confirm('Save changes to ' + browseState.collection + '/' + id + '?')) return;
1855 try {
1856 const resp = await fetch('/api/db/browse/' + encodeURIComponent(browseState.collection) + '/' + encodeURIComponent(id), {
1857 method: 'PUT',
1858 headers: { 'Content-Type': 'application/json', ...(accessToken ? { Authorization: 'Bearer ' + accessToken } : {}) },
1859 body: JSON.stringify(parsed),
1860 });
1861 const data = await resp.json();
1862 if (data.ok) {
1863 jsonEl.contentEditable = 'false';
1864 jsonEl.classList.remove('editing');
1865 document.getElementById('browseDocEdit').style.display = '';
1866 document.getElementById('browseDocSave').style.display = 'none';
1867 document.getElementById('browseDocCancel').style.display = 'none';
1868 loadBrowseDocs();
1869 } else {
1870 alert('Save failed: ' + (data.error || 'unknown'));
1871 }
1872 } catch (e) {
1873 alert('Save error: ' + e.message);
1874 }
1875};
1876
1877document.getElementById('browseDocDelete').onclick = async () => {
1878 const id = document.getElementById('browseDocId').textContent;
1879 if (!confirm('Delete ' + browseState.collection + '/' + id + '? This cannot be undone.')) return;
1880 try {
1881 const resp = await fetch('/api/db/browse/' + encodeURIComponent(browseState.collection) + '/' + encodeURIComponent(id), {
1882 method: 'DELETE',
1883 headers: accessToken ? { Authorization: 'Bearer ' + accessToken } : {},
1884 });
1885 const data = await resp.json();
1886 if (data.ok) {
1887 document.getElementById('browseDocDetail').style.display = 'none';
1888 loadBrowseDocs();
1889 } else {
1890 alert('Delete failed: ' + (data.error || 'unknown'));
1891 }
1892 } catch (e) {
1893 alert('Delete error: ' + e.message);
1894 }
1895};
1896
1897document.getElementById('browseDocClose').onclick = () => {
1898 document.getElementById('browseDocDetail').style.display = 'none';
1899};
1900
1901document.getElementById('browsePrev').onclick = () => {
1902 browseState.skip = Math.max(0, browseState.skip - browseState.limit);
1903 loadBrowseDocs();
1904};
1905
1906document.getElementById('browseNext').onclick = () => {
1907 browseState.skip += browseState.limit;
1908 loadBrowseDocs();
1909};
1910
1911document.getElementById('browseSearchBtn').onclick = () => {
1912 browseState.q = document.getElementById('browseSearch').value.trim();
1913 browseState.skip = 0;
1914 loadBrowseDocs();
1915};
1916
1917document.getElementById('browseSearch').addEventListener('keydown', (e) => {
1918 if (e.key === 'Enter') {
1919 browseState.q = e.target.value.trim();
1920 browseState.skip = 0;
1921 loadBrowseDocs();
1922 }
1923});
1924
1925let browseLoaded = false;
1926
1927function loadAll() {
1928 loadOverview(); loadCollections(); loadStorage(); loadServices(); loadBilling(); loadInstaStatus(); loadTiktokStatus();
1929 // Atlas compare is deferred — only load when that sub-tab is first visited
1930 let atlasLoaded = false;
1931 const origClick = document.querySelector('.sub-tab-btn[data-subtab="atlas"]');
1932 if (origClick) {
1933 const orig = origClick.onclick;
1934 origClick.addEventListener('click', () => {
1935 if (!atlasLoaded) { atlasLoaded = true; loadCompare(); }
1936 });
1937 }
1938 fhInit();
1939 // Redraw pie chart on resize
1940 window.addEventListener('resize', () => {
1941 if (cachedCollections) drawPieChart(cachedCollections, cachedCatMeta);
1942 });
1943 setInterval(loadOverview, 30000);
1944 setInterval(loadServices, 60000);
1945 setInterval(loadBilling, 60000);
1946 setInterval(loadInstaStatus, 60000);
1947 setInterval(loadTiktokStatus, 60000);
1948}
1949
1950// --- Lith dashboard ---
1951const LITH_URL = 'https://aesthetic.computer';
1952let lithTimer = null;
1953
1954function lithSubTab(btn, panelId) {
1955 btn.parentElement.querySelectorAll('.sub-tab-btn').forEach(b => b.classList.remove('active'));
1956 btn.classList.add('active');
1957 document.getElementById('lith-errors-panel').style.display = 'none';
1958 document.getElementById('lith-requests-panel').style.display = 'none';
1959 document.getElementById('lith-traffic-panel').style.display = 'none';
1960 document.getElementById(panelId).style.display = 'block';
1961}
1962
1963function fmtUptime(s) {
1964 if (s < 60) return s + 's';
1965 if (s < 3600) return Math.floor(s/60) + 'm ' + (s%60) + 's';
1966 const h = Math.floor(s/3600);
1967 return h + 'h ' + Math.floor((s%3600)/60) + 'm';
1968}
1969
1970function fmtTime(iso) {
1971 if (!iso) return '-';
1972 const d = new Date(iso);
1973 return d.toLocaleTimeString('en-US', { hour12: false }) + '.' + String(d.getMilliseconds()).padStart(3,'0');
1974}
1975
1976async function loadLith() {
1977 try {
1978 const [statsRes, errorsRes, reqsRes, trafficRes] = await Promise.all([
1979 fetch(LITH_URL + '/lith/stats'),
1980 fetch(LITH_URL + '/lith/errors?limit=100'),
1981 fetch(LITH_URL + '/lith/requests?limit=100'),
1982 fetch(LITH_URL + '/lith/traffic').catch(() => ({ json: () => ({ total: 0 }) })),
1983 ]);
1984 const stats = await statsRes.json();
1985 const errors = await errorsRes.json();
1986 const reqs = await reqsRes.json();
1987 const traffic = await trafficRes.json();
1988
1989 // Overview
1990 document.getElementById('lith-uptime').textContent = fmtUptime(stats.uptime);
1991 document.getElementById('lith-boot').textContent = fmtTime(stats.boot);
1992 document.getElementById('lith-fn-count').textContent = stats.functionsLoaded;
1993 document.getElementById('lith-mem').textContent = stats.memory.rss + ' MB / ' + stats.memory.heap + ' MB';
1994 document.getElementById('lith-total-calls').textContent = fmt(stats.totals.calls);
1995 document.getElementById('lith-total-errors').textContent = stats.totals.errors;
1996 document.getElementById('lith-total-errors').style.color = stats.totals.errors > 0 ? 'var(--err)' : 'var(--ok)';
1997
1998 // Top functions
1999 const topEl = document.getElementById('lith-top-fns');
2000 if (stats.functions.length === 0) {
2001 topEl.textContent = 'no calls yet';
2002 } else {
2003 const maxCalls = stats.functions[0]?.calls || 1;
2004 topEl.innerHTML = stats.functions.slice(0, 30).map(f => {
2005 const pct = Math.max(2, Math.round(f.calls / maxCalls * 100));
2006 const errStyle = f.errors > 0 ? 'color:var(--err)' : 'color:var(--fg2)';
2007 return '<div style="display:flex;align-items:center;gap:6px;padding:1px 0">' +
2008 '<span style="width:140px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">' + esc(f.name) + '</span>' +
2009 '<div style="flex:1;height:8px;background:var(--bg);border-radius:2px;overflow:hidden">' +
2010 '<div style="width:' + pct + '%;height:100%;background:var(--accent);border-radius:2px"></div>' +
2011 '</div>' +
2012 '<span style="width:50px;text-align:right">' + fmt(f.calls) + '</span>' +
2013 '<span style="width:40px;text-align:right;' + errStyle + '">' + f.errors + 'e</span>' +
2014 '<span style="width:45px;text-align:right;color:var(--fg2)">' + f.avgMs + 'ms</span>' +
2015 '</div>';
2016 }).join('');
2017 }
2018
2019 // Errors table
2020 const errBody = document.getElementById('lith-errors-body');
2021 if (errors.errors.length === 0) {
2022 errBody.innerHTML = '<tr><td colspan="5" style="padding:8px;color:var(--fg2)">no errors</td></tr>';
2023 } else {
2024 errBody.innerHTML = errors.errors.map(e => {
2025 const errMsg = (e.error || '').substring(0, 120);
2026 return '<tr style="border-bottom:1px solid var(--border)">' +
2027 '<td style="padding:2px 6px;white-space:nowrap;color:var(--fg2)">' + fmtTime(e.time) + '</td>' +
2028 '<td style="padding:2px 6px;color:var(--accent)">' + esc(e.fn) + '</td>' +
2029 '<td style="padding:2px 6px;color:var(--err)">' + e.status + '</td>' +
2030 '<td style="padding:2px 6px;color:var(--fg2)">' + esc(e.path || '') + '</td>' +
2031 '<td style="padding:2px 6px;color:var(--fg2);max-width:300px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">' + esc(errMsg) + '</td>' +
2032 '</tr>';
2033 }).join('');
2034 }
2035
2036 // Requests table
2037 const reqBody = document.getElementById('lith-requests-body');
2038 if (reqs.requests.length === 0) {
2039 reqBody.innerHTML = '<tr><td colspan="5" style="padding:8px;color:var(--fg2)">no requests yet</td></tr>';
2040 } else {
2041 reqBody.innerHTML = reqs.requests.map(r => {
2042 const statusColor = r.status >= 500 ? 'var(--err)' : r.status >= 400 ? 'var(--warn)' : 'var(--fg)';
2043 const msColor = r.ms > 1000 ? 'var(--warn)' : r.ms > 3000 ? 'var(--err)' : 'var(--fg2)';
2044 return '<tr style="border-bottom:1px solid var(--border)">' +
2045 '<td style="padding:2px 6px;white-space:nowrap;color:var(--fg2)">' + fmtTime(r.time) + '</td>' +
2046 '<td style="padding:2px 6px;color:var(--accent)">' + esc(r.fn) + '</td>' +
2047 '<td style="padding:2px 6px;color:' + msColor + '">' + r.ms + '</td>' +
2048 '<td style="padding:2px 6px;color:' + statusColor + '">' + r.status + '</td>' +
2049 '<td style="padding:2px 6px;color:var(--fg2)">' + esc(r.path || '') + '</td>' +
2050 '</tr>';
2051 }).join('');
2052 }
2053 // Traffic panels
2054 function barRow(label, count, max) {
2055 var pct = Math.max(2, Math.round(count / max * 100));
2056 return '<div style="display:flex;align-items:center;gap:4px;padding:1px 0">' +
2057 '<span style="width:160px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">' + esc(label) + '</span>' +
2058 '<div style="flex:1;height:6px;background:var(--bg);border-radius:2px;overflow:hidden">' +
2059 '<div style="width:' + pct + '%;height:100%;background:var(--accent);border-radius:2px"></div>' +
2060 '</div>' +
2061 '<span style="width:40px;text-align:right;color:var(--fg2)">' + count + '</span></div>';
2062 }
2063 if (traffic.byPath && traffic.byPath.length) {
2064 var maxP = traffic.byPath[0][1];
2065 document.getElementById('lith-traffic-paths').innerHTML = traffic.byPath.map(function(p) { return barRow(p[0], p[1], maxP); }).join('');
2066 } else {
2067 document.getElementById('lith-traffic-paths').textContent = 'no data';
2068 }
2069 if (traffic.byHost && traffic.byHost.length) {
2070 var maxH = traffic.byHost[0][1];
2071 document.getElementById('lith-traffic-hosts').innerHTML = traffic.byHost.map(function(h) { return barRow(h[0], h[1], maxH); }).join('');
2072 } else {
2073 document.getElementById('lith-traffic-hosts').textContent = 'no data';
2074 }
2075 if (traffic.byStatus && traffic.byStatus.length) {
2076 document.getElementById('lith-traffic-status').innerHTML = traffic.byStatus.map(function(s) {
2077 var color = s[0].startsWith('5') ? 'var(--err)' : s[0].startsWith('4') ? 'var(--warn)' : 'var(--ok)';
2078 return '<div style="padding:1px 0"><span style="color:' + color + ';font-weight:bold">' + s[0] + '</span> <span style="color:var(--fg2)">' + s[1] + '</span></div>';
2079 }).join('');
2080 }
2081
2082 } catch (err) {
2083 document.getElementById('lith-uptime').textContent = 'offline';
2084 document.getElementById('lith-uptime').style.color = 'var(--err)';
2085 console.error('lith fetch error:', err);
2086 }
2087}
2088
2089// Start polling lith when tab is selected
2090document.addEventListener('click', e => {
2091 if (e.target.matches('[data-tab="8"]')) {
2092 loadLith();
2093 if (!lithTimer) lithTimer = setInterval(loadLith, 10000);
2094 }
2095});
2096
2097initAuth();
2098</script>
2099</body>
2100</html>