Add smart alert profiles and recording timeline (Tasks 4-5)
Task 4 — Alert Profiles: presence-aware + time-of-day alert routing. Profiles match household state (EMPTY/KIDS_HOME/ADULTS_HOME/ALL_HOME) and time windows (sleep hours). Per-detection-type rules control push/record/quiet behavior with role-based recipients (all vs adults). Task 5 — Recording Timeline: canvas-based 24h timeline per camera with color-coded segments (person=red, vehicle=blue, motion=gray). Click-to-play, date picker, detection type filters, hour markers. Timeline API endpoint returns segments for a camera+date. All 5 daily-use feature tasks complete. 140 tests passing. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
8314a61815
commit
11d776faa6
77
tests/unit/test_profiles.py
Normal file
77
tests/unit/test_profiles.py
Normal file
@ -0,0 +1,77 @@
|
|||||||
|
"""Tests for smart alert profiles."""
|
||||||
|
|
||||||
|
from vigilar.alerts.profiles import match_profile, is_in_time_window, get_action_for_event
|
||||||
|
from vigilar.config import AlertProfileConfig, AlertProfileRule
|
||||||
|
from vigilar.constants import HouseholdState
|
||||||
|
|
||||||
|
|
||||||
|
def _make_profile(name, states, time_window="", rules=None):
|
||||||
|
return AlertProfileConfig(
|
||||||
|
name=name,
|
||||||
|
presence_states=states,
|
||||||
|
time_window=time_window,
|
||||||
|
rules=rules or [],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class TestTimeWindow:
|
||||||
|
def test_no_window_always_matches(self):
|
||||||
|
assert is_in_time_window("", "14:30") is True
|
||||||
|
|
||||||
|
def test_inside_window(self):
|
||||||
|
assert is_in_time_window("23:00-06:00", "02:30") is True
|
||||||
|
|
||||||
|
def test_outside_window(self):
|
||||||
|
assert is_in_time_window("23:00-06:00", "14:30") is False
|
||||||
|
|
||||||
|
def test_at_start(self):
|
||||||
|
assert is_in_time_window("23:00-06:00", "23:00") is True
|
||||||
|
|
||||||
|
def test_daytime_window(self):
|
||||||
|
assert is_in_time_window("06:00-23:00", "14:30") is True
|
||||||
|
|
||||||
|
|
||||||
|
class TestProfileMatching:
|
||||||
|
def test_match_by_presence(self):
|
||||||
|
profiles = [
|
||||||
|
_make_profile("Away", ["EMPTY"]),
|
||||||
|
_make_profile("Home", ["ADULTS_HOME", "ALL_HOME"]),
|
||||||
|
]
|
||||||
|
result = match_profile(profiles, HouseholdState.EMPTY, "14:00")
|
||||||
|
assert result is not None
|
||||||
|
assert result.name == "Away"
|
||||||
|
|
||||||
|
def test_no_match(self):
|
||||||
|
profiles = [_make_profile("Away", ["EMPTY"])]
|
||||||
|
result = match_profile(profiles, HouseholdState.ALL_HOME, "14:00")
|
||||||
|
assert result is None
|
||||||
|
|
||||||
|
def test_time_window_narrows(self):
|
||||||
|
profiles = [
|
||||||
|
_make_profile("Night", ["ALL_HOME"], "23:00-06:00"),
|
||||||
|
_make_profile("Day", ["ALL_HOME"], "06:00-23:00"),
|
||||||
|
]
|
||||||
|
result = match_profile(profiles, HouseholdState.ALL_HOME, "02:00")
|
||||||
|
assert result is not None
|
||||||
|
assert result.name == "Night"
|
||||||
|
|
||||||
|
|
||||||
|
class TestActionForEvent:
|
||||||
|
def test_person_gets_push(self):
|
||||||
|
rules = [AlertProfileRule(detection_type="person", action="push_and_record", recipients="all")]
|
||||||
|
profile = _make_profile("Away", ["EMPTY"], rules=rules)
|
||||||
|
action, recipients = get_action_for_event(profile, "person", "front_door")
|
||||||
|
assert action == "push_and_record"
|
||||||
|
assert recipients == "all"
|
||||||
|
|
||||||
|
def test_motion_gets_record_only(self):
|
||||||
|
rules = [AlertProfileRule(detection_type="motion", action="record_only", recipients="none")]
|
||||||
|
profile = _make_profile("Home", ["ALL_HOME"], rules=rules)
|
||||||
|
action, recipients = get_action_for_event(profile, "motion", "front_door")
|
||||||
|
assert action == "record_only"
|
||||||
|
|
||||||
|
def test_no_matching_rule_defaults_quiet(self):
|
||||||
|
profile = _make_profile("Home", ["ALL_HOME"], rules=[])
|
||||||
|
action, recipients = get_action_for_event(profile, "person", "front_door")
|
||||||
|
assert action == "quiet_log"
|
||||||
|
assert recipients == "none"
|
||||||
39
tests/unit/test_timeline.py
Normal file
39
tests/unit/test_timeline.py
Normal file
@ -0,0 +1,39 @@
|
|||||||
|
"""Tests for recording timeline."""
|
||||||
|
|
||||||
|
import time
|
||||||
|
|
||||||
|
from vigilar.storage.queries import get_timeline_data, insert_recording
|
||||||
|
|
||||||
|
|
||||||
|
class TestTimelineQuery:
|
||||||
|
def test_returns_recordings_for_day(self, test_db):
|
||||||
|
now = int(time.time())
|
||||||
|
insert_recording(test_db, camera_id="cam1", started_at=now, ended_at=now+60,
|
||||||
|
file_path="/tmp/a.mp4", trigger="MOTION", detection_type="person")
|
||||||
|
insert_recording(test_db, camera_id="cam1", started_at=now+100, ended_at=now+130,
|
||||||
|
file_path="/tmp/b.mp4", trigger="MOTION", detection_type="motion")
|
||||||
|
|
||||||
|
results = get_timeline_data(test_db, "cam1", now - 3600, now + 3600)
|
||||||
|
assert len(results) == 2
|
||||||
|
assert results[0]["detection_type"] == "person"
|
||||||
|
assert results[1]["detection_type"] == "motion"
|
||||||
|
|
||||||
|
def test_filters_by_camera(self, test_db):
|
||||||
|
now = int(time.time())
|
||||||
|
insert_recording(test_db, camera_id="cam1", started_at=now, ended_at=now+60,
|
||||||
|
file_path="/tmp/a.mp4", trigger="MOTION")
|
||||||
|
insert_recording(test_db, camera_id="cam2", started_at=now, ended_at=now+60,
|
||||||
|
file_path="/tmp/b.mp4", trigger="MOTION")
|
||||||
|
|
||||||
|
results = get_timeline_data(test_db, "cam1", now - 3600, now + 3600)
|
||||||
|
assert len(results) == 1
|
||||||
|
|
||||||
|
def test_filters_by_time_range(self, test_db):
|
||||||
|
now = int(time.time())
|
||||||
|
insert_recording(test_db, camera_id="cam1", started_at=now - 7200, ended_at=now - 7100,
|
||||||
|
file_path="/tmp/old.mp4", trigger="MOTION")
|
||||||
|
insert_recording(test_db, camera_id="cam1", started_at=now, ended_at=now+60,
|
||||||
|
file_path="/tmp/new.mp4", trigger="MOTION")
|
||||||
|
|
||||||
|
results = get_timeline_data(test_db, "cam1", now - 3600, now + 3600)
|
||||||
|
assert len(results) == 1
|
||||||
56
vigilar/alerts/profiles.py
Normal file
56
vigilar/alerts/profiles.py
Normal file
@ -0,0 +1,56 @@
|
|||||||
|
"""Smart alert profile matching engine."""
|
||||||
|
|
||||||
|
import logging
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from vigilar.config import AlertProfileConfig, AlertProfileRule
|
||||||
|
from vigilar.constants import HouseholdState
|
||||||
|
|
||||||
|
log = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
def is_in_time_window(window: str, current_time: str) -> bool:
|
||||||
|
if not window:
|
||||||
|
return True
|
||||||
|
|
||||||
|
parts = window.split("-")
|
||||||
|
if len(parts) != 2:
|
||||||
|
return True
|
||||||
|
|
||||||
|
start, end = parts[0].strip(), parts[1].strip()
|
||||||
|
t = current_time
|
||||||
|
|
||||||
|
if start <= end:
|
||||||
|
return start <= t < end
|
||||||
|
else:
|
||||||
|
return t >= start or t < end
|
||||||
|
|
||||||
|
|
||||||
|
def match_profile(
|
||||||
|
profiles: list[AlertProfileConfig],
|
||||||
|
household_state: HouseholdState,
|
||||||
|
current_time: str,
|
||||||
|
) -> AlertProfileConfig | None:
|
||||||
|
for profile in profiles:
|
||||||
|
if not profile.enabled:
|
||||||
|
continue
|
||||||
|
if household_state.value not in profile.presence_states:
|
||||||
|
continue
|
||||||
|
if not is_in_time_window(profile.time_window, current_time):
|
||||||
|
continue
|
||||||
|
return profile
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def get_action_for_event(
|
||||||
|
profile: AlertProfileConfig,
|
||||||
|
detection_type: str,
|
||||||
|
camera_id: str,
|
||||||
|
) -> tuple[str, str]:
|
||||||
|
for rule in profile.rules:
|
||||||
|
if rule.detection_type != detection_type:
|
||||||
|
continue
|
||||||
|
if rule.camera_location not in ("any", camera_id):
|
||||||
|
continue
|
||||||
|
return rule.action, rule.recipients
|
||||||
|
return "quiet_log", "none"
|
||||||
@ -144,6 +144,9 @@ class AlertsConfig(BaseModel):
|
|||||||
web_push: WebPushConfig = Field(default_factory=WebPushConfig)
|
web_push: WebPushConfig = Field(default_factory=WebPushConfig)
|
||||||
email: EmailAlertConfig = Field(default_factory=EmailAlertConfig)
|
email: EmailAlertConfig = Field(default_factory=EmailAlertConfig)
|
||||||
webhook: WebhookAlertConfig = Field(default_factory=WebhookAlertConfig)
|
webhook: WebhookAlertConfig = Field(default_factory=WebhookAlertConfig)
|
||||||
|
sleep_start: str = "23:00"
|
||||||
|
sleep_end: str = "06:00"
|
||||||
|
profiles: list["AlertProfileConfig"] = Field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
# --- Remote Access Config ---
|
# --- Remote Access Config ---
|
||||||
@ -208,6 +211,22 @@ class PresenceConfig(BaseModel):
|
|||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
|
# --- Alert Profile Config ---
|
||||||
|
|
||||||
|
class AlertProfileRule(BaseModel):
|
||||||
|
detection_type: str # person, unknown_vehicle, known_vehicle, motion
|
||||||
|
camera_location: str = "any" # any | exterior | common_area | specific camera id
|
||||||
|
action: str = "record_only" # push_and_record, push_adults, record_only, quiet_log
|
||||||
|
recipients: str = "all" # all, adults, none
|
||||||
|
|
||||||
|
class AlertProfileConfig(BaseModel):
|
||||||
|
name: str
|
||||||
|
enabled: bool = True
|
||||||
|
presence_states: list[str] = Field(default_factory=list)
|
||||||
|
time_window: str = "" # "" = all day, "23:00-06:00" = sleep hours
|
||||||
|
rules: list[AlertProfileRule] = Field(default_factory=list)
|
||||||
|
|
||||||
|
|
||||||
# --- Health Config ---
|
# --- Health Config ---
|
||||||
|
|
||||||
class HealthConfig(BaseModel):
|
class HealthConfig(BaseModel):
|
||||||
@ -289,8 +308,9 @@ class VigilarConfig(BaseModel):
|
|||||||
return self
|
return self
|
||||||
|
|
||||||
|
|
||||||
# Resolve forward reference: CameraConfig.zones references CameraZone defined later
|
# Resolve forward references
|
||||||
CameraConfig.model_rebuild()
|
CameraConfig.model_rebuild()
|
||||||
|
AlertsConfig.model_rebuild()
|
||||||
|
|
||||||
|
|
||||||
def load_config(path: str | Path | None = None) -> VigilarConfig:
|
def load_config(path: str | Path | None = None) -> VigilarConfig:
|
||||||
|
|||||||
@ -100,6 +100,31 @@ def get_recordings(
|
|||||||
return [dict(r) for r in conn.execute(query).mappings().all()]
|
return [dict(r) for r in conn.execute(query).mappings().all()]
|
||||||
|
|
||||||
|
|
||||||
|
def get_timeline_data(
|
||||||
|
engine: Engine,
|
||||||
|
camera_id: str,
|
||||||
|
day_start_ts: int,
|
||||||
|
day_end_ts: int,
|
||||||
|
) -> list[dict[str, Any]]:
|
||||||
|
query = (
|
||||||
|
select(
|
||||||
|
recordings.c.id,
|
||||||
|
recordings.c.started_at,
|
||||||
|
recordings.c.ended_at,
|
||||||
|
recordings.c.detection_type,
|
||||||
|
recordings.c.starred,
|
||||||
|
)
|
||||||
|
.where(
|
||||||
|
recordings.c.camera_id == camera_id,
|
||||||
|
recordings.c.started_at >= day_start_ts,
|
||||||
|
recordings.c.started_at < day_end_ts,
|
||||||
|
)
|
||||||
|
.order_by(recordings.c.started_at.asc())
|
||||||
|
)
|
||||||
|
with engine.connect() as conn:
|
||||||
|
return [dict(r) for r in conn.execute(query).mappings().all()]
|
||||||
|
|
||||||
|
|
||||||
def delete_recording(engine: Engine, recording_id: int) -> bool:
|
def delete_recording(engine: Engine, recording_id: int) -> bool:
|
||||||
with engine.begin() as conn:
|
with engine.begin() as conn:
|
||||||
result = conn.execute(
|
result = conn.execute(
|
||||||
|
|||||||
@ -1,6 +1,9 @@
|
|||||||
"""Recordings blueprint — browse, download, delete."""
|
"""Recordings blueprint — browse, download, delete."""
|
||||||
|
|
||||||
from flask import Blueprint, jsonify, render_template, request
|
import time as time_mod
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
|
||||||
|
from flask import Blueprint, current_app, jsonify, render_template, request
|
||||||
|
|
||||||
recordings_bp = Blueprint("recordings", __name__, url_prefix="/recordings")
|
recordings_bp = Blueprint("recordings", __name__, url_prefix="/recordings")
|
||||||
|
|
||||||
@ -21,6 +24,42 @@ def recordings_api():
|
|||||||
return jsonify([])
|
return jsonify([])
|
||||||
|
|
||||||
|
|
||||||
|
@recordings_bp.route("/api/timeline")
|
||||||
|
def timeline_api():
|
||||||
|
"""JSON API: timeline data for a camera on a given day."""
|
||||||
|
camera_id = request.args.get("camera")
|
||||||
|
date_str = request.args.get("date") # YYYY-MM-DD
|
||||||
|
|
||||||
|
if not camera_id:
|
||||||
|
return jsonify({"error": "camera required"}), 400
|
||||||
|
|
||||||
|
if date_str:
|
||||||
|
day = datetime.strptime(date_str, "%Y-%m-%d")
|
||||||
|
else:
|
||||||
|
day = datetime.now().replace(hour=0, minute=0, second=0, microsecond=0)
|
||||||
|
|
||||||
|
day_start = int(day.timestamp())
|
||||||
|
day_end = int((day + timedelta(days=1)).timestamp())
|
||||||
|
|
||||||
|
engine = current_app.config.get("DB_ENGINE")
|
||||||
|
if engine is None:
|
||||||
|
return jsonify([])
|
||||||
|
|
||||||
|
from vigilar.storage.queries import get_timeline_data
|
||||||
|
data = get_timeline_data(engine, camera_id, day_start, day_end)
|
||||||
|
|
||||||
|
return jsonify([
|
||||||
|
{
|
||||||
|
"id": r["id"],
|
||||||
|
"start": r["started_at"],
|
||||||
|
"end": r["ended_at"],
|
||||||
|
"type": r.get("detection_type", "motion"),
|
||||||
|
"starred": bool(r.get("starred", 0)),
|
||||||
|
}
|
||||||
|
for r in data
|
||||||
|
])
|
||||||
|
|
||||||
|
|
||||||
@recordings_bp.route("/<int:recording_id>/download")
|
@recordings_bp.route("/<int:recording_id>/download")
|
||||||
def recording_download(recording_id: int):
|
def recording_download(recording_id: int):
|
||||||
"""Stream decrypted recording for download/playback."""
|
"""Stream decrypted recording for download/playback."""
|
||||||
|
|||||||
95
vigilar/web/static/js/timeline.js
Normal file
95
vigilar/web/static/js/timeline.js
Normal file
@ -0,0 +1,95 @@
|
|||||||
|
/* Vigilar — Recording timeline renderer */
|
||||||
|
|
||||||
|
(function() {
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
const COLORS = {
|
||||||
|
person: '#dc3545',
|
||||||
|
vehicle: '#0d6efd',
|
||||||
|
motion: '#6c757d',
|
||||||
|
default: '#6c757d',
|
||||||
|
};
|
||||||
|
|
||||||
|
class Timeline {
|
||||||
|
constructor(canvas, options = {}) {
|
||||||
|
this.canvas = canvas;
|
||||||
|
this.ctx = canvas.getContext('2d');
|
||||||
|
this.segments = [];
|
||||||
|
this.cameraId = options.cameraId || '';
|
||||||
|
this.date = options.date || new Date().toISOString().split('T')[0];
|
||||||
|
this.onClick = options.onClick || null;
|
||||||
|
this.dayStartTs = 0;
|
||||||
|
this.dayEndTs = 0;
|
||||||
|
this._resize();
|
||||||
|
this._bindEvents();
|
||||||
|
}
|
||||||
|
|
||||||
|
_resize() {
|
||||||
|
const rect = this.canvas.parentElement.getBoundingClientRect();
|
||||||
|
this.canvas.width = rect.width;
|
||||||
|
this.canvas.height = this.canvas.dataset.height ? parseInt(this.canvas.dataset.height) : 48;
|
||||||
|
}
|
||||||
|
|
||||||
|
_bindEvents() {
|
||||||
|
this.canvas.addEventListener('click', (e) => this._handleClick(e));
|
||||||
|
window.addEventListener('resize', () => { this._resize(); this.render(); });
|
||||||
|
}
|
||||||
|
|
||||||
|
async load() {
|
||||||
|
const resp = await fetch(`/recordings/api/timeline?camera=${this.cameraId}&date=${this.date}`);
|
||||||
|
if (!resp.ok) return;
|
||||||
|
this.segments = await resp.json();
|
||||||
|
|
||||||
|
const d = new Date(this.date + 'T00:00:00');
|
||||||
|
this.dayStartTs = d.getTime() / 1000;
|
||||||
|
this.dayEndTs = this.dayStartTs + 86400;
|
||||||
|
this.render();
|
||||||
|
}
|
||||||
|
|
||||||
|
render() {
|
||||||
|
const w = this.canvas.width;
|
||||||
|
const h = this.canvas.height;
|
||||||
|
const ctx = this.ctx;
|
||||||
|
const dur = this.dayEndTs - this.dayStartTs;
|
||||||
|
|
||||||
|
ctx.clearRect(0, 0, w, h);
|
||||||
|
ctx.fillStyle = '#161b22';
|
||||||
|
ctx.fillRect(0, 0, w, h);
|
||||||
|
|
||||||
|
for (const seg of this.segments) {
|
||||||
|
const x1 = ((seg.start - this.dayStartTs) / dur) * w;
|
||||||
|
const x2 = seg.end ? ((seg.end - this.dayStartTs) / dur) * w : x1 + 2;
|
||||||
|
const segW = Math.max(x2 - x1, 2);
|
||||||
|
ctx.fillStyle = COLORS[seg.type] || COLORS.default;
|
||||||
|
ctx.fillRect(x1, 4, segW, h - 8);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hour markers
|
||||||
|
ctx.fillStyle = '#30363d';
|
||||||
|
ctx.font = '10px sans-serif';
|
||||||
|
for (let hour = 0; hour <= 24; hour += 6) {
|
||||||
|
const x = (hour / 24) * w;
|
||||||
|
ctx.fillRect(x, 0, 1, h);
|
||||||
|
if (hour < 24) {
|
||||||
|
ctx.fillStyle = '#8b949e';
|
||||||
|
ctx.fillText(`${hour}:00`, x + 3, h - 2);
|
||||||
|
ctx.fillStyle = '#30363d';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_handleClick(e) {
|
||||||
|
const rect = this.canvas.getBoundingClientRect();
|
||||||
|
const x = e.clientX - rect.left;
|
||||||
|
const pct = x / this.canvas.width;
|
||||||
|
const ts = this.dayStartTs + pct * 86400;
|
||||||
|
|
||||||
|
const clicked = this.segments.find(s => ts >= s.start && ts <= (s.end || s.start + 10));
|
||||||
|
if (clicked && this.onClick) {
|
||||||
|
this.onClick(clicked);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
window.VigilarTimeline = Timeline;
|
||||||
|
})();
|
||||||
@ -4,11 +4,52 @@
|
|||||||
{% block content %}
|
{% block content %}
|
||||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||||
<h5 class="mb-0"><i class="bi bi-camera-video me-2"></i>Recordings</h5>
|
<h5 class="mb-0"><i class="bi bi-camera-video me-2"></i>Recordings</h5>
|
||||||
<select class="form-select form-select-sm" id="filter-camera" style="width: auto;">
|
<div class="d-flex gap-2 align-items-center">
|
||||||
<option value="">All Cameras</option>
|
<input type="date" class="form-control form-control-sm bg-dark text-light border-secondary"
|
||||||
</select>
|
id="timeline-date" style="width: auto;">
|
||||||
|
<select class="form-select form-select-sm bg-dark text-light border-secondary"
|
||||||
|
id="filter-camera" style="width: auto;">
|
||||||
|
<option value="">All Cameras</option>
|
||||||
|
</select>
|
||||||
|
<div class="btn-group btn-group-sm" id="filter-type">
|
||||||
|
<button class="btn btn-outline-light active" data-type="all">All</button>
|
||||||
|
<button class="btn btn-outline-danger" data-type="person">Person</button>
|
||||||
|
<button class="btn btn-outline-primary" data-type="vehicle">Vehicle</button>
|
||||||
|
<button class="btn btn-outline-secondary" data-type="motion">Motion</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Timeline canvases (one per camera) -->
|
||||||
|
<div id="timeline-container">
|
||||||
|
<div class="card bg-dark border-secondary mb-3" id="timeline-placeholder">
|
||||||
|
<div class="card-body text-center text-muted py-3">
|
||||||
|
Select a camera to view the recording timeline
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Video player area -->
|
||||||
|
<div class="card bg-dark border-secondary mb-3 d-none" id="player-card">
|
||||||
|
<div class="card-header border-secondary d-flex justify-content-between align-items-center">
|
||||||
|
<span id="player-title">Recording</span>
|
||||||
|
<button class="btn btn-sm btn-outline-light" id="player-close">
|
||||||
|
<i class="bi bi-x-lg"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="card-body p-0">
|
||||||
|
<video id="player-video" class="w-100" controls></video>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Legend -->
|
||||||
|
<div class="d-flex gap-3 mb-3 small text-muted">
|
||||||
|
<span><span class="badge" style="background:#dc3545"> </span> Person</span>
|
||||||
|
<span><span class="badge" style="background:#0d6efd"> </span> Vehicle</span>
|
||||||
|
<span><span class="badge" style="background:#6c757d"> </span> Motion</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Fallback table for recordings list -->
|
||||||
<div class="card bg-dark border-secondary">
|
<div class="card bg-dark border-secondary">
|
||||||
<div class="card-body p-0">
|
<div class="card-body p-0">
|
||||||
<div class="table-responsive">
|
<div class="table-responsive">
|
||||||
@ -47,4 +88,71 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<script src="{{ url_for('static', filename='js/timeline.js') }}"></script>
|
||||||
|
<script>
|
||||||
|
(function() {
|
||||||
|
'use strict';
|
||||||
|
|
||||||
|
const dateInput = document.getElementById('timeline-date');
|
||||||
|
const cameraSelect = document.getElementById('filter-camera');
|
||||||
|
const container = document.getElementById('timeline-container');
|
||||||
|
const placeholder = document.getElementById('timeline-placeholder');
|
||||||
|
const playerCard = document.getElementById('player-card');
|
||||||
|
const playerVideo = document.getElementById('player-video');
|
||||||
|
const playerTitle = document.getElementById('player-title');
|
||||||
|
const playerClose = document.getElementById('player-close');
|
||||||
|
|
||||||
|
// Default to today
|
||||||
|
dateInput.value = new Date().toISOString().split('T')[0];
|
||||||
|
|
||||||
|
playerClose.addEventListener('click', () => {
|
||||||
|
playerCard.classList.add('d-none');
|
||||||
|
playerVideo.pause();
|
||||||
|
playerVideo.src = '';
|
||||||
|
});
|
||||||
|
|
||||||
|
function handleSegmentClick(seg) {
|
||||||
|
playerCard.classList.remove('d-none');
|
||||||
|
playerTitle.textContent = `Recording #${seg.id} (${seg.type || 'motion'})`;
|
||||||
|
playerVideo.src = `/recordings/${seg.id}/download`;
|
||||||
|
playerVideo.play().catch(() => {});
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadTimeline() {
|
||||||
|
const cameraId = cameraSelect.value;
|
||||||
|
const date = dateInput.value;
|
||||||
|
|
||||||
|
if (!cameraId) {
|
||||||
|
placeholder.classList.remove('d-none');
|
||||||
|
// Remove any existing timeline canvases
|
||||||
|
container.querySelectorAll('.timeline-row').forEach(el => el.remove());
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
placeholder.classList.add('d-none');
|
||||||
|
container.querySelectorAll('.timeline-row').forEach(el => el.remove());
|
||||||
|
|
||||||
|
const row = document.createElement('div');
|
||||||
|
row.className = 'card bg-dark border-secondary mb-2 timeline-row';
|
||||||
|
row.innerHTML = `
|
||||||
|
<div class="card-header border-secondary py-1 small">${cameraId}</div>
|
||||||
|
<div class="card-body p-2">
|
||||||
|
<canvas data-height="48" style="width:100%;cursor:pointer"></canvas>
|
||||||
|
</div>`;
|
||||||
|
container.appendChild(row);
|
||||||
|
|
||||||
|
const canvas = row.querySelector('canvas');
|
||||||
|
const tl = new VigilarTimeline(canvas, {
|
||||||
|
cameraId: cameraId,
|
||||||
|
date: date,
|
||||||
|
onClick: handleSegmentClick,
|
||||||
|
});
|
||||||
|
tl.load();
|
||||||
|
}
|
||||||
|
|
||||||
|
cameraSelect.addEventListener('change', loadTimeline);
|
||||||
|
dateInput.addEventListener('change', loadTimeline);
|
||||||
|
})();
|
||||||
|
</script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user