From 05db925e93c5aba034897ecd90f1d8a7fed752a0 Mon Sep 17 00:00:00 2001 From: firestar5683 <168790843+firestar5683@users.noreply.github.com> Date: Fri, 21 Aug 2026 18:44:11 -0500 Subject: [PATCH] sentry --- .../assets/components/tools/sentry.css | 25 +++ .../assets/components/tools/sentry.js | 69 +++++++- starpilot/system/the_galaxy/the_galaxy.py | 155 ++++++++++++++++-- system/sentryd/sentryd.py | 16 -- 4 files changed, 227 insertions(+), 38 deletions(-) diff --git a/starpilot/system/the_galaxy/assets/components/tools/sentry.css b/starpilot/system/the_galaxy/assets/components/tools/sentry.css index d56519dfe..ddc7efd10 100644 --- a/starpilot/system/the_galaxy/assets/components/tools/sentry.css +++ b/starpilot/system/the_galaxy/assets/components/tools/sentry.css @@ -192,6 +192,30 @@ margin: 0; } +.sentry-history-list { + border-top: 1px solid var(--sidebar-border-color); + display: flex; + flex-direction: column; + gap: 1rem; + padding-top: 1rem; +} + +.sentry-history-event { + border: 1px solid var(--sidebar-border-color); + border-radius: var(--border-radius-base); + display: flex; + flex-direction: column; + gap: 0.75rem; + padding: 1rem; +} + +.sentry-history-heading { + align-items: center; + display: flex; + gap: 0.75rem; + justify-content: space-between; +} + .sentry-image-grid { display: grid; gap: 0.75rem; @@ -218,6 +242,7 @@ .sentry-page-header, .sentry-card-heading, + .sentry-history-heading, .sentry-setting-row, .sentry-field { align-items: stretch; diff --git a/starpilot/system/the_galaxy/assets/components/tools/sentry.js b/starpilot/system/the_galaxy/assets/components/tools/sentry.js index ce203df36..3700c1b71 100644 --- a/starpilot/system/the_galaxy/assets/components/tools/sentry.js +++ b/starpilot/system/the_galaxy/assets/components/tools/sentry.js @@ -11,6 +11,9 @@ const state = reactive({ params: {}, status: {}, event: {}, + history: [], + historyVisible: false, + historyBusy: false, liveCapture: {}, testBusy: false, liveBusy: false, @@ -38,11 +41,29 @@ async function fetchStatus() { const payload = await response.json() state.status = payload.status || {} state.event = payload.lastEvent || {} + if (state.historyVisible && !state.historyBusy) fetchHistory() } catch (error) { console.error("Failed to fetch Sentry status:", error) } } +async function fetchHistory() { + state.historyBusy = true + try { + const response = await fetch(galaxyPath("/api/sentry/events"), { cache: "no-store" }) + const payload = await readJsonResponse(response) + if (!response.ok) { + showSnackbar(payload.error || "Failed to load Sentry history.") + return + } + state.history = Array.isArray(payload.events) ? payload.events : [] + } catch (error) { + showSnackbar(error.message || "Failed to load Sentry history.") + } finally { + state.historyBusy = false + } +} + function startPolling() { if (pollTimer !== null) return fetchParams() @@ -50,6 +71,11 @@ function startPolling() { pollTimer = window.setInterval(fetchStatus, 5000) } +async function toggleHistory() { + state.historyVisible = !state.historyVisible + if (state.historyVisible) await fetchHistory() +} + function numericParam(key, fallback) { const value = Number(state.params[key]) return Number.isFinite(value) ? value : fallback @@ -154,8 +180,8 @@ async function sendTestNotification() { } } -async function deleteEvent() { - const eventId = String(state.event?.eventId || "") +async function deleteEvent(eventId) { + eventId = String(eventId || "") if (!eventId || state.deleteBusy) return if (!window.confirm("Delete this Sentry event and its camera images? This cannot be undone.")) return @@ -169,7 +195,11 @@ async function deleteEvent() { showSnackbar(payload.error || "Sentry event deletion failed.") return } - state.event = {} + state.history = state.history.filter((event) => String(event.eventId || "") !== eventId) + if (String(state.event?.eventId || "") === eventId) { + state.event = {} + await fetchStatus() + } showSnackbar("Sentry event deleted.") } catch (error) { showSnackbar(error.message || "Network error — is the device reachable?") @@ -178,8 +208,8 @@ async function deleteEvent() { } } -function renderEvent() { - const event = state.event || {} +function renderEvent(event = state.event) { + event = event || {} if (!event.eventId) return html`

No Sentry events recorded yet.

` return html` @@ -202,6 +232,29 @@ function renderEvent() { ` } +function renderHistory() { + if (!state.historyVisible) return "" + if (state.historyBusy) return html`

Loading Sentry history…

` + if (state.history.length === 0) return html`

No retained Sentry events.

` + + return html` +
+

${state.history.length} event${state.history.length === 1 ? "" : "s"} retained. Events stay here until you delete them.

+ ${state.history.map((event) => html` +
+
+ ${event.detectedAt || "Sentry event"} + +
+ ${renderEvent(event)} +
+ `)} +
+ ` +} + function renderLiveCapture() { const capture = state.liveCapture || {} const imageUrls = Array.isArray(capture.imageUrls) ? capture.imageUrls : [] @@ -360,19 +413,23 @@ export function SentryMode() {

Events refresh automatically every five seconds.

+ ${remote ? "" : html` `} ${() => state.event?.eventId ? html` - ` : ""}
${() => renderEvent()} + ${() => renderHistory()} ` diff --git a/starpilot/system/the_galaxy/the_galaxy.py b/starpilot/system/the_galaxy/the_galaxy.py index 53905848c..fb65b9993 100644 --- a/starpilot/system/the_galaxy/the_galaxy.py +++ b/starpilot/system/the_galaxy/the_galaxy.py @@ -546,6 +546,116 @@ def _sentry_event_roots() -> tuple[Path, ...]: return tuple(root.resolve() for root in roots) +_SENTRY_EVENT_INDEX_NAME = "events.json" +_SENTRY_EVENT_INDEX_LOCK = threading.Lock() + + +def _sentry_event_index_path() -> Path: + return _sentry_event_roots()[0] / _SENTRY_EVENT_INDEX_NAME + + +def _load_sentry_event_catalog_unlocked() -> list[dict]: + index_path = _sentry_event_index_path() + try: + raw_events = json.loads(index_path.read_text()) + except (OSError, TypeError, ValueError, json.JSONDecodeError): + return [] + + if not isinstance(raw_events, list): + return [] + + events = [] + for raw_event in raw_events: + event = _normalize_sentry_event(raw_event) + if event is not None: + events.append(event) + return events + + +def _save_sentry_event_catalog_unlocked(events: list[dict]) -> None: + index_path = _sentry_event_index_path() + index_path.parent.mkdir(parents=True, exist_ok=True) + temporary_path = index_path.with_suffix(".tmp") + temporary_path.write_text(json.dumps(events, separators=(",", ":"))) + temporary_path.chmod(0o600) + temporary_path.replace(index_path) + + +def _stored_sentry_event() -> dict | None: + raw_event = params.get("SentryModeLastEvent", encoding="utf-8") or "{}" + try: + payload = raw_event if isinstance(raw_event, dict) else json.loads(raw_event) + except (TypeError, ValueError, json.JSONDecodeError): + return None + return _normalize_sentry_event(payload) + + +def _discover_legacy_sentry_events(known_event_ids: set[str]) -> list[dict]: + discovered = [] + for root in _sentry_event_roots(): + try: + directories = sorted( + (path for path in root.iterdir() if path.is_dir()), + key=lambda path: path.stat().st_mtime, + reverse=True, + ) + except OSError: + continue + + for directory in directories: + event_id = directory.name + if event_id == _SENTRY_LIVE_EVENT_ID or event_id in known_event_ids or len(event_id) > 96: + continue + + image_paths = [ + str(path) for path in (directory / "wide.jpg", directory / "driver.jpg") + if path.is_file() + ] + if not image_paths: + continue + + try: + detected_at = datetime.fromtimestamp(directory.stat().st_mtime, timezone.utc).isoformat() + except OSError: + detected_at = "" + is_test = event_id.startswith("test-") + discovered.append({ + "eventId": event_id, + "kind": "alarm" if is_test else "warning", + "detectedAt": detected_at, + "message": "Test sentry event." if is_test else "Movement detected while parked.", + "imagePaths": image_paths, + }) + known_event_ids.add(event_id) + return discovered + + +def _sentry_event_catalog() -> list[dict]: + with _SENTRY_EVENT_INDEX_LOCK: + events = _load_sentry_event_catalog_unlocked() + known_event_ids = {event["eventId"] for event in events} + legacy_events = _discover_legacy_sentry_events(known_event_ids) + if legacy_events: + events.extend(legacy_events) + latest_event = _stored_sentry_event() + if latest_event is not None and latest_event["eventId"] not in known_event_ids: + events.insert(0, latest_event) + _save_sentry_event_catalog_unlocked(events) + elif legacy_events: + _save_sentry_event_catalog_unlocked(events) + return events + + +def _record_sentry_event(event: dict) -> None: + with _SENTRY_EVENT_INDEX_LOCK: + events = _load_sentry_event_catalog_unlocked() + known_event_ids = {existing["eventId"] for existing in events} + events.extend(_discover_legacy_sentry_events(known_event_ids)) + events = [existing for existing in events if existing.get("eventId") != event["eventId"]] + events.insert(0, event) + _save_sentry_event_catalog_unlocked(events) + + def _safe_sentry_image_paths(raw_paths) -> list[str]: if not isinstance(raw_paths, list): return [] @@ -7110,21 +7220,25 @@ def setup(app): @app.route("/api/sentry/status", methods=["GET"]) def sentry_status(): - raw_event = params.get("SentryModeLastEvent", encoding="utf-8") or "{}" raw_status = params.get("SentryModeStatus", encoding="utf-8") or "{}" - try: - last_event = json.loads(raw_event) - except (TypeError, ValueError, json.JSONDecodeError): - last_event = {} try: status = json.loads(raw_status) except (TypeError, ValueError, json.JSONDecodeError): status = {} + events = _sentry_event_catalog() + last_event = events[0] if events else {} + return jsonify({ "enabled": params.get_bool("SentryModeEnabled"), "status": status if isinstance(status, dict) else {}, - "lastEvent": _public_sentry_event(last_event) if isinstance(last_event, dict) else {}, + "lastEvent": _public_sentry_event(last_event), + }) + + @app.route("/api/sentry/events", methods=["GET"]) + def get_sentry_events(): + return jsonify({ + "events": [_public_sentry_event(event) for event in _sentry_event_catalog()], }) @app.route("/api/sentry/events/", methods=["DELETE"]) @@ -7134,6 +7248,8 @@ def setup(app): if not event_id or event_id in {".", ".."} or Path(event_id).name != event_id: return jsonify({"error": "Invalid Sentry event ID."}), 400 + _sentry_event_catalog() + deleted_storage = False for root in _sentry_event_roots(): directory = (root / event_id).resolve() @@ -7142,19 +7258,24 @@ def setup(app): shutil.rmtree(directory) deleted_storage = True - raw_event = params.get("SentryModeLastEvent", encoding="utf-8") or "{}" - try: - current_event = raw_event if isinstance(raw_event, dict) else json.loads(raw_event) - except (TypeError, ValueError, json.JSONDecodeError): - current_event = {} + current_event = _stored_sentry_event() + current_event_deleted = current_event is not None and current_event.get("eventId") == event_id + with _SENTRY_EVENT_INDEX_LOCK: + events = _load_sentry_event_catalog_unlocked() + retained_events = [event for event in events if event.get("eventId") != event_id] + catalog_deleted = len(retained_events) != len(events) + if catalog_deleted: + _save_sentry_event_catalog_unlocked(retained_events) - cleared_latest = isinstance(current_event, dict) and str(current_event.get("eventId") or "") == event_id - if cleared_latest: - params.remove("SentryModeLastEvent") - - if not deleted_storage and not cleared_latest: + if not deleted_storage and not catalog_deleted: return jsonify({"error": "Sentry event not found."}), 404 + if current_event_deleted: + if retained_events: + params.put("SentryModeLastEvent", json.dumps(retained_events[0], separators=(",", ":"))) + else: + params.remove("SentryModeLastEvent") + return jsonify({"deleted": True, "eventId": event_id}) @app.route("/api/sentry/images//", methods=["GET"]) @@ -7199,6 +7320,7 @@ def setup(app): def capture_and_publish(): event["imagePaths"] = _capture_sentry_test_images(event_id) + _record_sentry_event(event) params.put("SentryModeLastEvent", json.dumps(event, separators=(",", ":"))) threading.Thread(target=_dispatch_sentry_event, args=(event,), name="galaxy-sentry-test-notify", daemon=True).start() @@ -7214,6 +7336,7 @@ def setup(app): if event is None: return jsonify({"error": "Invalid sentry event."}), 400 + _record_sentry_event(event) params.put("SentryModeLastEvent", json.dumps(event, separators=(",", ":"))) if request.args.get("blocking") == "1": _dispatch_sentry_event(event) diff --git a/system/sentryd/sentryd.py b/system/sentryd/sentryd.py index b1997c84e..4c5ab7fb1 100644 --- a/system/sentryd/sentryd.py +++ b/system/sentryd/sentryd.py @@ -33,7 +33,6 @@ MAX_WARNING_TIME_SECONDS = 10.0 ALARM_TRIGGER_COUNT = 25 ALARM_TIME_SECONDS = 30.0 RESET_TIME_SECONDS = 60.0 -MAX_EVENT_DIRECTORIES = 100 def event_root() -> Path: @@ -137,19 +136,6 @@ class SentryMode: paths.append(str(front_path)) return paths - def _trim_old_events(self) -> None: - root = event_root() - if not root.exists(): - return - try: - directories = sorted((path for path in root.iterdir() if path.is_dir()), key=lambda path: path.stat().st_mtime) - for directory in directories[:-MAX_EVENT_DIRECTORIES]: - for child in directory.iterdir(): - child.unlink(missing_ok=True) - directory.rmdir() - except OSError: - cloudlog.exception("sentryd: failed to trim old events") - def _publish_event(self, event: dict) -> None: self.params.put("SentryModeLastEvent", event) self._write_status(event["kind"], eventId=event["eventId"]) @@ -182,8 +168,6 @@ class SentryMode: } if kind in {"warning", "alarm"}: event["imagePaths"] = self._capture_images(event_id) - if kind == "alarm": - self._trim_old_events() self._publish_event(event) def update(self) -> None: