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: