personal memory agent
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat: unified observer display on health page

Add a health API endpoint that reports the local stream hostname for the UI. Refactor the health workspace observer state and rendering to group observer streams by host, unify the observer card naming, and fetch the local host on page load so the main observe panel can read from the shared observer map.

Also add route coverage for /app/health/api/info and fix the observer UI follow-up issues from review: describe/transcribe now reset to Idle when the primary stream exists but those processor fields have not arrived yet, and fully stale observer hosts now receive the stale card styling.

+188 -97
+7
apps/health/routes.py
··· 2 2 # Copyright (c) 2026 sol pbc 3 3 4 4 import re 5 + import socket 5 6 from pathlib import Path 6 7 7 8 from flask import Blueprint, jsonify, request 9 + from think.streams import stream_name 8 10 9 11 from convey import state 10 12 ··· 39 41 return jsonify(error="Failed to read log file"), 500 40 42 41 43 return jsonify(content=content, path=path) 44 + 45 + 46 + @health_bp.route("/api/info") 47 + def api_info(): 48 + return jsonify({"hostname": stream_name(host=socket.gethostname())})
+11
apps/health/tests/test_routes.py
··· 55 55 env = health_env() 56 56 resp = env.client.get("/app/health/api/log?path=20260322/health/foo%00.log") 57 57 assert resp.status_code == 400 58 + 59 + 60 + class TestInfoRoute: 61 + def test_returns_hostname(self, health_env): 62 + env = health_env() 63 + response = env.client.get("/app/health/api/info") 64 + assert response.status_code == 200 65 + data = response.get_json() 66 + assert "hostname" in data 67 + assert isinstance(data["hostname"], str) 68 + assert len(data["hostname"]) > 0
+170 -97
apps/health/workspace.html
··· 184 184 padding-left: 1em; 185 185 } 186 186 187 - /* Remote Observers Card */ 188 - .remotes-card { 187 + /* Observers Card */ 188 + .observers-card { 189 189 background: white; 190 190 border-radius: 12px; 191 191 padding: 1.5em; ··· 193 193 border-left: 4px solid #8b5cf6; 194 194 } 195 195 196 - .remotes-card.hidden { 196 + .observers-card.hidden { 197 197 display: none; 198 198 } 199 199 200 - .remotes-grid { 200 + .observers-grid { 201 201 display: grid; 202 202 grid-template-columns: repeat(auto-fill, minmax(200px, 1fr)); 203 203 gap: 1em; 204 204 } 205 205 206 - .remote-card { 206 + .observer-host-card { 207 207 background: linear-gradient(135deg, #f3f4f6 0%, #e5e7eb 100%); 208 208 border-radius: 8px; 209 209 padding: 1em; ··· 213 213 border: 2px solid transparent; 214 214 } 215 215 216 - .remote-card.active { 216 + .observer-host-card.active { 217 217 background: linear-gradient(135deg, #ede9fe 0%, #ddd6fe 100%); 218 218 border-color: #8b5cf6; 219 219 } 220 220 221 - .remote-card.stale { 221 + .observer-host-card.stale { 222 222 background: linear-gradient(135deg, #fef3c7 0%, #fde68a 100%); 223 223 border-color: #f59e0b; 224 224 } 225 225 226 - .remote-card-name { 226 + .observer-host-name { 227 227 font-size: 0.95em; 228 228 font-weight: 600; 229 229 color: #111827; 230 230 } 231 231 232 - .remote-card-host { 232 + .observer-host-platform { 233 233 font-size: 0.8em; 234 234 color: #6b7280; 235 235 } 236 236 237 - .remote-card-status { 238 - font-size: 0.85em; 239 - color: #374151; 237 + .observer-stream-row { 238 + display: flex; 239 + align-items: center; 240 + gap: 8px; 241 + padding: 2px 0; 242 + font-size: 11px; 243 + } 244 + 245 + .observer-stream-qualifier { 246 + color: var(--text-muted); 247 + min-width: 48px; 248 + } 249 + 250 + .observer-stream-mode { 251 + color: var(--text-secondary); 252 + } 253 + 254 + .observer-stream-row.stale .observer-stream-mode::after { 255 + content: ' (stale)'; 256 + color: var(--text-muted); 240 257 } 241 258 242 259 /* Activity Grids Container */ ··· 671 688 </div> 672 689 </div> 673 690 674 - <!-- Remote Observers Card --> 675 - <div class="remotes-card hidden" id="remotesCard"> 691 + <!-- Observers Card --> 692 + <div class="observers-card hidden" id="observersCard"> 676 693 <div class="card-header"> 677 694 <div class="card-title"> 678 - REMOTE OBSERVERS 695 + OBSERVERS 679 696 </div> 680 697 </div> 681 - <div class="remotes-grid" id="remotesGrid"></div> 698 + <div class="observers-grid" id="observersGrid"></div> 682 699 </div> 683 700 684 701 <!-- Activity Grids --> ··· 742 759 743 760 <script> 744 761 (function(){ 762 + fetch('/app/health/api/info') 763 + .then(r => r.json()) 764 + .then(info => { state.localHost = info.hostname; updateObserve(); }) 765 + .catch(() => {}); 766 + 745 767 // State management 746 768 const state = { 747 769 services: new Map(), // Running services ··· 758 780 sync: null, // Sync status snapshot (null when idle) 759 781 serviceLogs: new Map(), // service name -> array of {ts, stream, line} 760 782 logFollow: true, // Auto-scroll log viewport 761 - remotes: new Map(), // Remote observer status by host 762 - observe: { 763 - mode: null, // Current observe mode 764 - screencast: null, 765 - tmux: null, 766 - audio: null, 767 - activity: null, 768 - describe: null, 769 - transcribe: null 770 - } 783 + observers: new Map(), // keyed by stream name 784 + localHost: null, 771 785 }; 772 786 773 787 // DOM elements ··· 791 805 describeDetail: document.getElementById('describeDetail'), 792 806 transcribeStatus: document.getElementById('transcribeStatus'), 793 807 transcribeDetail: document.getElementById('transcribeDetail'), 794 - remotesCard: document.getElementById('remotesCard'), 795 - remotesGrid: document.getElementById('remotesGrid'), 808 + observersCard: document.getElementById('observersCard'), 809 + observersGrid: document.getElementById('observersGrid'), 796 810 cortexSection: document.getElementById('cortexSection'), 797 811 cortexGrid: document.getElementById('cortexGrid'), 798 812 importerSection: document.getElementById('importerSection'), ··· 958 972 959 973 // Update observe mode badge 960 974 function updateObserveMode() { 961 - const mode = state.observe.mode; 975 + const mode = state.observers.get(state.localHost)?.mode; 962 976 const badge = elements.observeModeBadge; 963 977 const label = elements.observeModeLabel; 964 978 ··· 979 993 980 994 // Update observe card 981 995 function updateObserve() { 982 - const obs = state.observe; 996 + const primary = state.localHost ? state.observers.get(state.localHost) : null; 997 + const tmux = state.localHost ? state.observers.get(state.localHost + '.tmux') : null; 983 998 984 - // Update mode badge 985 999 updateObserveMode(); 986 1000 1001 + if (!state.localHost || !primary) { 1002 + elements.screencastStatus.textContent = 'Waiting...'; 1003 + elements.screencastDetail.textContent = ''; 1004 + elements.tmuxStatus.textContent = 'Waiting...'; 1005 + elements.tmuxDetail.textContent = ''; 1006 + elements.audioStatus.textContent = 'Waiting...'; 1007 + elements.audioDetail.textContent = ''; 1008 + elements.activityStatus.textContent = 'Waiting...'; 1009 + elements.activityDetail.textContent = ''; 1010 + elements.describeStatus.textContent = 'Waiting...'; 1011 + elements.describeDetail.textContent = ''; 1012 + elements.transcribeStatus.textContent = 'Waiting...'; 1013 + elements.transcribeDetail.textContent = ''; 1014 + return; 1015 + } 1016 + 987 1017 // Screencast 988 - if (obs.screencast) { 989 - const recording = obs.screencast.recording; 1018 + if (primary.screencast) { 1019 + const recording = primary.screencast.recording; 990 1020 if (recording) { 991 - const streams = obs.screencast.streams || []; 992 - const elapsed = obs.screencast.window_elapsed_seconds || 0; 1021 + const streams = primary.screencast.streams || []; 1022 + const elapsed = primary.screencast.window_elapsed_seconds || 0; 993 1023 const streamCount = streams.length; 994 1024 const label = streamCount === 1 ? 'monitor' : 'monitors'; 995 1025 elements.screencastStatus.textContent = `${elapsed}s (${streamCount} ${label})`; ··· 1004 1034 elements.screencastStatus.textContent = 'Idle'; 1005 1035 elements.screencastDetail.textContent = ''; 1006 1036 } 1037 + } else { 1038 + elements.screencastStatus.textContent = 'Idle'; 1039 + elements.screencastDetail.textContent = ''; 1007 1040 } 1008 1041 1009 1042 // Tmux 1010 - if (obs.tmux) { 1011 - if (obs.tmux.capturing) { 1012 - const captures = obs.tmux.captures || 0; 1013 - const sessions = obs.tmux.sessions || []; 1014 - const elapsed = obs.tmux.window_elapsed_seconds || 0; 1043 + if (tmux?.tmux) { 1044 + if (tmux.tmux.capturing) { 1045 + const captures = tmux.tmux.captures || 0; 1046 + const sessions = tmux.tmux.sessions || []; 1047 + const elapsed = tmux.tmux.window_elapsed_seconds || 0; 1015 1048 elements.tmuxStatus.textContent = `${elapsed}s (${captures} captures)`; 1016 1049 if (sessions.length > 0) { 1017 1050 elements.tmuxDetail.textContent = sessions.join(', '); ··· 1022 1055 elements.tmuxStatus.textContent = 'Idle'; 1023 1056 elements.tmuxDetail.textContent = ''; 1024 1057 } 1058 + } else if (tmux) { 1059 + elements.tmuxStatus.textContent = 'Idle'; 1060 + elements.tmuxDetail.textContent = ''; 1061 + } else { 1062 + elements.tmuxStatus.textContent = 'Waiting...'; 1063 + elements.tmuxDetail.textContent = ''; 1025 1064 } 1026 1065 1027 1066 // Audio 1028 - if (obs.audio) { 1029 - const hits = obs.audio.threshold_hits || 0; 1030 - const willSave = obs.audio.will_save ? ' (saving)' : ''; 1067 + if (primary.audio) { 1068 + const hits = primary.audio.threshold_hits || 0; 1069 + const willSave = primary.audio.will_save ? ' (saving)' : ''; 1031 1070 elements.audioStatus.textContent = `${hits} hits${willSave}`; 1032 1071 elements.audioDetail.textContent = ''; 1072 + } else { 1073 + elements.audioStatus.textContent = 'Idle'; 1074 + elements.audioDetail.textContent = ''; 1033 1075 } 1034 1076 1035 1077 // Activity 1036 - if (obs.activity) { 1037 - const idleMs = obs.activity.idle_time_ms || 0; 1038 - if (obs.activity.power_save) { 1078 + if (primary.activity) { 1079 + const idleMs = primary.activity.idle_time_ms || 0; 1080 + if (primary.activity.power_save) { 1039 1081 elements.activityStatus.textContent = 'Power save'; 1040 - } else if (obs.activity.screen_locked) { 1082 + } else if (primary.activity.screen_locked) { 1041 1083 elements.activityStatus.textContent = 'Locked'; 1042 - } else if (obs.activity.sink_muted) { 1084 + } else if (primary.activity.sink_muted) { 1043 1085 elements.activityStatus.textContent = 'Muted'; 1044 1086 } else { 1045 1087 elements.activityStatus.textContent = `Idle: ${Math.floor(idleMs/1000)}s`; 1046 1088 } 1089 + elements.activityDetail.textContent = ''; 1090 + } else { 1091 + elements.activityStatus.textContent = 'Idle'; 1047 1092 elements.activityDetail.textContent = ''; 1048 1093 } 1049 1094 ··· 1073 1118 } 1074 1119 } 1075 1120 1076 - updateProcessorStatus(obs.describe, elements.describeStatus, elements.describeDetail); 1077 - updateProcessorStatus(obs.transcribe, elements.transcribeStatus, elements.transcribeDetail); 1121 + if (primary.describe) { 1122 + updateProcessorStatus(primary.describe, elements.describeStatus, elements.describeDetail); 1123 + } else { 1124 + elements.describeStatus.textContent = 'Idle'; 1125 + elements.describeDetail.textContent = ''; 1126 + } 1127 + if (primary.transcribe) { 1128 + updateProcessorStatus(primary.transcribe, elements.transcribeStatus, elements.transcribeDetail); 1129 + } else { 1130 + elements.transcribeStatus.textContent = 'Idle'; 1131 + elements.transcribeDetail.textContent = ''; 1132 + } 1078 1133 } 1079 1134 1080 - // Update remote observers 1081 - function updateRemotes() { 1082 - if (state.remotes.size === 0) { 1083 - elements.remotesCard.classList.add('hidden'); 1135 + // Update observers 1136 + function updateObservers() { 1137 + if (state.observers.size === 0) { 1138 + elements.observersCard.classList.add('hidden'); 1084 1139 return; 1085 1140 } 1086 1141 1087 - elements.remotesCard.classList.remove('hidden'); 1142 + elements.observersCard.classList.remove('hidden'); 1143 + 1144 + const byHost = new Map(); 1145 + for (const [stream, data] of state.observers) { 1146 + const host = data.host || stream; 1147 + if (!byHost.has(host)) byHost.set(host, []); 1148 + byHost.get(host).push({ stream, data }); 1149 + } 1150 + 1088 1151 const now = Date.now(); 1152 + const STALE_MS = 30000; 1089 1153 1090 - const html = Array.from(state.remotes.values()).map(info => { 1091 - // Consider stale if no update in 30 seconds 1092 - const isStale = (now - info.lastSeen) > 30000; 1093 - const cardClass = isStale ? 'remote-card stale' : 'remote-card active'; 1154 + elements.observersGrid.innerHTML = ''; 1155 + for (const [host, streams] of byHost) { 1156 + const card = document.createElement('div'); 1157 + card.className = 'observer-host-card'; 1094 1158 1095 - return ` 1096 - <div class="${cardClass}"> 1097 - <div class="remote-card-name">${info.name}</div> 1098 - <div class="remote-card-host">${info.platform}</div> 1099 - <div class="remote-card-status">${info.mode}${isStale ? ' (stale)' : ''}</div> 1100 - </div> 1101 - `; 1102 - }).join(''); 1159 + const anyActive = streams.some(({ data }) => (now - data.lastSeen) < STALE_MS); 1160 + if (anyActive) card.classList.add('active'); 1161 + if (!anyActive) card.classList.add('stale'); 1162 + 1163 + const nameEl = document.createElement('div'); 1164 + nameEl.className = 'observer-host-name'; 1165 + nameEl.textContent = host; 1166 + card.appendChild(nameEl); 1167 + 1168 + const platform = streams[0]?.data?.platform || ''; 1169 + if (platform) { 1170 + const platEl = document.createElement('div'); 1171 + platEl.className = 'observer-host-platform'; 1172 + platEl.textContent = platform; 1173 + card.appendChild(platEl); 1174 + } 1175 + 1176 + for (const { stream, data } of streams) { 1177 + const row = document.createElement('div'); 1178 + row.className = 'observer-stream-row'; 1179 + 1180 + const dotIdx = stream.indexOf('.'); 1181 + const qualifier = dotIdx >= 0 ? stream.slice(dotIdx + 1) : 'desktop'; 1182 + 1183 + const stale = (now - data.lastSeen) >= STALE_MS; 1184 + if (stale) row.classList.add('stale'); 1103 1185 1104 - elements.remotesGrid.innerHTML = html; 1186 + const qualEl = document.createElement('div'); 1187 + qualEl.className = 'observer-stream-qualifier'; 1188 + qualEl.textContent = qualifier; 1189 + row.appendChild(qualEl); 1190 + 1191 + const modeEl = document.createElement('div'); 1192 + modeEl.className = 'observer-stream-mode'; 1193 + modeEl.textContent = data.mode || '—'; 1194 + row.appendChild(modeEl); 1195 + 1196 + card.appendChild(row); 1197 + } 1198 + 1199 + elements.observersGrid.appendChild(card); 1200 + } 1105 1201 } 1106 1202 1107 1203 // Update cortex grid ··· 1444 1540 } 1445 1541 1446 1542 function handleObserveEvent(msg) { 1447 - if (msg.event === 'status') { 1448 - // Check if this is from a remote observer (has "remote" field added by relay) 1449 - const remote = msg.remote; 1450 - if (remote) { 1451 - // Track remote observer 1452 - state.remotes.set(remote, { 1453 - name: remote, 1454 - platform: msg.platform || 'unknown', 1455 - mode: msg.mode || 'unknown', 1456 - lastSeen: Date.now() 1457 - }); 1458 - updateRemotes(); 1459 - } 1460 - 1461 - // Update local observe state (merge fields, don't overwrite all) 1462 - // Don't let the tmux sub-observer's mode ("tmux") override the main badge. 1463 - // The tmux observer sends msg.tmux but not msg.screencast/audio/activity; 1464 - // the desktop observer is the source of truth for the mode badge. 1465 - if (msg.mode !== undefined && msg.tmux === undefined) state.observe.mode = msg.mode; 1466 - if (msg.screencast !== undefined) state.observe.screencast = msg.screencast; 1467 - if (msg.tmux !== undefined) state.observe.tmux = msg.tmux; 1468 - if (msg.audio !== undefined) state.observe.audio = msg.audio; 1469 - if (msg.activity !== undefined) state.observe.activity = msg.activity; 1470 - if (msg.describe !== undefined) state.observe.describe = msg.describe; 1471 - if (msg.transcribe !== undefined) state.observe.transcribe = msg.transcribe; 1472 - 1473 - updateObserve(); 1474 - } 1543 + if (!msg.stream) return; 1544 + const existing = state.observers.get(msg.stream) || {}; 1545 + state.observers.set(msg.stream, { ...existing, ...msg, lastSeen: Date.now() }); 1546 + updateObserve(); 1547 + updateObservers(); 1475 1548 } 1476 1549 1477 1550 function handleImporterEvent(msg) { ··· 1563 1636 1564 1637 // Hide dashboard cards and suppress live log rendering 1565 1638 const dashboard = document.querySelector('.health-dashboard'); 1566 - dashboard.querySelectorAll('.vitals-bar, .observe-card, .remotes-card, .activity-grids, .dream-card, .sync-card').forEach(el => el.style.display = 'none'); 1639 + dashboard.querySelectorAll('.vitals-bar, .observe-card, .observers-card, .activity-grids, .dream-card, .sync-card').forEach(el => el.style.display = 'none'); 1567 1640 state.deepLinkMode = true; 1568 1641 1569 1642 // Replace header with log file context