This commit is contained in:
firestar5683
2026-08-21 18:44:11 -05:00
parent 2bd080821b
commit 05db925e93
4 changed files with 227 additions and 38 deletions
@@ -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;
@@ -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`<p class="sentry-empty">No Sentry events recorded yet.</p>`
return html`
@@ -202,6 +232,29 @@ function renderEvent() {
`
}
function renderHistory() {
if (!state.historyVisible) return ""
if (state.historyBusy) return html`<p class="sentry-loading">Loading Sentry history…</p>`
if (state.history.length === 0) return html`<p class="sentry-empty">No retained Sentry events.</p>`
return html`
<div class="sentry-history-list">
<p class="sentry-muted">${state.history.length} event${state.history.length === 1 ? "" : "s"} retained. Events stay here until you delete them.</p>
${state.history.map((event) => html`
<article class="sentry-history-event">
<div class="sentry-history-heading">
<strong>${event.detectedAt || "Sentry event"}</strong>
<button class="sentry-button sentry-button-danger" @click="${() => deleteEvent(event.eventId)}" disabled="${() => state.deleteBusy}">
${() => state.deleteBusy ? "Deleting…" : "Delete event"}
</button>
</div>
${renderEvent(event)}
</article>
`)}
</div>
`
}
function renderLiveCapture() {
const capture = state.liveCapture || {}
const imageUrls = Array.isArray(capture.imageUrls) ? capture.imageUrls : []
@@ -360,19 +413,23 @@ export function SentryMode() {
<p class="sentry-muted">Events refresh automatically every five seconds.</p>
</div>
<div class="sentry-action-row">
<button class="sentry-button sentry-button-secondary" @click="${toggleHistory}" disabled="${() => state.historyBusy}">
${() => state.historyBusy ? "Loading…" : state.historyVisible ? "Hide history" : "View history"}
</button>
${remote ? "" : html`
<button class="sentry-button sentry-button-secondary" @click="${sendTestEvent}" disabled="${() => state.testBusy}">
${() => state.testBusy ? "Capturing…" : "Send test capture"}
</button>
`}
${() => state.event?.eventId ? html`
<button class="sentry-button sentry-button-danger" @click="${deleteEvent}" disabled="${() => state.deleteBusy}">
<button class="sentry-button sentry-button-danger" @click="${() => deleteEvent(state.event.eventId)}" disabled="${() => state.deleteBusy}">
${() => state.deleteBusy ? "Deleting…" : "Delete event"}
</button>
` : ""}
</div>
</div>
${() => renderEvent()}
${() => renderHistory()}
</section>
</div>
`
+139 -16
View File
@@ -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/<event_id>", 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/<event_id>/<filename>", 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)
-16
View File
@@ -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: