personal memory agent
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">×</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>