personal memory agent
0
fork

Configure Feed

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

observer: consolidate palette, grouped sort, form-first layout

- define semantic --status-{active,stale,error,inactive} vars in the shell and collapse observer badge/card variants onto the shared palette
- return thresholds from /app/observer/api/list, sort observers active→stale→inactive on the server, and render sibling-injected group headers from the client with the same freshness cutoffs
- promote the add-observer form to always-visible, remove the empty CTA/toggle/collapsible wrapper, and switch the empty state to a plain heading
- add a sandbox-only observer seeder + make sandbox-seed-observers for four-state visual checks, refresh the observer API/visual baselines, and keep observer escaping on AppServices via a lazy wrapper so the workspace still renders before shell JS attaches services

+564 -380
+7 -2
Makefile
··· 179 179 rm -rf "$$SANDBOX_JOURNAL"; \ 180 180 echo "Removed $$SANDBOX_JOURNAL"; \ 181 181 fi; \ 182 - rm -f .sandbox.pid .sandbox.journal; \ 183 - echo "Sandbox stopped." 182 + rm -f .sandbox.pid .sandbox.journal; \ 183 + echo "Sandbox stopped." 184 + 185 + .PHONY: sandbox-seed-observers 186 + sandbox-seed-observers: ## Seed 4 sample observers into the running sandbox journal 187 + @test -s .sandbox.journal || (echo "No sandbox running. Run 'make sandbox' first." && exit 1) 188 + @_SOLSTONE_JOURNAL_OVERRIDE=$$(cat .sandbox.journal) $(VENV_BIN)/python tests/fixtures/seed_observers.py 184 189 185 190 # Verify API baselines against running sandbox 186 191 verify-api: .installed
+43 -2
apps/observer/routes.py
··· 58 58 59 59 # Key length in bytes (256 bits = 32 bytes) 60 60 KEY_BYTES = 32 61 + ACTIVE_THRESHOLD_MS = 30_000 62 + STALE_THRESHOLD_MS = 120_000 61 63 62 64 63 65 def _get_key(url_key: str | None = None) -> str | None: ··· 75 77 return base64.urlsafe_b64encode(secrets.token_bytes(KEY_BYTES)).decode().rstrip("=") 76 78 77 79 80 + def _group_for(last_seen_ms: int | None, revoked: bool, now_ms: int) -> str: 81 + """Derive observer display group from freshness state.""" 82 + if revoked or last_seen_ms is None: 83 + return "inactive" 84 + elapsed = now_ms - last_seen_ms 85 + if elapsed < ACTIVE_THRESHOLD_MS: 86 + return "active" 87 + if elapsed < STALE_THRESHOLD_MS: 88 + return "stale" 89 + return "inactive" 90 + 91 + 78 92 def _revoke_observer(key: str) -> bool: 79 93 """Revoke observer by key (soft-delete).""" 80 94 observer = load_observer(key) ··· 91 105 @observer_bp.route("/api/list") 92 106 def api_list() -> Any: 93 107 """List all registered observers.""" 108 + now = now_ms() 94 109 observers = list_observers() 95 110 # Sanitize output - don't expose full keys 96 111 result = [] 97 112 for r in observers: 113 + key_prefix = r.get("key", "")[:8] 98 114 result.append( 99 115 { 100 - "key_prefix": r.get("key", "")[:8], 116 + "key_prefix": key_prefix, 101 117 "name": r.get("name", ""), 102 118 "created_at": r.get("created_at", 0), 103 119 "last_seen": r.get("last_seen"), ··· 108 124 "stats": r.get("stats", {}), 109 125 } 110 126 ) 111 - return jsonify(result) 127 + 128 + group_order = {"active": 0, "stale": 1, "inactive": 2} 129 + result.sort( 130 + key=lambda observer: ( 131 + group_order[ 132 + _group_for( 133 + observer.get("last_seen"), 134 + observer.get("revoked", False), 135 + now, 136 + ) 137 + ], 138 + 1 if observer.get("last_seen") is None else 0, 139 + -(observer.get("last_seen") or 0), 140 + observer.get("key_prefix", ""), 141 + ) 142 + ) 143 + 144 + return jsonify( 145 + { 146 + "thresholds": { 147 + "active_ms": ACTIVE_THRESHOLD_MS, 148 + "stale_ms": STALE_THRESHOLD_MS, 149 + }, 150 + "observers": result, 151 + } 152 + ) 112 153 113 154 114 155 @observer_bp.route("/api/create", methods=["POST"])
+228 -20
apps/observer/tests/test_routes.py
··· 8 8 import io 9 9 import json 10 10 11 + import apps.observer.routes as routes_module 12 + from apps.observer.utils import save_observer 13 + 14 + 15 + def _api_list_payload(env): 16 + resp = env.client.get("/app/observer/api/list") 17 + assert resp.status_code == 200 18 + return resp.get_json() 19 + 20 + 21 + def _api_list_observers(env): 22 + return _api_list_payload(env)["observers"] 23 + 24 + 25 + def _save_test_observer( 26 + key_prefix: str, 27 + name: str, 28 + *, 29 + created_at: int, 30 + last_seen: int | None, 31 + revoked: bool = False, 32 + ): 33 + key = key_prefix + ("f" * 56) 34 + assert save_observer( 35 + { 36 + "key": key, 37 + "name": name, 38 + "created_at": created_at, 39 + "last_seen": last_seen, 40 + "last_segment": None, 41 + "enabled": True, 42 + "revoked": revoked, 43 + "revoked_at": created_at + 1 if revoked else None, 44 + "stats": { 45 + "segments_received": 0, 46 + "bytes_received": 0, 47 + }, 48 + } 49 + ) 50 + return key 51 + 52 + 53 + def _client_state(observer: dict, thresholds: dict[str, int], current_now: int) -> str: 54 + if observer.get("revoked"): 55 + return "revoked" 56 + last_seen = observer.get("last_seen") 57 + if last_seen is None: 58 + return "disconnected" 59 + elapsed = current_now - last_seen 60 + if elapsed < thresholds["active_ms"]: 61 + return "connected" 62 + if elapsed < thresholds["stale_ms"]: 63 + return "stale" 64 + return "disconnected" 65 + 66 + 67 + def _group_from_client_state(state: str) -> str: 68 + if state == "connected": 69 + return "active" 70 + if state == "stale": 71 + return "stale" 72 + return "inactive" 73 + 11 74 12 75 def test_api_list_empty(observer_env): 13 76 """Test listing observers when none exist.""" 14 77 env = observer_env() 15 78 16 - resp = env.client.get("/app/observer/api/list") 17 - assert resp.status_code == 200 18 - assert resp.get_json() == [] 79 + assert _api_list_payload(env) == { 80 + "thresholds": { 81 + "active_ms": 30000, 82 + "stale_ms": 120000, 83 + }, 84 + "observers": [], 85 + } 19 86 20 87 21 88 def test_api_create_observer(observer_env): ··· 74 141 key_prefix = resp.get_json()["key_prefix"] 75 142 76 143 # List should show it 77 - resp = env.client.get("/app/observer/api/list") 78 - assert resp.status_code == 200 79 - observers = resp.get_json() 144 + payload = _api_list_payload(env) 145 + observers = payload["observers"] 80 146 81 147 assert len(observers) == 1 148 + assert payload["thresholds"] == {"active_ms": 30000, "stale_ms": 120000} 82 149 assert observers[0]["key_prefix"] == key_prefix 83 150 assert observers[0]["name"] == "my-observer" 84 151 assert observers[0]["enabled"] is True ··· 103 170 assert resp.get_json()["status"] == "ok" 104 171 105 172 # List should still show it, but marked as revoked 106 - resp = env.client.get("/app/observer/api/list") 107 - observers = resp.get_json() 173 + observers = _api_list_observers(env) 108 174 assert len(observers) == 1 109 175 assert observers[0]["key_prefix"] == key_prefix 110 176 assert observers[0]["revoked"] is True 111 177 assert observers[0]["revoked_at"] is not None 112 178 113 179 180 + def test_api_list_sorts_by_group_and_last_seen(observer_env, monkeypatch): 181 + """api_list orders active, then stale, then inactive with freshest first.""" 182 + env = observer_env() 183 + fixed_now = 2_000_000 184 + monkeypatch.setattr(routes_module, "now_ms", lambda: fixed_now) 185 + 186 + _save_test_observer( 187 + "cccc0000", 188 + "inactive-disconnected", 189 + created_at=10, 190 + last_seen=fixed_now - 600_000, 191 + ) 192 + _save_test_observer( 193 + "bbbb0000", 194 + "stale-observer", 195 + created_at=20, 196 + last_seen=fixed_now - 60_000, 197 + ) 198 + _save_test_observer( 199 + "aaaa0000", 200 + "active-observer", 201 + created_at=30, 202 + last_seen=fixed_now - 5_000, 203 + ) 204 + _save_test_observer( 205 + "dddd0000", 206 + "inactive-never", 207 + created_at=40, 208 + last_seen=None, 209 + ) 210 + 211 + observers = _api_list_observers(env) 212 + assert [observer["name"] for observer in observers] == [ 213 + "active-observer", 214 + "stale-observer", 215 + "inactive-disconnected", 216 + "inactive-never", 217 + ] 218 + 219 + 220 + def test_api_list_tie_breaks_by_key_prefix(observer_env, monkeypatch): 221 + """Observers with the same last_seen sort by key_prefix ascending.""" 222 + env = observer_env() 223 + fixed_now = 3_000_000 224 + monkeypatch.setattr(routes_module, "now_ms", lambda: fixed_now) 225 + 226 + _save_test_observer( 227 + "bbbb0000", 228 + "active-b", 229 + created_at=10, 230 + last_seen=fixed_now - 5_000, 231 + ) 232 + _save_test_observer( 233 + "aaaa0000", 234 + "active-a", 235 + created_at=20, 236 + last_seen=fixed_now - 5_000, 237 + ) 238 + 239 + observers = _api_list_observers(env) 240 + assert [observer["key_prefix"] for observer in observers] == [ 241 + "aaaa0000", 242 + "bbbb0000", 243 + ] 244 + 245 + 246 + def test_api_list_revoked_observer_buckets_inactive(observer_env, monkeypatch): 247 + """Revoked observers sort in the inactive bucket regardless of last_seen.""" 248 + env = observer_env() 249 + fixed_now = 4_000_000 250 + monkeypatch.setattr(routes_module, "now_ms", lambda: fixed_now) 251 + 252 + _save_test_observer( 253 + "bbbb0000", 254 + "revoked-observer", 255 + created_at=10, 256 + last_seen=fixed_now - 1_000, 257 + revoked=True, 258 + ) 259 + _save_test_observer( 260 + "aaaa0000", 261 + "stale-observer", 262 + created_at=20, 263 + last_seen=fixed_now - 60_000, 264 + ) 265 + 266 + observers = _api_list_observers(env) 267 + assert [observer["name"] for observer in observers] == [ 268 + "stale-observer", 269 + "revoked-observer", 270 + ] 271 + 272 + 273 + def test_api_list_client_server_mapping_agree(observer_env, monkeypatch): 274 + """Client freshness and server ordering stay aligned via shared thresholds.""" 275 + env = observer_env() 276 + fixed_now = 5_000_000 277 + monkeypatch.setattr(routes_module, "now_ms", lambda: fixed_now) 278 + 279 + _save_test_observer("eeee0000", "never", created_at=10, last_seen=None) 280 + _save_test_observer( 281 + "dddd0000", 282 + "revoked", 283 + created_at=20, 284 + last_seen=fixed_now - 1_000, 285 + revoked=True, 286 + ) 287 + _save_test_observer( 288 + "cccc0000", 289 + "inactive", 290 + created_at=30, 291 + last_seen=fixed_now - 600_000, 292 + ) 293 + _save_test_observer( 294 + "bbbb0000", 295 + "stale", 296 + created_at=40, 297 + last_seen=fixed_now - 60_000, 298 + ) 299 + _save_test_observer( 300 + "aaaa0000", 301 + "active", 302 + created_at=50, 303 + last_seen=fixed_now - 5_000, 304 + ) 305 + 306 + payload = _api_list_payload(env) 307 + thresholds = payload["thresholds"] 308 + observers = payload["observers"] 309 + group_order = {"active": 0, "stale": 1, "inactive": 2} 310 + 311 + expected = sorted( 312 + observers, 313 + key=lambda observer: ( 314 + group_order[ 315 + _group_from_client_state(_client_state(observer, thresholds, fixed_now)) 316 + ], 317 + 1 if observer.get("last_seen") is None else 0, 318 + -(observer.get("last_seen") or 0), 319 + observer["key_prefix"], 320 + ), 321 + ) 322 + 323 + assert [observer["key_prefix"] for observer in observers] == [ 324 + observer["key_prefix"] for observer in expected 325 + ] 326 + 327 + 114 328 def test_api_delete_nonexistent(observer_env): 115 329 """Test deleting a nonexistent observer returns 404.""" 116 330 env = observer_env() ··· 305 519 assert resp.status_code == 200 306 520 307 521 # Check stats updated 308 - resp = env.client.get("/app/observer/api/list") 309 - observers = resp.get_json() 522 + observers = _api_list_observers(env) 310 523 assert len(observers) == 1 311 524 assert observers[0]["stats"]["segments_received"] == 1 312 525 assert observers[0]["stats"]["bytes_received"] == len(test_data) ··· 700 913 assert resp.status_code == 200 701 914 702 915 # Check stats - last_segment should be the adjusted one 703 - resp = env.client.get("/app/observer/api/list") 704 - observers = resp.get_json() 916 + observers = _api_list_observers(env) 705 917 assert len(observers) == 1 706 918 last_segment = observers[0]["last_segment"] 707 919 assert last_segment is not None ··· 1249 1461 key_prefix = data["key_prefix"] 1250 1462 1251 1463 # Initially no segments_observed 1252 - resp = env.client.get("/app/observer/api/list") 1253 - data = resp.get_json() 1464 + data = _api_list_observers(env) 1254 1465 assert len(data) == 1 1255 1466 assert "segments_observed" not in data[0]["stats"] 1256 1467 ··· 1265 1476 json.dump(observer_data, f) 1266 1477 1267 1478 # Should now show in list 1268 - resp = env.client.get("/app/observer/api/list") 1269 - data = resp.get_json() 1479 + data = _api_list_observers(env) 1270 1480 assert data[0]["stats"]["segments_observed"] == 5 1271 1481 1272 1482 ··· 1395 1605 assert resp.status_code == 200 1396 1606 1397 1607 # Check stats - no duplicates_rejected yet 1398 - resp = env.client.get("/app/observer/api/list") 1399 - stats = resp.get_json()[0]["stats"] 1608 + stats = _api_list_observers(env)[0]["stats"] 1400 1609 assert stats.get("duplicates_rejected", 0) == 0 1401 1610 1402 1611 # Submit duplicate ··· 1413 1622 assert resp.get_json()["status"] == "duplicate" 1414 1623 1415 1624 # Check stats - should have 1 duplicate rejected 1416 - resp = env.client.get("/app/observer/api/list") 1417 - stats = resp.get_json()[0]["stats"] 1625 + stats = _api_list_observers(env)[0]["stats"] 1418 1626 assert stats["duplicates_rejected"] == 1 1419 1627 1420 1628
+171 -355
apps/observer/workspace.html
··· 1 1 <style> 2 2 .observer-card { 3 - background: #fafafa; 4 3 border: 1px solid var(--facet-border, #e5e0db); 4 + border-left: 3px solid transparent; 5 5 border-radius: 8px; 6 + background: #fafafa; 6 7 padding: 1em 1.25em; 7 8 margin-bottom: 1.5em; 9 + transition: background-color 0.15s ease, border-left-color 0.15s ease; 8 10 } 9 11 .observer-card.stale { 10 - background: #fff9e6; 11 - border-color: var(--facet-border, #e5e0db); 12 - border-left: 3px solid #e5c35a; 12 + background: color-mix(in srgb, var(--status-stale) 10%, white); 13 + border-left-color: var(--status-stale); 13 14 } 14 15 .observer-card.revoked { 15 - background: #f5f3f1; 16 - border-color: var(--facet-border, #e5e0db); 17 - border-left: 3px solid #8a8078; 16 + background: color-mix(in srgb, var(--status-inactive) 10%, white); 17 + border-left-color: var(--status-inactive); 18 18 } 19 19 .observer-card.revoked .observer-name { 20 20 text-decoration: line-through; 21 21 color: #5a6268; 22 22 } 23 23 .observer-card.disconnected { 24 - background: #fef2f2; 25 - border-color: var(--facet-border, #e5e0db); 26 - border-left: 3px solid #dc3545; 24 + background: color-mix(in srgb, var(--status-error) 10%, white); 25 + border-left-color: var(--status-error); 27 26 } 28 27 .observer-card.connected { 29 - background: #f0fdf4; 30 - border-color: var(--facet-border, #e5e0db); 31 - border-left: 3px solid #28a745; 28 + background: color-mix(in srgb, var(--status-active) 10%, white); 29 + border-left-color: var(--status-active); 32 30 } 33 31 .observer-header { 34 32 display: flex; ··· 44 42 display: inline-flex; 45 43 align-items: center; 46 44 gap: 6px; 45 + min-height: 28px; 47 46 font-size: 0.8em; 48 47 font-weight: 600; 49 48 letter-spacing: 0.03em; 50 49 text-transform: uppercase; 51 50 padding: 4px 10px; 52 51 border-radius: 4px; 53 - } 54 - .observer-status.connected { 55 - background: #d4edda; 56 - color: #155724; 52 + transition: background-color 0.6s ease, color 0.15s ease; 57 53 } 58 - .observer-status.connected::before { 54 + 55 + /* ── Transition: ambient status dot ── */ 56 + .observer-status::before { 59 57 content: ''; 60 58 width: 10px; 61 59 height: 10px; 62 60 border-radius: 50%; 63 - background: #28a745; 61 + transition: background-color 0.6s ease; 62 + } 63 + .observer-status::after { 64 + font-size: 0.95em; 65 + font-weight: 700; 66 + line-height: 1; 67 + } 68 + .observer-status.connected { 69 + background: color-mix(in srgb, var(--status-active) 12%, transparent); 70 + color: var(--status-active); 71 + } 72 + .observer-status.connected::before { 73 + background: var(--status-active); 64 74 } 65 75 .observer-status.connected::after { 66 76 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 77 } 80 78 .observer-status.stale { 81 - background: #fff3cd; 82 - color: #856404; 79 + background: color-mix(in srgb, var(--status-stale) 12%, transparent); 80 + color: var(--status-stale); 83 81 } 84 82 .observer-status.stale::before { 85 - content: ''; 86 - width: 10px; 87 - height: 10px; 88 - border-radius: 50%; 89 - background: #ffc107; 83 + background: var(--status-stale); 90 84 } 91 85 .observer-status.stale::after { 92 86 content: '⚠'; 93 87 } 88 + .observer-status.disconnected { 89 + background: color-mix(in srgb, var(--status-error) 12%, transparent); 90 + color: var(--status-error); 91 + } 92 + .observer-status.disconnected::before { 93 + background: var(--status-error); 94 + } 94 95 .observer-status.disconnected::after { 95 96 content: '✗'; 96 - font-weight: bold; 97 97 } 98 98 .observer-status.revoked { 99 - background: #f0ece8; 100 - color: #8a8078; 99 + background: color-mix(in srgb, var(--status-inactive) 12%, transparent); 100 + color: var(--status-inactive); 101 101 } 102 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; 103 + background: var(--status-inactive); 113 104 } 114 105 .observer-status.revoked::after { 115 106 content: '—'; ··· 353 344 color: #999; 354 345 margin-bottom: 1.5em; 355 346 } 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 347 .section-title { 381 348 margin-bottom: 1em; 382 349 color: #333; ··· 385 352 letter-spacing: 0.01em; 386 353 } 387 354 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; 355 + .observer-group-header { 356 + margin: 0 0 0.75em; 357 + color: #5a6268; 358 + font-size: 0.85em; 359 + font-weight: 600; 360 + letter-spacing: 0.04em; 361 + text-transform: uppercase; 404 362 } 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; 363 + .observer-group-header:not(:first-child) { 364 + margin-top: 2em; 417 365 } 418 366 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; 367 + .observer-empty-heading { 368 + margin: 0; 369 + color: #333; 370 + font-size: 1rem; 371 + font-weight: 600; 372 + line-height: 1.4; 423 373 } 424 374 425 375 /* ── Responsive: Tablet (≤768px) ── */ 426 376 @media (max-width: 768px) { 427 - .add-observer-toggle { 428 - width: 100%; 429 - } 430 377 .add-observer-form { 431 378 flex-direction: column; 432 379 } ··· 533 480 /* ── Transitions: interactive elements ── */ 534 481 .observer-actions button, 535 482 .add-observer-form button, 536 - .add-observer-toggle, 537 483 .copy-btn, 538 484 .modal-close, 539 485 .modal-actions button, 540 - .no-observers-action, 541 486 .ws-error-retry, 542 487 .ws-error-dismiss { 543 488 transition: background-color 0.12s ease, color 0.12s ease, border-color 0.12s ease; ··· 554 499 </div> 555 500 </div> 556 501 <section aria-label="observers"> 557 - <h2 class="section-title">Connected Observers</h2> 502 + <h2 class="section-title">Observers</h2> 503 + <section aria-label="add observer"> 504 + <h2 class="section-title">Add Observer</h2> 505 + <form class="add-observer-form" id="addObserverForm"> 506 + <label for="observerName">Observer name</label> 507 + <input type="text" id="observerName" placeholder="e.g., laptop, desktop" maxlength="64" required> 508 + <button type="submit">Add Observer</button> 509 + </form> 510 + </section> 558 511 <div id="observersList" role="list"> 559 512 <div class="no-observers">Loading...</div> 560 513 </div> 561 514 </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 515 </div> 578 516 579 517 <!-- Observer Key Modal (for new observers and viewing existing keys) --> ··· 606 544 const observersList = document.getElementById('observersList'); 607 545 const addObserverForm = document.getElementById('addObserverForm'); 608 546 const observerNameInput = document.getElementById('observerName'); 609 - const addObserverToggle = document.getElementById('addObserverToggle'); 610 - const addObserverSection = document.getElementById('addObserverSection'); 611 547 const keyModal = document.getElementById('keyModal'); 612 548 const modalObserverName = document.getElementById('modalObserverName'); 613 549 const serverUrlText = document.getElementById('serverUrlText'); ··· 617 553 const doneBtn = document.getElementById('doneBtn'); 618 554 const keyModalClose = document.getElementById('keyModalClose'); 619 555 const revealKeyBtn = document.getElementById('revealKeyBtn'); 620 - const escapeHtml = window.AppServices.escapeHtml; 556 + 557 + function escapeHtml(value) { 558 + return window.AppServices.escapeHtml(value); 559 + } 560 + 621 561 let keyModalTrigger = null; 622 562 // Error display state 623 563 const wsErrorContainer = document.getElementById('wsErrorContainer'); ··· 627 567 let errorAutoHideTimer = null; 628 568 let countdownInterval = null; 629 569 let lastPollTime = Date.now(); 630 - let isFirstLoad = true; 570 + let thresholds = { active_ms: 30000, stale_ms: 120000 }; 571 + const GROUP_ORDER = ['active', 'stale', 'inactive']; 572 + const GROUP_LABELS = { 573 + active: 'Active', 574 + stale: 'Stale', 575 + inactive: 'Inactive' 576 + }; 631 577 632 578 function emptyStateHTML() { 633 - return `<div class="no-observers"> 634 - <div class="no-observers-icon">📡</div> 635 - <div class="no-observers-text">no observers yet</div> 636 - <div class="no-observers-hint">observers capture audio and screen from your devices</div> 637 - <button class="no-observers-action" onclick="setFormCollapsed(false); document.getElementById('observerName').focus();">add an observer</button> 638 - </div>`; 579 + return '<h3 class="observer-empty-heading">Add your first observer</h3>'; 639 580 } 640 581 641 582 function statsHTML(observer, statusClass) { ··· 698 639 let currentFullKey = null; 699 640 let revealTimer = null; 700 641 701 - function setFormCollapsed(collapsed) { 702 - if (collapsed) { 703 - addObserverSection.classList.add('collapsed'); 704 - addObserverSection.setAttribute('aria-hidden', 'true'); 705 - addObserverToggle.setAttribute('aria-expanded', 'false'); 706 - addObserverToggle.querySelector('.toggle-indicator').textContent = '▶'; 707 - } else { 708 - addObserverSection.classList.remove('collapsed'); 709 - addObserverSection.setAttribute('aria-hidden', 'false'); 710 - addObserverToggle.setAttribute('aria-expanded', 'true'); 711 - addObserverToggle.querySelector('.toggle-indicator').textContent = '▼'; 642 + function statusMeta(observer) { 643 + if (observer.revoked) { 644 + return { 645 + statusClass: 'revoked', 646 + statusText: 'Revoked', 647 + cardClass: 'revoked' 648 + }; 712 649 } 650 + 651 + const state = freshness(observer.last_seen); 652 + return { 653 + statusClass: state, 654 + statusText: state === 'connected' ? 'Connected' : state === 'stale' ? 'Stale' : 'Disconnected', 655 + cardClass: state 656 + }; 713 657 } 714 658 715 - addObserverToggle.addEventListener('click', () => { 716 - const isCollapsed = addObserverSection.classList.contains('collapsed'); 717 - setFormCollapsed(!isCollapsed); 718 - if (isCollapsed) { 719 - // Focus the input when expanding 720 - document.getElementById('observerName').focus(); 659 + function groupFor(state, revoked) { 660 + if (revoked) return 'inactive'; 661 + if (state === 'connected') return 'active'; 662 + if (state === 'stale') return 'stale'; 663 + return 'inactive'; 664 + } 665 + 666 + function groupHeaderHTML(group) { 667 + return `<h3 class="observer-group-header" data-group="${group}">${GROUP_LABELS[group]}</h3>`; 668 + } 669 + 670 + function observerCardHTML(observer) { 671 + const { statusClass, statusText, cardClass } = statusMeta(observer); 672 + 673 + return ` 674 + <div class="observer-card ${cardClass}" data-key="${observer.key_prefix}" role="listitem" aria-label="${escapeHtml(observer.name)}, ${statusText}"> 675 + <div class="observer-header"> 676 + <span class="observer-name">${escapeHtml(observer.name)}</span> 677 + <span class="observer-status ${statusClass}">${statusText}</span> 678 + </div> 679 + <div class="observer-stats">${statsHTML(observer, statusClass)}</div> 680 + <div class="observer-actions"> 681 + ${observer.revoked ? '' : `<button onclick="viewObserverKey('${observer.key_prefix}', '${escapeHtml(observer.name)}')">View Key</button>`} 682 + ${observer.revoked ? '' : `<button class="danger" onclick="revokeObserver('${observer.key_prefix}', '${escapeHtml(observer.name)}')">Revoke</button>`} 683 + </div> 684 + </div> 685 + `; 686 + } 687 + 688 + function renderObservers(observers) { 689 + if (!observers || observers.length === 0) { 690 + return emptyStateHTML(); 721 691 } 722 - }); 692 + 693 + const groups = { 694 + active: [], 695 + stale: [], 696 + inactive: [] 697 + }; 698 + 699 + for (const observer of observers) { 700 + const { statusClass } = statusMeta(observer); 701 + groups[groupFor(statusClass, observer.revoked)].push(observer); 702 + } 703 + 704 + let html = ''; 705 + for (const group of GROUP_ORDER) { 706 + if (!groups[group].length) continue; 707 + html += groupHeaderHTML(group); 708 + html += groups[group].map(observerCardHTML).join(''); 709 + } 710 + 711 + return html || emptyStateHTML(); 712 + } 723 713 724 714 function formatBytes(bytes) { 725 715 if (bytes === 0) return '0 B'; ··· 745 735 function freshness(lastSeen) { 746 736 if (!lastSeen) return 'disconnected'; 747 737 const elapsed = Date.now() - lastSeen; 748 - if (elapsed < 30000) return 'connected'; 749 - if (elapsed < 120000) return 'stale'; 738 + if (elapsed < thresholds.active_ms) return 'connected'; 739 + if (elapsed < thresholds.stale_ms) return 'stale'; 750 740 return 'disconnected'; 751 741 } 752 742 ··· 755 745 if (countdownInterval) { clearInterval(countdownInterval); countdownInterval = null; } 756 746 try { 757 747 const response = await fetch('/app/observer/api/list'); 758 - const observers = await response.json(); 759 - 760 - if (isFirstLoad) { 761 - if (!observers || observers.length === 0) { 762 - observersList.innerHTML = emptyStateHTML(); 763 - isFirstLoad = false; 764 - setFormCollapsed(false); 765 - clearError(); 766 - return; 767 - } 768 - 769 - let html = ''; 770 - for (const observer of observers) { 771 - const isRevoked = observer.revoked; 772 - let statusClass, statusText, cardClass; 773 - 774 - if (isRevoked) { 775 - statusClass = 'revoked'; 776 - statusText = 'Revoked'; 777 - cardClass = 'revoked'; 778 - } else { 779 - const state = freshness(observer.last_seen); 780 - statusClass = state; 781 - if (state === 'connected') statusText = 'Connected'; 782 - else if (state === 'stale') statusText = 'Stale'; 783 - else statusText = 'Disconnected'; 784 - cardClass = state; 785 - } 786 - 787 - html += ` 788 - <div class="observer-card ${cardClass}" data-key="${observer.key_prefix}" role="listitem" aria-label="${escapeHtml(observer.name)}, ${statusText}"> 789 - <div class="observer-header"> 790 - <span class="observer-name">${escapeHtml(observer.name)}</span> 791 - <span class="observer-status ${statusClass}">${statusText}</span> 792 - </div> 793 - <div class="observer-stats">${statsHTML(observer, statusClass)}</div> 794 - <div class="observer-actions"> 795 - ${isRevoked ? '' : `<button onclick="viewObserverKey('${observer.key_prefix}', '${escapeHtml(observer.name)}')">View Key</button>`} 796 - ${isRevoked ? '' : `<button class="danger" onclick="revokeObserver('${observer.key_prefix}', '${escapeHtml(observer.name)}')">Revoke</button>`} 797 - </div> 798 - </div> 799 - `; 800 - } 801 - observersList.innerHTML = html; 802 - isFirstLoad = false; 803 - } else { 804 - // Save focus 805 - const activeEl = document.activeElement; 806 - let focusSelector = null; 807 - if (activeEl && observersList.contains(activeEl)) { 808 - const card = activeEl.closest('[data-key]'); 809 - if (card) { 810 - const key = card.getAttribute('data-key'); 811 - const buttons = [...card.querySelectorAll('button')]; 812 - const btnIndex = buttons.indexOf(activeEl); 813 - if (btnIndex >= 0) { 814 - focusSelector = `[data-key="${key}"] button:nth-child(${btnIndex + 1})`; 815 - } 816 - } 817 - } 748 + const payload = await response.json(); 749 + if (payload && payload.thresholds) { 750 + thresholds = payload.thresholds; 751 + } 752 + const observers = payload?.observers || []; 818 753 819 - // Save scroll 820 - const scrollTop = observersList.scrollTop; 821 - 822 - // Build lookup from API data 823 - const newDataMap = new Map(); 824 - for (const observer of observers) { 825 - newDataMap.set(observer.key_prefix, observer); 826 - } 827 - 828 - // Remove cards not in new data 829 - const existingCards = observersList.querySelectorAll('[data-key]'); 830 - for (const card of existingCards) { 831 - if (!newDataMap.has(card.getAttribute('data-key'))) { 832 - card.remove(); 833 - } 834 - } 835 - 836 - // Update existing cards and track which keys exist in DOM 837 - const existingKeys = new Set(); 838 - for (const card of observersList.querySelectorAll('[data-key]')) { 754 + // Rebuild on every poll so grouped headers stay aligned with server ordering. 755 + const activeEl = document.activeElement; 756 + let focusSelector = null; 757 + if (activeEl && observersList.contains(activeEl)) { 758 + const card = activeEl.closest('.observer-card'); 759 + if (card) { 839 760 const key = card.getAttribute('data-key'); 840 - existingKeys.add(key); 841 - const observer = newDataMap.get(key); 842 - if (!observer) continue; // shouldn't happen after removal pass, but safe 843 - 844 - const isRevoked = observer.revoked; 845 - let statusClass, statusText, cardClass; 846 - if (isRevoked) { 847 - statusClass = 'revoked'; 848 - statusText = 'Revoked'; 849 - cardClass = 'revoked'; 850 - } else { 851 - const state = freshness(observer.last_seen); 852 - statusClass = state; 853 - statusText = state === 'connected' ? 'Connected' : state === 'stale' ? 'Stale' : 'Disconnected'; 854 - cardClass = state; 855 - } 856 - 857 - // Update card class — replace all status classes 858 - card.className = `observer-card ${cardClass}`; 859 - card.setAttribute('aria-label', `${escapeHtml(observer.name)}, ${statusText}`); 860 - 861 - // Update status badge 862 - const statusEl = card.querySelector('.observer-status'); 863 - statusEl.className = `observer-status ${statusClass}`; 864 - statusEl.textContent = statusText; 865 - 866 - // Update name (in case it somehow changed — safe to always set) 867 - card.querySelector('.observer-name').textContent = observer.name; 868 - 869 - // Update stats 870 - const statsSpans = card.querySelectorAll('.observer-stats dd'); 871 - if (statsSpans[0]) { 872 - statsSpans[0].setAttribute('data-last-seen', observer.last_seen || ''); 873 - statsSpans[0].setAttribute('data-state', statusClass); 874 - statsSpans[0].textContent = formatTimeAgo(observer.last_seen, statusClass); 875 - } 876 - if (statsSpans[1]) { 877 - statsSpans[1].textContent = observer.stats?.segments_received ?? 0; 878 - } 879 - if (statsSpans[2]) { 880 - statsSpans[2].textContent = formatBytes(observer.stats?.bytes_received || 0); 881 - } 882 - 883 - // Update actions (revoked status may have changed) 884 - const actionsEl = card.querySelector('.observer-actions'); 885 - if (isRevoked) { 886 - actionsEl.innerHTML = ''; 887 - } else { 888 - // Only rebuild if buttons are missing (was previously revoked → now not, which shouldn't normally happen, but be safe) 889 - if (actionsEl.querySelectorAll('button').length === 0) { 890 - 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>`; 891 - } 761 + const buttons = [...card.querySelectorAll('button')]; 762 + const btnIndex = buttons.indexOf(activeEl); 763 + if (btnIndex >= 0) { 764 + focusSelector = `[data-key="${key}"] button:nth-child(${btnIndex + 1})`; 892 765 } 893 766 } 894 - 895 - // Add new cards 896 - for (const [key, observer] of newDataMap) { 897 - if (existingKeys.has(key)) continue; 898 - 899 - const isRevoked = observer.revoked; 900 - let statusClass, statusText, cardClass; 901 - if (isRevoked) { 902 - statusClass = 'revoked'; statusText = 'Revoked'; cardClass = 'revoked'; 903 - } else { 904 - const state = freshness(observer.last_seen); 905 - statusClass = state; 906 - statusText = state === 'connected' ? 'Connected' : state === 'stale' ? 'Stale' : 'Disconnected'; 907 - cardClass = state; 908 - } 909 - 910 - const div = document.createElement('div'); 911 - div.className = `observer-card ${cardClass}`; 912 - div.setAttribute('data-key', key); 913 - div.setAttribute('role', 'listitem'); 914 - div.setAttribute('aria-label', `${escapeHtml(observer.name)}, ${statusText}`); 915 - div.innerHTML = ` 916 - <div class="observer-header"> 917 - <span class="observer-name">${escapeHtml(observer.name)}</span> 918 - <span class="observer-status ${statusClass}">${statusText}</span> 919 - </div> 920 - <div class="observer-stats">${statsHTML(observer, statusClass)}</div> 921 - <div class="observer-actions"> 922 - ${isRevoked ? '' : `<button onclick="viewObserverKey('${key}', '${escapeHtml(observer.name)}')">View Key</button>`} 923 - ${isRevoked ? '' : `<button class="danger" onclick="revokeObserver('${key}', '${escapeHtml(observer.name)}')">Revoke</button>`} 924 - </div> 925 - `; 926 - observersList.appendChild(div); 927 - } 928 - 929 - // Handle empty state transition 930 - if (newDataMap.size === 0) { 931 - observersList.innerHTML = emptyStateHTML(); 932 - } else { 933 - // Remove stale "no observers" message if present 934 - const noObs = observersList.querySelector('.no-observers'); 935 - if (noObs) noObs.remove(); 936 - } 937 - 938 - // Restore scroll 939 - observersList.scrollTop = scrollTop; 940 - 941 - // Restore focus 942 - if (focusSelector) { 943 - const target = observersList.querySelector(focusSelector); 944 - if (target) target.focus(); 945 - } 946 767 } 768 + const scrollTop = observersList.scrollTop; 947 769 770 + observersList.innerHTML = renderObservers(observers); 948 771 clearError(); 772 + observersList.scrollTop = scrollTop; 949 773 950 - // Auto-collapse form when observers exist, expand when empty 951 - if (!observers || observers.length === 0) { 952 - setFormCollapsed(false); 953 - } else { 954 - const formHasFocus = addObserverSection.contains(document.activeElement); 955 - if (!formHasFocus) { 956 - setFormCollapsed(true); 957 - } 774 + if (focusSelector) { 775 + const target = observersList.querySelector(focusSelector); 776 + if (target) target.focus(); 958 777 } 959 778 } catch (err) { 960 779 observersList.innerHTML = '<div class="no-observers">couldn\'t load observers</div>'; 961 - isFirstLoad = true; 962 780 showLocalError('couldn\'t load observers — the server may be unreachable. it will retry automatically, or you can retry now.', { retry: true }); 963 781 console.error('Failed to load observers:', err); 964 782 } ··· 978 796 span.textContent = formatTimeAgo(lastSeen, newState); 979 797 span.setAttribute('data-state', newState); 980 798 981 - // If freshness changed, update the card's visual state too 799 + // If freshness changed, update the card's visual state too. Group placement 800 + // intentionally lags until the next 30s poll rebuild. 982 801 if (newState !== oldState) { 983 802 const card = span.closest('[data-key]'); 984 803 if (card && !card.classList.contains('revoked')) { ··· 1079 898 // Clear input and reload list 1080 899 observerNameInput.value = ''; 1081 900 loadObservers(); 1082 - // Auto-collapse form after successful add 1083 - setFormCollapsed(true); 1084 - keyModalTrigger = addObserverToggle; 1085 901 } catch (err) { 1086 902 const msg = 'couldn\'t add observer — the name may already be in use. try a different name or refresh the page.'; 1087 903 if (window.showError) { showError(msg); } else { showLocalError(msg); }
+5
convey/static/app.css
··· 79 79 --facet-color: #b06a1a; 80 80 --facet-bg: transparent; 81 81 --facet-border: #e5e0db; 82 + /* Semantic status palette — observer is the first consumer. */ 83 + --status-active: #16a34a; 84 + --status-stale: #d97706; 85 + --status-error: #dc2626; 86 + --status-inactive: #6b7280; 82 87 83 88 /* Layout dimensions */ 84 89 --facet-bar-height: 64px;
+7 -1
tests/baselines/api/observer/list.json
··· 1 - [] 1 + { 2 + "observers": [], 3 + "thresholds": { 4 + "active_ms": 30000, 5 + "stale_ms": 120000 6 + } 7 + }
tests/baselines/visual/observer/smoke.jpg

This is a binary file and will not be displayed.

+103
tests/fixtures/seed_observers.py
··· 1 + # SPDX-License-Identifier: AGPL-3.0-only 2 + # Copyright (c) 2026 sol pbc 3 + 4 + """Sandbox-only seed helper for observer fixture records. 5 + 6 + Writes four observer records covering active/stale/inactive/never-connected 7 + states against a sandbox journal. NEVER run against a production journal. 8 + """ 9 + 10 + from __future__ import annotations 11 + 12 + import argparse 13 + import hashlib 14 + import sys 15 + from pathlib import Path 16 + 17 + from apps.observer.utils import list_observers, save_observer 18 + from convey import state 19 + from think.utils import get_journal, now_ms 20 + 21 + SEEDS: list[tuple[str, int | None]] = [ 22 + ("sandbox-active", 5 * 1000), 23 + ("sandbox-stale", 60 * 1000), 24 + ("sandbox-disconnected", 600 * 1000), 25 + ("sandbox-never-connected", None), 26 + ] 27 + 28 + 29 + def _seed_key(name: str) -> str: 30 + return hashlib.sha256(name.encode("utf-8")).hexdigest() 31 + 32 + 33 + def seed_observers() -> int: 34 + journal = Path(get_journal()).expanduser().resolve() 35 + if not journal.exists() or not journal.is_dir(): 36 + raise RuntimeError(f"Sandbox journal does not exist: {journal}") 37 + # save_observer() -> get_observers_dir() -> get_app_storage_path() reads 38 + # convey.state.journal_root (not get_journal()), so we must set it 39 + # explicitly when running outside a Flask request context. 40 + state.journal_root = str(journal) 41 + 42 + existing = list_observers() 43 + bad_names = sorted( 44 + name 45 + for observer in existing 46 + if (name := (observer.get("name") or "")) and not name.startswith("sandbox-") 47 + ) 48 + if bad_names: 49 + raise RuntimeError( 50 + f"Refusing to seed: non-sandbox observer(s) present: {bad_names}" 51 + ) 52 + 53 + existing_by_name = { 54 + observer.get("name"): observer 55 + for observer in existing 56 + if observer.get("name", "").startswith("sandbox-") 57 + } 58 + 59 + current_now = now_ms() 60 + written = 0 61 + for name, offset_ms in SEEDS: 62 + existing_record = existing_by_name.get(name, {}) 63 + last_seen = None if offset_ms is None else current_now - offset_ms 64 + record = { 65 + "key": _seed_key(name), 66 + "name": name, 67 + "created_at": existing_record.get("created_at", current_now), 68 + "last_seen": last_seen, 69 + "last_segment": None, 70 + "enabled": True, 71 + "revoked": False, 72 + "revoked_at": None, 73 + "stats": { 74 + "segments_received": 0, 75 + "bytes_received": 0, 76 + }, 77 + } 78 + if not save_observer(record): 79 + raise RuntimeError(f"Failed to save observer: {name}") 80 + written += 1 81 + 82 + print(f"Seeded {written} sandbox observers into {journal}") 83 + return 0 84 + 85 + 86 + def main() -> int: 87 + parser = argparse.ArgumentParser( 88 + description=( 89 + "Sandbox-only observer seed helper. Requires " 90 + "_SOLSTONE_JOURNAL_OVERRIDE to already point at a sandbox journal." 91 + ) 92 + ) 93 + parser.parse_args() 94 + 95 + try: 96 + return seed_observers() 97 + except RuntimeError as exc: 98 + print(str(exc), file=sys.stderr) 99 + return 2 100 + 101 + 102 + if __name__ == "__main__": 103 + raise SystemExit(main())