// screens-cameras-activity.jsx — the Activity tab of the Cameras page. // // Mirrors ui/screens-cameras-avatars.jsx for structure: plain window.* // component library, window.api.get/post, useLive polling, no build step // (Babel-in-browser). Exposes window.ActivityTab, rendered by // screens-cameras.jsx's tab switcher with { site, cameras, aliasOptions, // notify } (only { site } is guaranteed — cameras are re-fetched here when // absent). // // Sections (plan P10): StreamsPanel (activity-log stream table + create/edit // modal with the WatchesTab camera picker), LogBrowser (stream + day selector, // expandable journal entries with composite-sheet thumbnails that hide // themselves once media retention reaps the file), HoursPanel (hourly // rollups), DaysPanel (daily reports: status, share-link copy, generate / // regenerate). // // Data lives behind /ui/api/cameras/activity/* (camera_observer_http.go) — // admin-gated like the rest of the panel; the session cookie covers // requests to /camera/media/. Everything polls at 30s — the journal // only moves every 1–15 minutes. (() => { const { useState, useEffect, useMemo, useCallback } = React; const { Icon, api, useLive, timeAgo, Button, IconButton, Card, Field, Input, Select, Switch, Checkbox, Badge, Modal, useToast, EmptyState, CopyButton, } = window; /* ───────────────────────────────────────────────────────────── Helpers ───────────────────────────────────────────────────────────── */ function mediaURL(token) { return token ? "/camera/media/" + token : ""; } function ago(ts) { if (!ts) return "—"; const t = Date.parse(ts); return isNaN(t) ? ts : timeAgo(t); } // clock renders an RFC3339 UTC instant as browser-local "HH:MM" — journal // windows are stored UTC; operators think in wall-clock time. function clock(ts) { if (!ts) return "—"; const t = Date.parse(ts); return isNaN(t) ? ts : new Date(t).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" }); } // hourLabel renders an hour boundary with its date ("Jul 11, 14:00"). function hourLabel(ts) { if (!ts) return "—"; const t = Date.parse(ts); if (isNaN(t)) return ts; const d = new Date(t); return d.toLocaleDateString([], { month: "short", day: "numeric" }) + ", " + d.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" }); } // todayLocalDay: the browser-local calendar date as "YYYY-MM-DD" — the day // picker's default and the generate button's default date. function todayLocalDay() { const d = new Date(); const pad = (n) => String(n).padStart(2, "0"); return d.getFullYear() + "-" + pad(d.getMonth() + 1) + "-" + pad(d.getDate()); } // dayWindowUTC maps a local "YYYY-MM-DD" day onto the UTC RFC3339 window the // entries route filters from_ts with (seconds precision, matching the store's // stored format so the string comparisons line up). function dayWindowUTC(day) { const start = new Date(day + "T00:00:00"); if (isNaN(start.getTime())) return null; const iso = (d) => d.toISOString().replace(/\.\d+Z$/, "Z"); return { from: iso(start), to: iso(new Date(start.getTime() + 86399000)) }; } const ROLLUP_STATUS_TONE = { pending: "info", done: "success", empty: "neutral", error: "danger" }; function RollupStatusBadge({ status }) { return {status || "—"}; } /* ───────────────────────────────────────────────────────────── STREAMS — table + create/edit modal (WatchFormModal clone) ───────────────────────────────────────────────────────────── */ function StreamFormModal({ open, stream, siteId, cameras, aliasOptions, notify, onClose, onSaved }) { const blank = { name: "", camera_ids: [], interval_minutes: 5, frame_step_seconds: 30, analysis_alias: "", active_hours: "", motion_gate: true, daily_report_time: "20:00", enabled: false, }; const [form, setForm] = useState(blank); const [saving, setSaving] = useState(false); useEffect(() => { if (!open) return; if (stream) { setForm({ name: stream.name || "", camera_ids: stream.camera_ids || [], interval_minutes: stream.interval_minutes || 5, frame_step_seconds: stream.frame_step_seconds || 30, analysis_alias: stream.analysis_alias || "", active_hours: stream.active_hours || "", motion_gate: stream.motion_gate !== false, daily_report_time: stream.daily_report_time || "20:00", enabled: !!stream.enabled, }); } else { setForm(blank); } }, [open, stream]); const toggleCamera = (id) => { setForm((f) => ({ ...f, camera_ids: f.camera_ids.includes(id) ? f.camera_ids.filter((x) => x !== id) : [...f.camera_ids, id], })); }; const save = useCallback(async () => { const name = form.name.trim(); if (!name) { notify("Name is required", "error"); return; } setSaving(true); try { const body = { name, camera_ids: form.camera_ids, interval_minutes: Number(form.interval_minutes) || 5, frame_step_seconds: Number(form.frame_step_seconds) || 30, analysis_alias: form.analysis_alias, active_hours: form.active_hours, motion_gate: !!form.motion_gate, daily_report_time: form.daily_report_time, }; let path; if (stream) { path = "/ui/api/cameras/activity/streams/update"; body.id = stream.id; } else { path = "/ui/api/cameras/activity/streams"; body.site_id = siteId; body.enabled = form.enabled; } const res = await api.post(path, body); if (!res || !res.ok) { notify((res && res.error) || "Save failed", "error"); return; } // enabled is toggle-only on the update route (the watch semantics) — // flip it separately when the switch changed this session. if (stream && form.enabled !== !!stream.enabled) { await api.post("/ui/api/cameras/activity/streams/toggle", { id: stream.id, enabled: form.enabled }); } notify(stream ? "Stream updated" : "Stream created", "success"); onSaved(); } catch (e) { notify("Save failed: " + ((e && e.message) || e), "error"); } finally { setSaving(false); } }, [form, stream, siteId, notify, onSaved]); return ( } > setForm((f) => ({ ...f, name: e.target.value }))} placeholder="Reception journal" disabled={saving} />
{cameras.length === 0 ? (

No cameras discovered yet.

) : cameras.map((c) => ( toggleCamera(c.id)} label={c.name + (c.area ? " · " + c.area : "")} disabled={saving} /> ))}
setForm((f) => ({ ...f, interval_minutes: e.target.value }))} disabled={saving} /> setForm((f) => ({ ...f, frame_step_seconds: e.target.value }))} disabled={saving} /> setForm((f) => ({ ...f, daily_report_time: e.target.value }))} placeholder="20:00" disabled={saving} />
setForm((f) => ({ ...f, active_hours: e.target.value }))} placeholder="07:00-22:00" disabled={saving} /> setForm((f) => ({ ...f, motion_gate: v }))} label="Motion gate — write a cheap idle entry (no AI call) when armed cameras saw no motion" /> setForm((f) => ({ ...f, enabled: v }))} label="Enabled" />
); } function StreamsPanel({ site, streams, cameras, aliasOptions, notify, refresh }) { const [formOpen, setFormOpen] = useState(false); const [editing, setEditing] = useState(null); const [deleteId, setDeleteId] = useState(""); const [runningId, setRunningId] = useState(""); const runNow = async (id) => { setRunningId(id); try { const res = await api.post("/ui/api/cameras/activity/streams/run", { id }); if (!res || !res.ok) notify((res && res.error) || "Could not start the cycle", "error"); else notify("Cycle started — the entry lands in the journal below", "success"); } catch (e) { notify("Failed: " + ((e && e.message) || e), "error"); } finally { setRunningId(""); } }; const toggleStream = async (s) => { try { await api.post("/ui/api/cameras/activity/streams/toggle", { id: s.id, enabled: !s.enabled }); refresh(); } catch (e) { notify("Update failed: " + ((e && e.message) || e), "error"); } }; const deleteStream = async () => { try { await api.post("/ui/api/cameras/activity/streams/delete", { id: deleteId }); notify("Stream deleted", "success"); setDeleteId(""); refresh(); } catch (e) { notify("Delete failed: " + ((e && e.message) || e), "error"); } }; return ( <> { setEditing(null); setFormOpen(true); }}>New stream} flush > {streams.length === 0 ? ( ) : (
{streams.map((s) => ( ))}
NameCamerasIntervalGateReportLast runEnabled
{s.name || "Untitled stream"} {s.last_error &&
{s.last_error}
}
{(s.camera_ids || []).length || "all"} {s.interval_minutes}m / {s.frame_step_seconds}s {s.motion_gate ? "motion" : "always"} {s.daily_report_time || "20:00"} {s.last_run_at ? ago(s.last_run_at) : "—"} toggleStream(s)} /> { setEditing(s); setFormOpen(true); }} /> setDeleteId(s.id)} />
)}
setFormOpen(false)} onSaved={() => { setFormOpen(false); refresh(); }} /> setDeleteId("")} title="Delete this stream?" description="This stops the journal for this stream. Logged entries and reports are kept." footer={
} /> ); } /* ───────────────────────────────────────────────────────────── LOG BROWSER — stream + day selector, expandable entries ───────────────────────────────────────────────────────────── */ // SheetThumb hides itself when the capture behind the token has been reaped // by media retention — old entries keep their text, not their thumbnails. function SheetThumb({ token, label }) { const [dead, setDead] = useState(false); if (dead) return null; return (
{label} setDead(true)} /> {label}
); } function EntryRow({ entry, camerasById }) { const [open, setOpen] = useState(false); const sheets = Array.isArray(entry.media_tokens) ? entry.media_tokens : []; const details = Array.isArray(entry.detail_tokens) ? entry.detail_tokens : []; const cams = (entry.camera_ids || []).map((id) => (camerasById[id] ? camerasById[id].name : id)); return (
setOpen((o) => !o)}>

{entry.headline || "(no headline)"}

{clock(entry.from_ts)} – {clock(entry.to_ts)} {entry.motion_gated ? idle : {entry.frames || 0} frames} {cams.length > 0 && {cams.join(", ")}} {entry.error && {entry.error}}
{open && ( <>

{entry.report || "(no report)"}

{(sheets.length > 0 || details.length > 0) && (
{sheets.map((t, i) => )} {details.map((t, i) => )}
)} )}
); } function LogBrowser({ site, streams, camerasById, streamId, setStreamId }) { const [day, setDay] = useState(todayLocalDay()); useEffect(() => { setDay(todayLocalDay()); }, [site.id]); const win = dayWindowUTC(day); const entriesLive = useLive( win ? `/ui/api/cameras/activity/entries?site_id=${encodeURIComponent(site.id)}&stream_id=${encodeURIComponent(streamId)}&from=${encodeURIComponent(win.from)}&to=${encodeURIComponent(win.to)}&limit=300` : "", { interval: 30000, enabled: !!site.id && !!win } ); const entries = (entriesLive.data && entriesLive.data.entries) || []; const streamOptions = [ { value: "", label: "All streams" }, ...streams.map((s) => ({ value: s.id, label: s.name || s.id })), ]; return ( setDay(e.target.value)} /> } flush > {entries.length === 0 ? ( ) : (
{entries.map((e) => )}
)}
); } /* ───────────────────────────────────────────────────────────── HOURLY SUMMARIES + DAILY REPORTS ───────────────────────────────────────────────────────────── */ function HoursPanel({ site, streamId }) { const hoursLive = useLive( `/ui/api/cameras/activity/hours?site_id=${encodeURIComponent(site.id)}&stream_id=${encodeURIComponent(streamId)}&limit=48`, { interval: 30000, enabled: !!site.id } ); const hours = (hoursLive.data && hoursLive.data.hours) || []; return ( {hours.length === 0 ? ( ) : (
{hours.map((h) => ( ))}
HourStatusEntriesSummary
{hourLabel(h.hour_start)} {h.error &&
{h.error}
}
{h.entry_count}{h.gated_count ? ` (${h.gated_count} idle)` : ""} {h.summary || }
)}
); } function DaysPanel({ site, streams, streamId, notify }) { const daysLive = useLive( `/ui/api/cameras/activity/days?site_id=${encodeURIComponent(site.id)}&stream_id=${encodeURIComponent(streamId)}&limit=60`, { interval: 30000, enabled: !!site.id } ); const days = (daysLive.data && daysLive.data.days) || []; const [genDate, setGenDate] = useState(todayLocalDay()); const [genBusy, setGenBusy] = useState(""); const streamName = useCallback((id) => { const s = streams.find((x) => x.id === id); return s ? (s.name || s.id) : id; }, [streams]); // The header "Generate now" needs a concrete stream: the journal filter // selection, or the only stream when the site has exactly one. const genStreamId = streamId || (streams.length === 1 ? streams[0].id : ""); // Generation runs a text-only model call inside the request — give it a // generous timeout instead of the 10s default. const generate = async (sid, date, force) => { setGenBusy(sid + "|" + date); try { const res = await api.post("/ui/api/cameras/activity/days/generate", { stream_id: sid, date, force }, { timeoutMs: 180000 }); if (!res || !res.ok) { notify((res && res.error) || "Generate failed", "error"); return; } const rep = res.report || {}; notify(rep.status === "empty" ? "Report generated — no entries that day" : "Daily report generated", "success"); daysLive.refresh(); } catch (e) { notify("Generate failed: " + ((e && e.message) || e), "error"); } finally { setGenBusy(""); } }; return ( setGenDate(e.target.value)} /> {genStreamId ? ( ) : ( Pick a stream in the journal to generate )} } flush > {days.length === 0 ? ( ) : (
{days.map((d) => ( ))}
DayStreamStatusReportShare
{d.day} {streamName(d.stream_id)} {d.error &&
{d.error}
}
{d.headline || } {d.report && (
full report

{d.report}

)}
{d.view_url ? ( <> window.open(d.view_url, "_blank", "noopener")} /> ) : ( )}
)}
); } /* ───────────────────────────────────────────────────────────── Tab root ───────────────────────────────────────────────────────────── */ function ActivityTab({ site, cameras: camerasProp, aliasOptions: aliasProp, notify: notifyProp }) { const toast = useToast(); const fallbackNotify = useCallback((msg, tone) => { if (!toast) return; if (tone === "error") toast.error(msg); else if (tone === "success") toast.success(msg); else toast.info(msg); }, [toast]); const notify = notifyProp || fallbackNotify; const streamsLive = useLive(`/ui/api/cameras/activity/streams?site_id=${encodeURIComponent(site.id)}`, { interval: 30000, enabled: !!site.id }); const streams = (streamsLive.data && streamsLive.data.streams) || []; // cameras usually arrive as a prop from the Cameras screen; fetch them // here only when this tab is mounted standalone (the AvatarsTab pattern). const needCameras = !Array.isArray(camerasProp); const camerasLive = useLive(`/ui/api/cameras/list?site_id=${encodeURIComponent(site.id)}`, { interval: 30000, enabled: !!site.id && needCameras }); const cameras = needCameras ? ((camerasLive.data && camerasLive.data.cameras) || []) : camerasProp; const camerasById = useMemo(() => { const m = {}; cameras.forEach((c) => { m[c.id] = c; }); return m; }, [cameras]); const aliasOptions = Array.isArray(aliasProp) ? aliasProp : []; // The journal's stream filter also scopes the hours/days panels and the // header "Generate now" target. const [streamId, setStreamId] = useState(""); useEffect(() => { setStreamId(""); }, [site.id]); return (
); } window.ActivityTab = ActivityTab; })();