Monorepo for Aesthetic.Computer aesthetic.computer
at main 2100 lines 90 kB view raw
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>