Files
StarPilot/openpilot/starpilot/galaxy/web/js/sentry.js
T
Danny d08cd1b08b Galaxy: Add remote keys and app Sentry notifications
Serve the Galaxy app key and its QR code from /api/galaxy/session to direct local connections so the Android app can import the tunnel key on its own. Let the app and the iOS app register for Sentry push alerts.
2026-10-27 02:46:50 -04:00

93 lines
6.2 KiB
JavaScript

import { GxState } from "./state.js"
import { SettingsPage } from "./settings.js"
import { GxNotice } from "./notice.js"
import { BluetoothNotice, bluetoothConnection } from "./bluetooth-notice.js"
import { SentryNotifications } from "./sentry-notifications.js"
import { SnapshotFeed } from "./snapshot-feed.js"
import { deleteRecords, storedSize } from "./record-deletion.js"
// Historical motion records and independently configured notifications.
const ID = /^[0-9a-f]{32}$/
export function validSentryEvents(value) {
if (value?.schemaVersion !== 1 || value.source !== "local" || typeof value.scanIncomplete !== "boolean" ||
value.capacity !== 512 || !Array.isArray(value.events) || value.events.length > 512) return false
if (value.storageBytes !== undefined && (!Number.isSafeInteger(value.storageBytes) || value.storageBytes < 0)) return false
if (value.storageIncomplete !== undefined && typeof value.storageIncomplete !== "boolean") return false
const seen = new Set()
return value.events.every((event) => {
if (event === null || typeof event !== "object" || Object.keys(event).some((key) => !["eventId", "kind", "systemTimeMs", "images", "simulated"].includes(key)) ||
(event.simulated !== undefined && event.simulated !== true) ||
(event.images !== undefined && (!Array.isArray(event.images) || event.images.length > 2 ||
new Set(event.images).size !== event.images.length || event.images.some((camera) => !["wide", "cabin"].includes(camera)))) ||
typeof event.eventId !== "string" || !ID.test(event.eventId) || seen.has(event.eventId) ||
!["warning", "alarm", "selfie"].includes(event.kind) || !Number.isSafeInteger(event.systemTimeMs) ||
event.systemTimeMs <= 0 || event.systemTimeMs > 8_640_000_000_000_000) return false
seen.add(event.eventId)
return true
})
}
export const systemTime = (milliseconds) => Number.isSafeInteger(milliseconds) && milliseconds > 0 && milliseconds <= 8_640_000_000_000_000
? new Date(milliseconds).toLocaleString() : "Unavailable"
export class SentryEventsFeed extends SnapshotFeed {
static endpoint = "./api/sentry/events"
static valid = validSentryEvents
static subject = "local motion events"
static unavailable = "Local motion events are unavailable."
static invalid = "Local motion records are unavailable."
stop() { this.deleting = false; super.stop() }
emit() { this.publish({ status: this.status, data: this.data, error: this.error, deleting: this.deleting === true }) }
remove(eventId, confirm) {
if (eventId != null && !ID.test(eventId)) return Promise.resolve(false)
return deleteRecords(this, "./api/sentry/events/delete", eventId == null ? { all: true, confirmed: true } : { eventId }, {
valid: validSentryEvents, confirm, subject: "Sentry events",
accept: data => { this.data = data; this.status = "ready"; this.error = ""; this.emit() },
})
}
}
export const SentryEventsPage = {
name: "SentryEventsPage",
components: { GxState, GxNotice, BluetoothNotice, SentryNotifications, SettingsPage },
props: { mode: { type: String, required: true }, unauthorized: { type: Function, required: true },
go: { type: Function, required: true } },
data: () => ({ bluetooth: bluetoothConnection(), status: "idle", data: null, error: "", deleting: false }),
created() { this.feed = new SentryEventsFeed({ publish: (update) => Object.assign(this.$data, update), unauthorized: this.unauthorized }) },
mounted() {
this.visibility = () => { if (document.hidden) this.feed.stop(); else if (this.mode === "local") this.feed.start() }
document.addEventListener("visibilitychange", this.visibility)
if (this.mode === "local" && !document.hidden) this.feed.start()
},
beforeUnmount() { document.removeEventListener("visibilitychange", this.visibility); this.feed.stop() },
methods: { systemTime, storedSize },
template: `
<div class="gx-view gx-home" aria-label="Sentry motion events">
<header class="gx-settings__header gx-page-header"><div><h2>Sentry</h2>
<p>Review motion events, configure motion monitoring, and manage notifications. Timestamps use the device clock and may be inaccurate.</p></div></header>
<SettingsPage :mode="mode" :unauthorized="unauthorized" initial-page="sentry" title="" />
<SentryNotifications :mode="mode" :unauthorized="unauthorized" />
<GxState v-if="mode !== 'local'">Local motion records are unavailable in preview.</GxState>
<template v-else>
<GxState v-if="status === 'loading'" loading>Reading local motion events…</GxState>
<GxNotice tone="danger" v-if="error">{{ error }}</GxNotice>
<template v-if="status === 'ready' && data">
<div class="gx-actions"><p>{{ data.events.length }} events · {{ data.storageIncomplete ? 'At least ' : '' }}{{ storedSize(data.storageBytes) }} stored</p><button type="button" class="gx-btn" :disabled="deleting" @click="feed.remove(null)">Delete All</button></div>
<GxNotice v-if="data.scanIncomplete" tone="warn">This scan was incomplete. More local motion events may exist.</GxNotice>
<GxState v-if="!data.events.length">No local motion events found in this scan.</GxState>
<BluetoothNotice v-if="bluetooth && data.events.some(event => event.images?.length)" feature="Motion event images" />
<section v-for="event in data.events" :key="event.eventId" class="gx-card gx-home__card">
<div class="gx-section__header"><i class="bi" :class="event.kind === 'selfie' ? 'bi-camera' : event.kind === 'alarm' ? 'bi-bell' : 'bi-exclamation-triangle'"></i><h2 class="gx-section__title">{{ event.kind === 'selfie' ? 'Selfie' : event.kind === 'alarm' ? 'Alarm' : 'Warning' }}</h2></div>
<p>System time: {{ systemTime(event.systemTimeMs) }}</p>
<small>Event ID: {{ event.eventId }}</small>
<button type="button" class="gx-btn" :disabled="deleting" @click="feed.remove(event.eventId)">Delete</button>
<template v-if="!bluetooth"><img v-for="camera in (event.images || [])" :key="camera" :src="'./api/sentry/image/' + event.eventId + '/' + camera" :alt="camera + ' camera at motion event'" class="gx-media" /></template>
</section>
</template>
</template>
</div>`,
}