mirror of
https://github.com/firestar5683/StarPilot.git
synced 2026-10-28 00:53:45 +08:00
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:
@@ -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')
|
||||
Reference in New Issue
Block a user