mirror of
https://github.com/firestar5683/StarPilot.git
synced 2026-08-31 21:23:49 +08:00
sentry
This commit is contained in:
@@ -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>
|
||||
`
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user