personal memory agent
at main 1213 lines 36 kB view raw
1<style> 2.observer-card { 3 background: #fafafa; 4 border: 1px solid var(--facet-border, #e5e0db); 5 border-radius: 8px; 6 padding: 1em 1.25em; 7 margin-bottom: 1.5em; 8} 9.observer-card.stale { 10 background: #fff9e6; 11 border-color: var(--facet-border, #e5e0db); 12 border-left: 3px solid #e5c35a; 13} 14.observer-card.revoked { 15 background: #f5f3f1; 16 border-color: var(--facet-border, #e5e0db); 17 border-left: 3px solid #8a8078; 18} 19.observer-card.revoked .observer-name { 20 text-decoration: line-through; 21 color: #5a6268; 22} 23.observer-card.disconnected { 24 background: #fef2f2; 25 border-color: var(--facet-border, #e5e0db); 26 border-left: 3px solid #dc3545; 27} 28.observer-card.connected { 29 background: #f0fdf4; 30 border-color: var(--facet-border, #e5e0db); 31 border-left: 3px solid #28a745; 32} 33.observer-header { 34 display: flex; 35 align-items: center; 36 gap: 12px; 37 margin-bottom: 0.5em; 38} 39.observer-name { 40 font-weight: 600; 41 font-size: 1.1em; 42} 43.observer-status { 44 display: inline-flex; 45 align-items: center; 46 gap: 6px; 47 font-size: 0.8em; 48 font-weight: 600; 49 letter-spacing: 0.03em; 50 text-transform: uppercase; 51 padding: 4px 10px; 52 border-radius: 4px; 53} 54.observer-status.connected { 55 background: #d4edda; 56 color: #155724; 57} 58.observer-status.connected::before { 59 content: ''; 60 width: 10px; 61 height: 10px; 62 border-radius: 50%; 63 background: #28a745; 64} 65.observer-status.connected::after { 66 content: '✓'; 67 font-weight: bold; 68} 69.observer-status.disconnected { 70 background: #f8d7da; 71 color: #721c24; 72} 73.observer-status.disconnected::before { 74 content: ''; 75 width: 10px; 76 height: 10px; 77 border-radius: 50%; 78 background: #dc3545; 79} 80.observer-status.stale { 81 background: #fff3cd; 82 color: #856404; 83} 84.observer-status.stale::before { 85 content: ''; 86 width: 10px; 87 height: 10px; 88 border-radius: 50%; 89 background: #ffc107; 90} 91.observer-status.stale::after { 92 content: '⚠'; 93} 94.observer-status.disconnected::after { 95 content: '✗'; 96 font-weight: bold; 97} 98.observer-status.revoked { 99 background: #f0ece8; 100 color: #8a8078; 101} 102.observer-status.revoked::before { 103 content: ''; 104 width: 10px; 105 height: 10px; 106 border-radius: 50%; 107 background: #8a8078; 108} 109 110/* ── Transition: ambient status dot ── */ 111.observer-status::before { 112 transition: background-color 0.6s ease; 113} 114.observer-status.revoked::after { 115 content: '—'; 116} 117.observer-stats { 118 font-size: 0.9em; 119 color: #666; 120 margin-bottom: 1em; 121} 122.observer-stats dl { 123 margin: 0; 124 display: flex; 125 flex-wrap: wrap; 126 gap: 0 2em; 127} 128.observer-stats dl > div { 129 display: flex; 130 gap: 0.3em; 131} 132.observer-stats dt { 133 color: #8a8078; 134 font-weight: 500; 135 font-size: 0.85em; 136} 137.observer-stats dt::after { 138 content: ':'; 139} 140.observer-stats dd { 141 margin: 0; 142} 143.observer-actions { 144 display: flex; 145 gap: 8px; 146 min-height: 44px; 147} 148.observer-actions button { 149 padding: 8px 16px; 150 border: 1px solid var(--facet-border, #e5e0db); 151 border-radius: 4px; 152 background: white; 153 cursor: pointer; 154 font-size: 0.9em; 155 font-weight: 500; 156 min-height: 44px; 157} 158.observer-actions button:hover { 159 background: rgba(0,0,0,0.05); 160} 161.observer-actions button.danger { 162 color: #dc3545; 163 border-color: #dc3545; 164} 165.observer-actions button.danger:hover { 166 background: #f8d7da; 167} 168.observer-actions button:active { 169 background: rgba(0,0,0,0.08); 170} 171.observer-actions button.danger:active { 172 background: #f1b0b7; 173} 174 175/* Add observer form */ 176.add-observer-form { 177 display: flex; 178 gap: 8px; 179 margin-bottom: 1em; 180} 181.add-observer-form input { 182 flex: 1; 183 padding: 8px 12px; 184 border: 1px solid #ccc; 185 border-radius: 4px; 186 font-size: 1em; 187} 188.add-observer-form button { 189 padding: 8px 16px; 190 background: var(--facet-color, #b06a1a); 191 color: white; 192 border: none; 193 border-radius: 4px; 194 cursor: pointer; 195 font-weight: bold; 196 min-height: 44px; 197} 198.add-observer-form button:hover { 199 background: color-mix(in srgb, var(--facet-color, #b06a1a) 85%, black); 200} 201.add-observer-form button:focus-visible { 202 outline: 2px solid color-mix(in srgb, var(--facet-color, #b06a1a) 85%, black); 203 outline-offset: 2px; 204} 205.add-observer-form button:active { 206 background: color-mix(in srgb, var(--facet-color, #b06a1a) 70%, black); 207} 208.add-observer-form button:disabled { 209 background: #ccc; 210 cursor: not-allowed; 211} 212 213/* Observer key modal */ 214#keyModal .modal-content { 215 max-width: 600px; 216 padding: 1.5em; 217} 218.modal-close { 219 position: absolute; 220 top: 10px; 221 right: 15px; 222 background: none; 223 border: none; 224 padding: 0; 225 font-family: inherit; 226 cursor: pointer; 227 font-size: 24px; 228 color: #666; 229 min-width: 44px; 230 min-height: 44px; 231 display: flex; 232 align-items: center; 233 justify-content: center; 234} 235.modal-close:hover { 236 color: #333; 237} 238.modal-close:focus-visible { 239 outline: 2px solid color-mix(in srgb, var(--facet-color, #b06a1a) 85%, black); 240 outline-offset: 2px; 241 border-radius: 4px; 242} 243.modal-close:active { 244 color: #000; 245} 246.modal h3 { 247 margin-top: 0; 248 margin-bottom: 1em; 249} 250.command-box { 251 background: #1e1e1e; 252 color: #d4d4d4; 253 padding: 1em; 254 border-radius: 4px; 255 font-family: 'SF Mono', 'Fira Code', 'Fira Mono', 'Roboto Mono', monospace; 256 letter-spacing: 0.02em; 257 font-size: 0.9em; 258 overflow-x: auto; 259 margin: 1em 0; 260 position: relative; 261} 262.command-box code { 263 white-space: pre-wrap; 264 word-break: break-all; 265} 266.copy-btn { 267 position: absolute; 268 top: 8px; 269 right: 8px; 270 padding: 4px 8px; 271 background: #444; 272 color: white; 273 border: none; 274 border-radius: 4px; 275 cursor: pointer; 276 font-size: 0.8em; 277 min-height: 44px; 278 min-width: 44px; 279} 280.copy-btn:hover { 281 background: #555; 282} 283.copy-btn:focus-visible { 284 outline: 2px solid #80bdff; 285 outline-offset: 2px; 286} 287.copy-btn:active { 288 background: #333; 289} 290.copy-btn.copied { 291 background: #28a745; 292} 293#revealKeyBtn { 294 right: 60px; 295} 296.credential-label { 297 font-weight: 600; 298 text-transform: uppercase; 299 letter-spacing: 0.04em; 300 font-size: 0.85em; 301 color: #333; 302 margin-bottom: 0.25em; 303} 304.modal-actions { 305 display: flex; 306 justify-content: flex-end; 307 margin-top: 1em; 308} 309.modal-actions button { 310 padding: 8px 16px; 311 border-radius: 4px; 312 cursor: pointer; 313 min-height: 44px; 314} 315.modal-primary-btn { 316 background: var(--facet-color, #b06a1a); 317 color: white; 318 border: none; 319 font-weight: bold; 320} 321.modal-primary-btn:hover { 322 background: color-mix(in srgb, var(--facet-color, #b06a1a) 85%, black); 323} 324.modal-primary-btn:focus-visible { 325 outline: 2px solid color-mix(in srgb, var(--facet-color, #b06a1a) 85%, black); 326 outline-offset: 2px; 327} 328.modal-primary-btn:active { 329 background: color-mix(in srgb, var(--facet-color, #b06a1a) 70%, black); 330} 331.modal-warning { 332 font-size: 0.85em; 333 color: #6b7280; 334 line-height: 1.5; 335} 336 337.no-observers { 338 text-align: center; 339 padding: 3em 1em; 340 color: #666; 341} 342.no-observers-icon { 343 font-size: 3em; 344 margin-bottom: 1em; 345 opacity: 0.5; 346} 347.no-observers-text { 348 font-size: 1.1em; 349 margin-bottom: 0.5em; 350} 351.no-observers-hint { 352 font-size: 0.85em; 353 color: #999; 354 margin-bottom: 1.5em; 355} 356.no-observers-action { 357 display: inline-flex; 358 align-items: center; 359 justify-content: center; 360 padding: 8px 16px; 361 min-height: 44px; 362 background: var(--facet-color, #b06a1a); 363 color: white; 364 border: none; 365 border-radius: 4px; 366 cursor: pointer; 367 font-size: 0.9em; 368} 369.no-observers-action:hover { 370 background: color-mix(in srgb, var(--facet-color, #b06a1a) 85%, black); 371} 372.no-observers-action:focus-visible { 373 outline: 2px solid color-mix(in srgb, var(--facet-color, #b06a1a) 85%, black); 374 outline-offset: 2px; 375} 376.no-observers-action:active { 377 background: color-mix(in srgb, var(--facet-color, #b06a1a) 70%, black); 378} 379 380.section-title { 381 margin-bottom: 1em; 382 color: #333; 383 font-size: 1.1em; 384 font-weight: 600; 385 letter-spacing: 0.01em; 386} 387 388/* Add observer toggle */ 389.add-observer-toggle { 390 display: inline-flex; 391 align-items: center; 392 gap: 6px; 393 padding: 8px 16px; 394 background: var(--facet-color, #b06a1a); 395 color: white; 396 border: none; 397 border-radius: 4px; 398 cursor: pointer; 399 font-weight: bold; 400 font-size: 1em; 401 min-height: 44px; 402 margin-top: 1.5em; 403 margin-bottom: 1em; 404} 405.add-observer-toggle:hover { 406 background: color-mix(in srgb, var(--facet-color, #b06a1a) 85%, black); 407} 408.add-observer-toggle:focus-visible { 409 outline: 2px solid color-mix(in srgb, var(--facet-color, #b06a1a) 85%, black); 410 outline-offset: 2px; 411} 412.add-observer-toggle:active { 413 background: color-mix(in srgb, var(--facet-color, #b06a1a) 70%, black); 414} 415.add-observer-toggle .toggle-indicator { 416 font-size: 0.85em; 417} 418 419/* Collapsed state for add-observer section */ 420.add-observer-section.collapsed .add-observer-form, 421.add-observer-section.collapsed .section-title { 422 display: none; 423} 424 425/* ── Responsive: Tablet (≤768px) ── */ 426@media (max-width: 768px) { 427 .add-observer-toggle { 428 width: 100%; 429 } 430 .add-observer-form { 431 flex-direction: column; 432 } 433 .add-observer-form input, 434 .add-observer-form button { 435 width: 100%; 436 } 437 .observer-header { 438 flex-wrap: wrap; 439 gap: 8px; 440 } 441 #keyModal .modal-content { 442 margin: 16px; 443 max-width: calc(100vw - 32px); 444 } 445} 446 447/* ── Responsive: Mobile (≤480px) ── */ 448@media (max-width: 480px) { 449 .observer-stats dl { 450 flex-direction: column; 451 gap: 4px; 452 } 453 .observer-actions { 454 flex-wrap: wrap; 455 } 456 .command-box { 457 max-width: 100%; 458 } 459} 460 461/* ── Error container ── */ 462.ws-error-container { 463 display: none; 464 background: #fff3cd; 465 border-left: 4px solid #ffc107; 466 color: #856404; 467 padding: 12px 16px; 468 margin-bottom: 1em; 469 border-radius: 4px; 470 position: relative; 471 font-size: 0.95em; 472 line-height: 1.4; 473} 474.ws-error-container[style*="display: flex"] { 475 display: flex !important; 476 flex-direction: column; 477 gap: 8px; 478} 479.ws-error-message { 480 flex: 1; 481} 482.ws-error-actions { 483 display: flex; 484 align-items: center; 485 gap: 8px; 486 flex-wrap: wrap; 487} 488.ws-error-retry { 489 background: #856404; 490 color: #fff; 491 border: none; 492 border-radius: 4px; 493 padding: 8px 16px; 494 cursor: pointer; 495 font-size: 0.9em; 496 min-height: 44px; 497} 498.ws-error-retry:hover { 499 background: #6d5303; 500} 501.ws-error-retry:focus-visible { 502 outline: 2px solid #856404; 503 outline-offset: 2px; 504} 505.ws-error-retry:active { 506 background: #554303; 507} 508.ws-error-dismiss { 509 background: transparent; 510 border: 1px solid #856404; 511 color: #856404; 512 border-radius: 4px; 513 padding: 8px 12px; 514 cursor: pointer; 515 font-size: 0.9em; 516 min-height: 44px; 517} 518.ws-error-dismiss:hover { 519 background: rgba(133, 100, 4, 0.1); 520} 521.ws-error-dismiss:focus-visible { 522 outline: 2px solid #856404; 523 outline-offset: 2px; 524} 525.ws-error-dismiss:active { 526 background: rgba(133, 100, 4, 0.15); 527} 528.ws-error-countdown { 529 color: #6d5303; 530 font-size: 0.85em; 531} 532 533/* ── Transitions: interactive elements ── */ 534.observer-actions button, 535.add-observer-form button, 536.add-observer-toggle, 537.copy-btn, 538.modal-close, 539.modal-actions button, 540.no-observers-action, 541.ws-error-retry, 542.ws-error-dismiss { 543 transition: background-color 0.12s ease, color 0.12s ease, border-color 0.12s ease; 544} 545</style> 546 547<div class="workspace-content"> 548 <div id="wsErrorContainer" class="ws-error-container" role="alert" style="display: none"> 549 <div class="ws-error-message" id="wsErrorMessage"></div> 550 <div class="ws-error-actions"> 551 <button type="button" class="ws-error-retry" id="wsErrorRetry" style="display: none" onclick="loadObservers()">retry now</button> 552 <span class="ws-error-countdown" id="wsErrorCountdown"></span> 553 <button type="button" class="ws-error-dismiss" onclick="clearError()">dismiss</button> 554 </div> 555 </div> 556 <section aria-label="observers"> 557 <h2 class="section-title">Connected Observers</h2> 558 <div id="observersList" role="list"> 559 <div class="no-observers">Loading...</div> 560 </div> 561 </section> 562 563 <button class="add-observer-toggle" id="addObserverToggle" 564 aria-expanded="false" aria-controls="addObserverSection"> 565 <span class="toggle-indicator"></span> Add observer 566 </button> 567 568 <section class="add-observer-section collapsed" id="addObserverSection" 569 aria-label="add observer" aria-hidden="true"> 570 <h2 class="section-title">Add Observer</h2> 571 <form class="add-observer-form" id="addObserverForm"> 572 <label for="observerName">Observer name</label> 573 <input type="text" id="observerName" placeholder="e.g., laptop, desktop" maxlength="64" required> 574 <button type="submit">Add Observer</button> 575 </form> 576 </section> 577</div> 578 579<!-- Observer Key Modal (for new observers and viewing existing keys) --> 580<div id="keyModal" class="modal" role="dialog" aria-modal="true" aria-label="observer key"> 581 <div class="modal-content"> 582 <button type="button" class="modal-close" id="keyModalClose" aria-label="Close">&times;</button> 583 <h3 id="keyModalTitle">Observer: <span id="modalObserverName"></span></h3> 584 <p>Use these credentials in your solstone app's service settings. Bearer authentication (header) is recommended; URL path auth is legacy.</p> 585 <div class="credential-label">Server URL</div> 586 <div class="command-box"> 587 <code id="serverUrlText"></code> 588 <button class="copy-btn" id="copyServerUrlBtn">Copy</button> 589 </div> 590 <div class="credential-label">Key</div> 591 <div class="command-box"> 592 <code id="keyText"></code> 593 <button class="copy-btn" id="revealKeyBtn">Reveal</button> 594 <button class="copy-btn" id="copyKeyBtn">Copy</button> 595 </div> 596 <p class="modal-warning"> 597 Keep this key secret — anyone with it can upload to your journal. 598 </p> 599 <div class="modal-actions"> 600 <button id="doneBtn" class="modal-primary-btn">Done</button> 601 </div> 602 </div> 603</div> 604 605<script> 606const observersList = document.getElementById('observersList'); 607const addObserverForm = document.getElementById('addObserverForm'); 608const observerNameInput = document.getElementById('observerName'); 609const addObserverToggle = document.getElementById('addObserverToggle'); 610const addObserverSection = document.getElementById('addObserverSection'); 611const keyModal = document.getElementById('keyModal'); 612const modalObserverName = document.getElementById('modalObserverName'); 613const serverUrlText = document.getElementById('serverUrlText'); 614const keyText = document.getElementById('keyText'); 615const copyServerUrlBtn = document.getElementById('copyServerUrlBtn'); 616const copyKeyBtn = document.getElementById('copyKeyBtn'); 617const doneBtn = document.getElementById('doneBtn'); 618const keyModalClose = document.getElementById('keyModalClose'); 619const revealKeyBtn = document.getElementById('revealKeyBtn'); 620let keyModalTrigger = null; 621// Error display state 622const wsErrorContainer = document.getElementById('wsErrorContainer'); 623const wsErrorMessage = document.getElementById('wsErrorMessage'); 624const wsErrorRetry = document.getElementById('wsErrorRetry'); 625const wsErrorCountdown = document.getElementById('wsErrorCountdown'); 626let errorAutoHideTimer = null; 627let countdownInterval = null; 628let lastPollTime = Date.now(); 629let isFirstLoad = true; 630 631function emptyStateHTML() { 632 return `<div class="no-observers"> 633 <div class="no-observers-icon">📡</div> 634 <div class="no-observers-text">no observers yet</div> 635 <div class="no-observers-hint">observers capture audio and screen from your devices</div> 636 <button class="no-observers-action" onclick="setFormCollapsed(false); document.getElementById('observerName').focus();">add an observer</button> 637 </div>`; 638} 639 640function statsHTML(observer, statusClass) { 641 return `<dl> 642 <div><dt>Last seen</dt><dd data-last-seen="${observer.last_seen || ''}" data-state="${statusClass}">${formatTimeAgo(observer.last_seen, statusClass)}</dd></div> 643 <div><dt>Segments (5-min chunks)</dt><dd>${observer.stats?.segments_received ?? 0}</dd></div> 644 <div><dt>Data</dt><dd>${formatBytes(observer.stats?.bytes_received || 0)}</dd></div> 645 </dl>`; 646} 647 648function showLocalError(message, opts = {}) { 649 wsErrorMessage.textContent = message; 650 wsErrorContainer.style.display = 'flex'; 651 wsErrorRetry.style.display = opts.retry ? 'inline-block' : 'none'; 652 653 // Clear any existing auto-hide timer 654 if (errorAutoHideTimer) clearTimeout(errorAutoHideTimer); 655 656 if (opts.retry) { 657 // For load errors: start countdown, no auto-hide (countdown stays until next load) 658 startCountdown(); 659 } else { 660 // For action errors: auto-hide after 10 seconds, no countdown 661 if (countdownInterval) { 662 clearInterval(countdownInterval); 663 countdownInterval = null; 664 } 665 wsErrorCountdown.textContent = ''; 666 errorAutoHideTimer = setTimeout(clearError, 10000); 667 } 668} 669 670function clearError() { 671 wsErrorContainer.style.display = 'none'; 672 wsErrorMessage.textContent = ''; 673 wsErrorCountdown.textContent = ''; 674 wsErrorRetry.style.display = 'none'; 675 if (errorAutoHideTimer) { 676 clearTimeout(errorAutoHideTimer); 677 errorAutoHideTimer = null; 678 } 679 if (countdownInterval) { 680 clearInterval(countdownInterval); 681 countdownInterval = null; 682 } 683} 684 685function startCountdown() { 686 if (countdownInterval) clearInterval(countdownInterval); 687 updateCountdownDisplay(); 688 countdownInterval = setInterval(updateCountdownDisplay, 1000); 689} 690 691function updateCountdownDisplay() { 692 const elapsed = Math.floor((Date.now() - lastPollTime) / 1000); 693 const remaining = Math.max(0, 30 - elapsed); 694 wsErrorCountdown.textContent = remaining > 0 ? `retrying in ${remaining}s\u2026` : 'retrying\u2026'; 695} 696 697let currentFullKey = null; 698let revealTimer = null; 699 700function setFormCollapsed(collapsed) { 701 if (collapsed) { 702 addObserverSection.classList.add('collapsed'); 703 addObserverSection.setAttribute('aria-hidden', 'true'); 704 addObserverToggle.setAttribute('aria-expanded', 'false'); 705 addObserverToggle.querySelector('.toggle-indicator').textContent = '▶'; 706 } else { 707 addObserverSection.classList.remove('collapsed'); 708 addObserverSection.setAttribute('aria-hidden', 'false'); 709 addObserverToggle.setAttribute('aria-expanded', 'true'); 710 addObserverToggle.querySelector('.toggle-indicator').textContent = '▼'; 711 } 712} 713 714addObserverToggle.addEventListener('click', () => { 715 const isCollapsed = addObserverSection.classList.contains('collapsed'); 716 setFormCollapsed(!isCollapsed); 717 if (isCollapsed) { 718 // Focus the input when expanding 719 document.getElementById('observerName').focus(); 720 } 721}); 722 723function formatBytes(bytes) { 724 if (bytes === 0) return '0 B'; 725 const k = 1024; 726 const sizes = ['B', 'KB', 'MB', 'GB', 'TB']; 727 const i = Math.floor(Math.log(bytes) / Math.log(k)); 728 return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i]; 729} 730 731function formatTimeAgo(timestamp, state) { 732 if (!timestamp) return 'never'; 733 const seconds = Math.floor((Date.now() - timestamp) / 1000); 734 let text; 735 if (seconds < 60) text = 'just now'; 736 else if (seconds < 3600) text = `${Math.floor(seconds / 60)} min ago`; 737 else if (seconds < 86400) text = `${Math.floor(seconds / 3600)} hours ago`; 738 else text = `${Math.floor(seconds / 86400)} days ago`; 739 if (state === 'stale') return `${text} — stale`; 740 if (state === 'disconnected') return `${text} — offline`; 741 return text; 742} 743 744function freshness(lastSeen) { 745 if (!lastSeen) return 'disconnected'; 746 const elapsed = Date.now() - lastSeen; 747 if (elapsed < 30000) return 'connected'; 748 if (elapsed < 120000) return 'stale'; 749 return 'disconnected'; 750} 751 752async function loadObservers() { 753 lastPollTime = Date.now(); 754 if (countdownInterval) { clearInterval(countdownInterval); countdownInterval = null; } 755 try { 756 const response = await fetch('/app/observer/api/list'); 757 const observers = await response.json(); 758 759 if (isFirstLoad) { 760 if (!observers || observers.length === 0) { 761 observersList.innerHTML = emptyStateHTML(); 762 isFirstLoad = false; 763 setFormCollapsed(false); 764 clearError(); 765 return; 766 } 767 768 let html = ''; 769 for (const observer of observers) { 770 const isRevoked = observer.revoked; 771 let statusClass, statusText, cardClass; 772 773 if (isRevoked) { 774 statusClass = 'revoked'; 775 statusText = 'Revoked'; 776 cardClass = 'revoked'; 777 } else { 778 const state = freshness(observer.last_seen); 779 statusClass = state; 780 if (state === 'connected') statusText = 'Connected'; 781 else if (state === 'stale') statusText = 'Stale'; 782 else statusText = 'Disconnected'; 783 cardClass = state; 784 } 785 786 html += ` 787 <div class="observer-card ${cardClass}" data-key="${observer.key_prefix}" role="listitem" aria-label="${escapeHtml(observer.name)}, ${statusText}"> 788 <div class="observer-header"> 789 <span class="observer-name">${escapeHtml(observer.name)}</span> 790 <span class="observer-status ${statusClass}">${statusText}</span> 791 </div> 792 <div class="observer-stats">${statsHTML(observer, statusClass)}</div> 793 <div class="observer-actions"> 794 ${isRevoked ? '' : `<button onclick="viewObserverKey('${observer.key_prefix}', '${escapeHtml(observer.name)}')">View Key</button>`} 795 ${isRevoked ? '' : `<button class="danger" onclick="revokeObserver('${observer.key_prefix}', '${escapeHtml(observer.name)}')">Revoke</button>`} 796 </div> 797 </div> 798 `; 799 } 800 observersList.innerHTML = html; 801 isFirstLoad = false; 802 } else { 803 // Save focus 804 const activeEl = document.activeElement; 805 let focusSelector = null; 806 if (activeEl && observersList.contains(activeEl)) { 807 const card = activeEl.closest('[data-key]'); 808 if (card) { 809 const key = card.getAttribute('data-key'); 810 const buttons = [...card.querySelectorAll('button')]; 811 const btnIndex = buttons.indexOf(activeEl); 812 if (btnIndex >= 0) { 813 focusSelector = `[data-key="${key}"] button:nth-child(${btnIndex + 1})`; 814 } 815 } 816 } 817 818 // Save scroll 819 const scrollTop = observersList.scrollTop; 820 821 // Build lookup from API data 822 const newDataMap = new Map(); 823 for (const observer of observers) { 824 newDataMap.set(observer.key_prefix, observer); 825 } 826 827 // Remove cards not in new data 828 const existingCards = observersList.querySelectorAll('[data-key]'); 829 for (const card of existingCards) { 830 if (!newDataMap.has(card.getAttribute('data-key'))) { 831 card.remove(); 832 } 833 } 834 835 // Update existing cards and track which keys exist in DOM 836 const existingKeys = new Set(); 837 for (const card of observersList.querySelectorAll('[data-key]')) { 838 const key = card.getAttribute('data-key'); 839 existingKeys.add(key); 840 const observer = newDataMap.get(key); 841 if (!observer) continue; // shouldn't happen after removal pass, but safe 842 843 const isRevoked = observer.revoked; 844 let statusClass, statusText, cardClass; 845 if (isRevoked) { 846 statusClass = 'revoked'; 847 statusText = 'Revoked'; 848 cardClass = 'revoked'; 849 } else { 850 const state = freshness(observer.last_seen); 851 statusClass = state; 852 statusText = state === 'connected' ? 'Connected' : state === 'stale' ? 'Stale' : 'Disconnected'; 853 cardClass = state; 854 } 855 856 // Update card class — replace all status classes 857 card.className = `observer-card ${cardClass}`; 858 card.setAttribute('aria-label', `${escapeHtml(observer.name)}, ${statusText}`); 859 860 // Update status badge 861 const statusEl = card.querySelector('.observer-status'); 862 statusEl.className = `observer-status ${statusClass}`; 863 statusEl.textContent = statusText; 864 865 // Update name (in case it somehow changed — safe to always set) 866 card.querySelector('.observer-name').textContent = observer.name; 867 868 // Update stats 869 const statsSpans = card.querySelectorAll('.observer-stats dd'); 870 if (statsSpans[0]) { 871 statsSpans[0].setAttribute('data-last-seen', observer.last_seen || ''); 872 statsSpans[0].setAttribute('data-state', statusClass); 873 statsSpans[0].textContent = formatTimeAgo(observer.last_seen, statusClass); 874 } 875 if (statsSpans[1]) { 876 statsSpans[1].textContent = observer.stats?.segments_received ?? 0; 877 } 878 if (statsSpans[2]) { 879 statsSpans[2].textContent = formatBytes(observer.stats?.bytes_received || 0); 880 } 881 882 // Update actions (revoked status may have changed) 883 const actionsEl = card.querySelector('.observer-actions'); 884 if (isRevoked) { 885 actionsEl.innerHTML = ''; 886 } else { 887 // Only rebuild if buttons are missing (was previously revoked → now not, which shouldn't normally happen, but be safe) 888 if (actionsEl.querySelectorAll('button').length === 0) { 889 actionsEl.innerHTML = `<button onclick="viewObserverKey('${observer.key_prefix}', '${escapeHtml(observer.name)}')">View Key</button><button class="danger" onclick="revokeObserver('${observer.key_prefix}', '${escapeHtml(observer.name)}')">Revoke</button>`; 890 } 891 } 892 } 893 894 // Add new cards 895 for (const [key, observer] of newDataMap) { 896 if (existingKeys.has(key)) continue; 897 898 const isRevoked = observer.revoked; 899 let statusClass, statusText, cardClass; 900 if (isRevoked) { 901 statusClass = 'revoked'; statusText = 'Revoked'; cardClass = 'revoked'; 902 } else { 903 const state = freshness(observer.last_seen); 904 statusClass = state; 905 statusText = state === 'connected' ? 'Connected' : state === 'stale' ? 'Stale' : 'Disconnected'; 906 cardClass = state; 907 } 908 909 const div = document.createElement('div'); 910 div.className = `observer-card ${cardClass}`; 911 div.setAttribute('data-key', key); 912 div.setAttribute('role', 'listitem'); 913 div.setAttribute('aria-label', `${escapeHtml(observer.name)}, ${statusText}`); 914 div.innerHTML = ` 915 <div class="observer-header"> 916 <span class="observer-name">${escapeHtml(observer.name)}</span> 917 <span class="observer-status ${statusClass}">${statusText}</span> 918 </div> 919 <div class="observer-stats">${statsHTML(observer, statusClass)}</div> 920 <div class="observer-actions"> 921 ${isRevoked ? '' : `<button onclick="viewObserverKey('${key}', '${escapeHtml(observer.name)}')">View Key</button>`} 922 ${isRevoked ? '' : `<button class="danger" onclick="revokeObserver('${key}', '${escapeHtml(observer.name)}')">Revoke</button>`} 923 </div> 924 `; 925 observersList.appendChild(div); 926 } 927 928 // Handle empty state transition 929 if (newDataMap.size === 0) { 930 observersList.innerHTML = emptyStateHTML(); 931 } else { 932 // Remove stale "no observers" message if present 933 const noObs = observersList.querySelector('.no-observers'); 934 if (noObs) noObs.remove(); 935 } 936 937 // Restore scroll 938 observersList.scrollTop = scrollTop; 939 940 // Restore focus 941 if (focusSelector) { 942 const target = observersList.querySelector(focusSelector); 943 if (target) target.focus(); 944 } 945 } 946 947 clearError(); 948 949 // Auto-collapse form when observers exist, expand when empty 950 if (!observers || observers.length === 0) { 951 setFormCollapsed(false); 952 } else { 953 const formHasFocus = addObserverSection.contains(document.activeElement); 954 if (!formHasFocus) { 955 setFormCollapsed(true); 956 } 957 } 958 } catch (err) { 959 observersList.innerHTML = '<div class="no-observers">couldn\'t load observers</div>'; 960 isFirstLoad = true; 961 showLocalError('couldn\'t load observers — the server may be unreachable. it will retry automatically, or you can retry now.', { retry: true }); 962 console.error('Failed to load observers:', err); 963 } 964} 965 966function refreshTimestamps() { 967 const spans = observersList.querySelectorAll('[data-last-seen]'); 968 for (const span of spans) { 969 const lastSeen = parseInt(span.getAttribute('data-last-seen'), 10); 970 if (!lastSeen) continue; 971 972 // Recompute freshness — it may have changed since last data fetch 973 const newState = freshness(lastSeen); 974 const oldState = span.getAttribute('data-state'); 975 976 // Update timestamp text 977 span.textContent = formatTimeAgo(lastSeen, newState); 978 span.setAttribute('data-state', newState); 979 980 // If freshness changed, update the card's visual state too 981 if (newState !== oldState) { 982 const card = span.closest('[data-key]'); 983 if (card && !card.classList.contains('revoked')) { 984 card.className = `observer-card ${newState}`; 985 const statusEl = card.querySelector('.observer-status'); 986 if (statusEl) { 987 statusEl.className = `observer-status ${newState}`; 988 statusEl.textContent = newState === 'connected' ? 'Connected' : newState === 'stale' ? 'Stale' : 'Disconnected'; 989 } 990 // Update aria-label 991 const nameEl = card.querySelector('.observer-name'); 992 if (nameEl) { 993 const statusText = newState === 'connected' ? 'Connected' : newState === 'stale' ? 'Stale' : 'Disconnected'; 994 card.setAttribute('aria-label', `${nameEl.textContent}, ${statusText}`); 995 } 996 } 997 } 998 } 999} 1000 1001function escapeHtml(text) { 1002 const div = document.createElement('div'); 1003 div.textContent = text; 1004 return div.innerHTML; 1005} 1006 1007async function revokeObserver(keyPrefix, name) { 1008 if (!confirm(`Revoke observer "${name}"? The observer will no longer be able to upload.`)) { 1009 return; 1010 } 1011 1012 try { 1013 const response = await fetch(`/app/observer/api/${keyPrefix}`, { 1014 method: 'DELETE' 1015 }); 1016 1017 if (!response.ok) { 1018 const data = await response.json(); 1019 throw new Error(data.error || 'Failed to revoke'); 1020 } 1021 1022 loadObservers(); 1023 } catch (err) { 1024 const msg = 'couldn\'t revoke observer — the server may be unavailable. try again in a moment.'; 1025 if (window.showError) { showError(msg); } else { showLocalError(msg); } 1026 } 1027} 1028 1029async function viewObserverKey(keyPrefix, name) { 1030 if (!confirm(`Reveal key for "${name}"?`)) return; 1031 const trigger = document.querySelector(`.observer-card[data-key="${keyPrefix}"] button`); 1032 try { 1033 const response = await fetch(`/app/observer/api/${keyPrefix}/key`); 1034 const data = await response.json(); 1035 1036 if (!response.ok) { 1037 throw new Error(data.error || 'Failed to get key'); 1038 } 1039 1040 showKeyModal(name, data.key, trigger); 1041 } catch (err) { 1042 const msg = 'couldn\'t retrieve observer key — the server may be unavailable. try again in a moment.'; 1043 if (window.showError) { showError(msg); } else { showLocalError(msg); } 1044 } 1045} 1046 1047function showKeyModal(name, key, trigger) { 1048 modalObserverName.textContent = name; 1049 serverUrlText.textContent = window.location.origin; 1050 currentFullKey = key; 1051 keyText.textContent = key.slice(0, 8) + '••••••••••••••••'; 1052 revealKeyBtn.textContent = 'Reveal'; 1053 if (revealTimer) { clearTimeout(revealTimer); revealTimer = null; } 1054 keyModal.style.display = 'block'; 1055 keyModalTrigger = trigger || null; 1056 keyModalClose.focus(); 1057 document.addEventListener('keydown', handleKeyModalKeys); 1058} 1059 1060addObserverForm.onsubmit = async (e) => { 1061 e.preventDefault(); 1062 const name = observerNameInput.value.trim(); 1063 if (!name) return; 1064 1065 const submitBtn = addObserverForm.querySelector('button[type="submit"]'); 1066 submitBtn.disabled = true; 1067 1068 try { 1069 const response = await fetch('/app/observer/api/create', { 1070 method: 'POST', 1071 headers: { 'Content-Type': 'application/json' }, 1072 body: JSON.stringify({ name }) 1073 }); 1074 1075 const data = await response.json(); 1076 1077 if (!response.ok) { 1078 throw new Error(data.error || 'Failed to create observer'); 1079 } 1080 1081 // Show modal with key 1082 showKeyModal(name, data.key, submitBtn); 1083 1084 // Clear input and reload list 1085 observerNameInput.value = ''; 1086 loadObservers(); 1087 // Auto-collapse form after successful add 1088 setFormCollapsed(true); 1089 keyModalTrigger = addObserverToggle; 1090 } catch (err) { 1091 const msg = 'couldn\'t add observer — the name may already be in use. try a different name or refresh the page.'; 1092 if (window.showError) { showError(msg); } else { showLocalError(msg); } 1093 } finally { 1094 submitBtn.disabled = false; 1095 } 1096}; 1097 1098function handleKeyModalKeys(e) { 1099 if (e.key === 'Escape') { 1100 closeKeyModal(); 1101 return; 1102 } 1103 if (e.key === 'Tab') { 1104 const focusable = [...keyModal.querySelectorAll('button:not([disabled]), [tabindex="0"]')]; 1105 if (focusable.length === 0) return; 1106 const currentIndex = focusable.indexOf(document.activeElement); 1107 if (e.shiftKey) { 1108 e.preventDefault(); 1109 focusable[currentIndex <= 0 ? focusable.length - 1 : currentIndex - 1].focus(); 1110 } else { 1111 e.preventDefault(); 1112 focusable[currentIndex >= focusable.length - 1 ? 0 : currentIndex + 1].focus(); 1113 } 1114 } 1115} 1116 1117function closeKeyModal() { 1118 if (revealTimer) { clearTimeout(revealTimer); revealTimer = null; } 1119 currentFullKey = null; 1120 revealKeyBtn.textContent = 'Reveal'; 1121 keyModal.style.display = 'none'; 1122 document.removeEventListener('keydown', handleKeyModalKeys); 1123 if (keyModalTrigger && document.contains(keyModalTrigger)) { 1124 keyModalTrigger.focus(); 1125 } 1126 keyModalTrigger = null; 1127} 1128 1129// Modal controls 1130keyModalClose.onclick = () => closeKeyModal(); 1131doneBtn.onclick = () => closeKeyModal(); 1132 1133window.onclick = (e) => { 1134 if (e.target === keyModal) { 1135 closeKeyModal(); 1136 } 1137}; 1138 1139function copyText(text) { 1140 if (navigator.clipboard && navigator.clipboard.writeText) { 1141 return navigator.clipboard.writeText(text); 1142 } 1143 // Fallback for non-HTTPS contexts (local solstone instances) 1144 const ta = document.createElement('textarea'); 1145 ta.value = text; 1146 ta.style.position = 'fixed'; 1147 ta.style.opacity = '0'; 1148 document.body.appendChild(ta); 1149 ta.select(); 1150 document.execCommand('copy'); 1151 document.body.removeChild(ta); 1152 return Promise.resolve(); 1153} 1154 1155function setupCopyBtn(btn, codeEl) { 1156 btn.onclick = () => { 1157 copyText(codeEl.textContent).then(() => { 1158 btn.textContent = 'Copied!'; 1159 btn.classList.add('copied'); 1160 setTimeout(() => { 1161 btn.textContent = 'Copy'; 1162 btn.classList.remove('copied'); 1163 }, 2000); 1164 }, () => { 1165 btn.textContent = 'Failed'; 1166 setTimeout(() => { btn.textContent = 'Copy'; }, 2000); 1167 }); 1168 }; 1169} 1170 1171setupCopyBtn(copyServerUrlBtn, serverUrlText); 1172copyKeyBtn.onclick = () => { 1173 if (!currentFullKey) return; 1174 copyText(currentFullKey).then(() => { 1175 copyKeyBtn.textContent = 'Copied!'; 1176 copyKeyBtn.classList.add('copied'); 1177 setTimeout(() => { 1178 copyKeyBtn.textContent = 'Copy'; 1179 copyKeyBtn.classList.remove('copied'); 1180 }, 2000); 1181 }, () => { 1182 copyKeyBtn.textContent = 'Failed'; 1183 setTimeout(() => { copyKeyBtn.textContent = 'Copy'; }, 2000); 1184 }); 1185}; 1186revealKeyBtn.onclick = () => { 1187 if (!currentFullKey) return; 1188 const isRevealed = keyText.textContent === currentFullKey; 1189 if (isRevealed) { 1190 keyText.textContent = currentFullKey.slice(0, 8) + '••••••••••••••••'; 1191 revealKeyBtn.textContent = 'Reveal'; 1192 if (revealTimer) { clearTimeout(revealTimer); revealTimer = null; } 1193 } else { 1194 keyText.textContent = currentFullKey; 1195 revealKeyBtn.textContent = 'Hide'; 1196 if (revealTimer) clearTimeout(revealTimer); 1197 revealTimer = setTimeout(() => { 1198 if (currentFullKey && keyText.textContent === currentFullKey) { 1199 keyText.textContent = currentFullKey.slice(0, 8) + '••••••••••••••••'; 1200 revealKeyBtn.textContent = 'Reveal'; 1201 } 1202 revealTimer = null; 1203 }, 30000); 1204 } 1205}; 1206 1207// Initial load 1208loadObservers(); 1209 1210// Refresh every 30 seconds to update connection status 1211setInterval(loadObservers, 30000); 1212setInterval(refreshTimestamps, 10000); 1213</script>