Sentry UI

Expose Sentry events and notification presentation through Galaxy.

Co-authored-by: Prabhaav Pillai <prabhaav@msu.edu>
Co-authored-by: dirwin31 <83434411+dirwin31@users.noreply.github.com>
This commit is contained in:
firestar5683
2026-09-25 14:53:21 -05:00
parent e52e1a6a86
commit 14e42178df
4 changed files with 293 additions and 0 deletions
@@ -0,0 +1,68 @@
"""Bounded projection of local Sentry motion metadata."""
import json
import re
from collections.abc import Callable
from typing import Protocol, cast
from openpilot.starpilot.sentry_mode.storage import EventStore, StorageUnavailable
MAX_RESPONSE_BYTES = 128 * 1024
ID = re.compile(r"[0-9a-f]{32}\Z")
class SentryEventsUnavailable(Exception):
pass
class EventSnapshotSource(Protocol):
def snapshot(self) -> dict: ...
class EventDeletionSource(Protocol):
def delete(self, event_id: str | None = None, *, permitted: Callable[[], bool]) -> dict: ...
class SentryEvents:
def __init__(self, store: EventSnapshotSource | None = None):
self.store = store if store is not None else EventStore()
def snapshot(self) -> dict:
try:
source = self.store.snapshot()
if (type(source) is not dict or type(source.get("version")) is not int or source["version"] != 1 or
type(source.get("incomplete")) is not bool or type(source.get("capacity")) is not int or source["capacity"] != 512 or
type(source.get("events")) is not list or len(source["events"]) > 512):
raise ValueError("Invalid motion inventory")
events = []
for value in source["events"]:
if (type(value) is not dict or type(value.get("eventId")) is not str or
ID.fullmatch(value["eventId"]) is None or type(value.get("kind")) is not str or
value["kind"] not in ("warning", "alarm", "selfie") or
type(value.get("wallTimeNs")) is not int or not 0 < value["wallTimeNs"] < 2**63):
raise ValueError("Invalid motion event")
events.append({"eventId": value["eventId"], "kind": value["kind"],
"systemTimeMs": value["wallTimeNs"] // 1_000_000,
"images": cast(Callable[[str], list], self.store.images)(value["eventId"]) if hasattr(self.store, "images") else []})
result = {"schemaVersion": 1, "source": "local", "scanIncomplete": source["incomplete"],
"capacity": source["capacity"], "events": events,
"storageBytes": source.get("storageBytes", 0), "storageIncomplete": source.get("storageIncomplete", False)}
if len(json.dumps(result, separators=(",", ":")).encode()) > MAX_RESPONSE_BYTES:
raise ValueError("Motion response exceeds limit")
return result
except (StorageUnavailable, OSError, ValueError, TypeError, OverflowError, RecursionError) as error:
raise SentryEventsUnavailable from error
def image(self, event_id, camera):
try:
return self.store.image(event_id, camera)
except (OSError, StorageUnavailable, AttributeError) as error:
raise SentryEventsUnavailable from error
def delete(self, event_id=None, *, permitted=lambda: True):
try:
result = cast(EventDeletionSource, self.store).delete(event_id, permitted=permitted)
return {**self.snapshot(), **result}
except (StorageUnavailable, OSError, AttributeError) as error:
raise SentryEventsUnavailable from error
@@ -0,0 +1,108 @@
import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs'
import { SentryEventsFeed, SentryEventsPage, validSentryEvents } from '../web/js/sentry.js'
import { CamerasPage } from '../web/js/cameras.js'
const flush = async () => { for (let i = 0; i < 8; i++) await Promise.resolve() }
const eventId = 'a'.repeat(32)
const inventory = { schemaVersion: 1, source: 'local', scanIncomplete: false, capacity: 512,
events: [{ eventId, kind: 'warning', systemTimeMs: 1_700_000_000_000 }] }
assert.equal(validSentryEvents(inventory), true)
assert.equal(validSentryEvents({ ...inventory, events: [{ ...inventory.events[0], images: ['wide', 'cabin'] }] }), true)
assert.equal(validSentryEvents({ ...inventory, events: [{ ...inventory.events[0], images: ['../../secret'] }] }), false)
assert.equal(validSentryEvents({ ...inventory, events: [{ ...inventory.events[0], kind: 'armed' }] }), false)
assert.equal(validSentryEvents({ ...inventory, events: [{ ...inventory.events[0], path: '/secret' }] }), false)
assert.equal(validSentryEvents({ ...inventory, events: [inventory.events[0], inventory.events[0]] }), false)
assert.equal(validSentryEvents({ ...inventory, source: 'remote' }), false)
assert.match(SentryEventsPage.template, /device clock and may be inaccurate/)
assert.match(SentryEventsPage.template, /<img[^>]*event.images[^>]*api\/sentry\/image\//)
assert.doesNotMatch(SentryEventsPage.template, /v-html|deleteEvent|Download/)
assert.match(CamerasPage.template, /View motion events/)
const app = readFileSync(new URL('../web/js/app.js', import.meta.url), 'utf8')
assert.match(app, /SentryEventsPage[^>]*\/cameras\/events[^>]*\/cameras\/sentry-settings/)
function fixture() {
const requests = [], states = [], timers = new Map()
let next = 0, revoked = 0
const feed = new SentryEventsFeed({ publish: (state) => states.push(state), unauthorized: () => revoked++,
fetcher: (url, options) => new Promise((resolve) => requests.push({ url, options, resolve })),
later: (fn, ms) => { timers.set(++next, { fn, ms }); return next }, cancelTimer: (id) => timers.delete(id) })
async function reply(index, body, code = 200) {
requests[index].resolve({ status: code, ok: code === 200, json: async () => body })
await flush()
}
return { feed, requests, states, timers, reply, get revoked() { return revoked } }
}
const normal = fixture()
normal.feed.start()
assert.equal(normal.requests[0].url, './api/sentry/events')
assert.equal(normal.requests[0].options.credentials, 'same-origin')
await normal.reply(0, inventory)
assert.equal(normal.feed.status, 'ready')
await flush()
assert.equal(normal.requests.length, 1, 'no repeated storage scan without manual refresh')
normal.feed.load()
await normal.reply(1, { ...inventory, events: [] })
assert.equal(normal.feed.data.events.length, 0)
normal.feed.stop()
assert.equal(normal.feed.data, null)
const auth = fixture()
auth.feed.start()
await auth.reply(0, { code: 'setup_required' }, 503)
assert.equal(auth.revoked, 1)
assert.equal(auth.feed.data, null)
const loggedOut = fixture()
loggedOut.feed.start()
await loggedOut.reply(0, {}, 401)
assert.equal(loggedOut.revoked, 1)
const unavailable = fixture()
unavailable.feed.start()
await unavailable.reply(0, { code: 'backend_unavailable' }, 503)
assert.equal(unavailable.revoked, 0)
assert.equal(unavailable.feed.status, 'unavailable')
const timedOut = fixture()
timedOut.feed.start()
assert.deepEqual([...timedOut.timers.values()].map((timer) => timer.ms), [10000, 4000])
const [timeoutId, timeout] = [...timedOut.timers.entries()].find(([, timer]) => timer.ms === 4000)
timedOut.timers.delete(timeoutId)
timeout.fn()
assert.equal(timedOut.feed.status, 'unavailable')
timedOut.feed.load()
assert.equal(timedOut.requests.length, 2)
await timedOut.reply(1, inventory)
await timedOut.reply(0, { ...inventory, events: [] })
assert.equal(timedOut.feed.data.events.length, 1)
const lateBody = fixture()
lateBody.feed.start()
let resolveBody
lateBody.requests[0].resolve({ status: 200, ok: true, json: () => new Promise((resolve) => { resolveBody = resolve }) })
await flush()
lateBody.feed.stop()
lateBody.feed.start()
await lateBody.reply(1, inventory)
resolveBody({ ...inventory, events: [] })
await flush()
assert.equal(lateBody.feed.data.events.length, 1)
const priorDocument = globalThis.document
const listeners = new Map()
globalThis.document = { hidden: true, addEventListener: (name, cb) => listeners.set(name, cb),
removeEventListener: (name, cb) => { if (listeners.get(name) === cb) listeners.delete(name) } }
const page = { mode: 'local', $data: { status: 'idle', data: null, error: '' }, unauthorized() { throw new Error('revoked') } }
SentryEventsPage.created.call(page)
const pending = []
page.feed.fetcher = (url, options) => new Promise((resolve) => pending.push({ url, options, resolve }))
SentryEventsPage.mounted.call(page)
assert.equal(pending.length, 0)
document.hidden = false
listeners.get('visibilitychange')()
assert.equal(pending.length, 1)
document.hidden = true
listeners.get('visibilitychange')()
assert.equal(page.$data.data, null)
SentryEventsPage.beforeUnmount.call(page)
assert.equal(listeners.size, 0)
globalThis.document = priorDocument
@@ -0,0 +1,59 @@
"""Local motion records are projected without exposing storage or session details."""
from pathlib import Path
import tempfile
import unittest
from openpilot.starpilot.galaxy.sentry_events import SentryEvents, SentryEventsUnavailable
from openpilot.starpilot.sentry_mode.storage import EventStore
class SentryEventsTest(unittest.TestCase):
def test_actual_store_projection_and_empty_store(self):
with tempfile.TemporaryDirectory() as directory:
store = EventStore(Path(directory).resolve() / "events")
self.assertEqual(SentryEvents(store).snapshot()["events"], [])
receipt = store.record("warning", 123, permitted=lambda: True)
result = SentryEvents(store).snapshot()
self.assertEqual(result["schemaVersion"], 1)
self.assertEqual(result["source"], "local")
self.assertEqual(result["capacity"], 512)
self.assertEqual(result["events"][0]["eventId"], receipt.event_id)
self.assertEqual(set(result["events"][0]), {"eventId", "kind", "systemTimeMs", "images"})
self.assertEqual(result["events"][0]["kind"], "warning")
self.assertEqual(result["events"][0]["images"], [])
def test_published_images_project_after_restart_and_use_same_event_id(self):
with tempfile.TemporaryDirectory() as directory:
root = Path(directory).resolve() / "events"
image = b"\xff\xd8fixture\xff\xd9"
receipt = EventStore(root).record("alarm", 456, permitted=lambda: True, images={"cabin": image})
projection = SentryEvents(EventStore(root))
event = projection.snapshot()["events"][0]
self.assertEqual(event["eventId"], receipt.event_id)
self.assertEqual(event["images"], ["cabin"])
self.assertEqual(projection.image(event["eventId"], "cabin"), image)
with self.assertRaises(SentryEventsUnavailable):
projection.image(event["eventId"], "wide")
def test_invalid_or_oversized_snapshot_is_unavailable(self):
class Fixture:
def __init__(self, value):
self.value = value
def snapshot(self):
return self.value
base = {"version": 1, "incomplete": False, "capacity": 512, "events": []}
event = {"eventId": "a" * 32, "kind": "alarm", "wallTimeNs": 123_000_000}
for value in ({**base, "version": True}, {**base, "capacity": True},
{**base, "events": [event] * 513},
{**base, "events": [{**event, "kind": "unknown"}]},
{**base, "events": [{**event, "wallTimeNs": -1}]}):
with self.subTest(value=value):
with self.assertRaises(SentryEventsUnavailable):
SentryEvents(Fixture(value)).snapshot()
if __name__ == "__main__":
unittest.main()
@@ -0,0 +1,58 @@
import assert from 'node:assert/strict'
import { readFileSync } from 'node:fs'
import { runInNewContext } from 'node:vm'
import { NotificationClient, SentryNotifications, validNotifications, channels } from '../web/js/sentry-notifications.js'
const fixture = () => ({ schemaVersion: 1, queueFull: false, subscriptionCount: 0, subscriptions: [],
deliverySemantics: 'Retries may repeat delivery after an interrupted remote response.',
channels: Object.fromEntries(channels.map(name => [name, { enabled: false, configured: false, pending: 0, lastState: 'idle', lastError: '' }])) })
const status = fixture()
assert.equal(validNotifications(status), true)
assert.equal(validNotifications({ ...status, subscriptionCount: 17 }), false)
assert.equal(validNotifications({ ...status, channels: { ...status.channels, ntfy: { ...status.channels.ntfy, lastError: 'secret-url' } } }), false)
assert.match(SentryNotifications.template, /Send test/)
assert.match(SentryNotifications.template, /Forget configuration/)
assert.match(SentryNotifications.template, /type="password"/)
assert.doesNotMatch(SentryNotifications.template, /v-html/)
const requests = [], states = []; let revoked = 0
const client = new NotificationClient({ publish: value => states.push(value), unauthorized: () => revoked++,
fetcher: (url, options) => new Promise(resolve => requests.push({ url, options, resolve })) })
const first = client.run({ action: 'test', channel: 'ntfy' })
assert.equal(requests[0].options.credentials, 'same-origin')
assert.equal(JSON.parse(requests[0].options.body).channel, 'ntfy')
requests[0].resolve({ status: 200, ok: true, json: async () => status })
assert.equal(await first, true)
const pollStart = states.length
const stale = client.run()
assert.equal(states.slice(pollStart).some(state => state.notificationBusy === true), false, "passive refresh keeps buttons stable")
client.stop()
requests[1].resolve({ status: 200, ok: true, json: async () => status })
assert.equal(await stale, false)
const unauthorized = client.run()
requests[2].resolve({ status: 401, ok: false, json: async () => ({}) })
assert.equal(await unauthorized, false)
assert.equal(revoked, 1)
const invalid = client.run()
requests[3].resolve({ status: 200, ok: true, json: async () => ({ privateToken: 'bad' }) })
assert.equal(await invalid, false)
assert.match(states.findLast(state => state.notificationError)?.notificationError, /unavailable/)
const worker = readFileSync(new URL('../web/sentry-push-worker.js', import.meta.url), 'utf8')
assert.match(worker, /\.\/\#\/cameras\/events/)
assert.match(worker, /tag: id/)
// Push stays registered, but stale offline app code must no longer intercept navigation.
assert.doesNotMatch(worker, /addEventListener\("fetch"/)
assert.match(worker, /galaxy-offline-v1/)
const handlers = new Map(), removed = []
let claims = 0, activation
runInNewContext(worker, {
self: { addEventListener: (name, fn) => handlers.set(name, fn),
clients: { claim: async () => { claims++ } }, skipWaiting() {} },
caches: { delete: async name => { removed.push(name); return true } },
})
handlers.get('activate')({ waitUntil: promise => { activation = promise } })
await activation
assert.deepEqual(removed, ['galaxy-offline-v1'])
assert.equal(claims, 1)
assert.equal(handlers.has('fetch'), false)
assert.equal(handlers.has('push'), true)
assert.equal(handlers.has('notificationclick'), true)
console.log('Sentry notification browser client and worker checks passed')