Compare commits

...

14 Commits

Author SHA1 Message Date
Blake Blackshear
46fe06e779 tweak vod settings for varying iframe intervals 2021-08-28 21:26:23 -05:00
Blake Blackshear
fbea51372f sync global snapshot options (fixes #1621) 2021-08-28 09:14:00 -05:00
Blake Blackshear
fa5ec8d019 cleanup global and camera detect config (fixes #1615) 2021-08-28 08:51:51 -05:00
Blake Blackshear
11c425a7eb error on invalid role 2021-08-28 08:16:25 -05:00
Blake Blackshear
0d352f3d8a use model from frogfish release 2021-08-28 08:04:29 -05:00
Blake Blackshear
6ccff71408 handle missing camera names 2021-08-28 07:43:51 -05:00
Blake Blackshear
41fea2a531 fix match for websocket url (fixes #1633) 2021-08-28 07:42:30 -05:00
Blake Blackshear
3d6dad7e7e reverse sort within a day for recordings 2021-08-27 07:26:11 -05:00
Bernt Christian Egeland
4efc584816 Move event-view to events table. (#1596)
* fixed position for Dialog

* added eventId to deleted item

* removed page route redirect + New Close Button

* event component added to events list. New delete reducer

* removed event route

* moved delete reducer to event page

* removed redundant event details

* keep aspect ratio

* keep aspect ratio

* removed old buttons - repositioned to top

* removed console.log

* event view function

* removed clip header

* top position

* centered image if no clips avail

* comments

* linting

* lint

* added scrollIntoView when event has been mounted

* added Clip header

* added scrollIntoView to test

* lint

* useRef to scroll event into view

* removed unused functions

* reverted changes to event.test

* scroll into view

* moved delete reducer

* removed commented code

* styling

* moved close button to right side

* Added new close svg icon

Co-authored-by: Bernt Christian Egeland <cbegelan@gmail.com>
2021-08-26 06:54:36 -05:00
ᗪєνιη ᗷυнʟ
10ab70080a fix: consistent error logging to mqtt connection issues (#1578) 2021-08-24 07:59:31 -05:00
Blake Blackshear
29de723267 limit legacy expiration to files after the oldest recording in the db 2021-08-24 06:50:58 -05:00
Bernt Christian Egeland
354a9240f0 reduced navbar padding / height 2021-08-23 07:47:39 -05:00
Bernt Christian Egeland
5ae4f47e96 removed comma. This was causing the main window to be pulled down behind the headerbar, hence the odd menu behavior 2021-08-23 07:44:17 -05:00
Blake Blackshear
26424488a5 use find to reduce CPU usage for legacy expiration 2021-08-23 07:21:27 -05:00
20 changed files with 465 additions and 243 deletions

View File

@@ -40,8 +40,8 @@ COPY --from=nginx /usr/local/nginx/ /usr/local/nginx/
# get model and labels # get model and labels
COPY labelmap.txt /labelmap.txt COPY labelmap.txt /labelmap.txt
RUN wget -q https://github.com/google-coral/test_data/raw/master/ssdlite_mobiledet_coco_qat_postprocess_edgetpu.tflite -O /edgetpu_model.tflite RUN wget -q https://github.com/google-coral/test_data/raw/release-frogfish/ssdlite_mobiledet_coco_qat_postprocess_edgetpu.tflite -O /edgetpu_model.tflite
RUN wget -q https://github.com/google-coral/test_data/raw/master/ssdlite_mobiledet_coco_qat_postprocess.tflite -O /cpu_model.tflite RUN wget -q https://github.com/google-coral/test_data/raw/release-frogfish/ssdlite_mobiledet_coco_qat_postprocess.tflite -O /cpu_model.tflite
WORKDIR /opt/frigate/ WORKDIR /opt/frigate/
ADD frigate frigate/ ADD frigate frigate/

View File

@@ -52,6 +52,8 @@ http {
vod_mode mapped; vod_mode mapped;
vod_max_mapping_response_size 1m; vod_max_mapping_response_size 1m;
vod_upstream_location /api; vod_upstream_location /api;
vod_align_segments_to_key_frames on;
vod_manifest_segment_durations_mode accurate;
# vod caches # vod caches
vod_metadata_cache metadata_cache 512m; vod_metadata_cache metadata_cache 512m;

View File

@@ -284,11 +284,11 @@ cameras:
# Required: Camera level detect settings # Required: Camera level detect settings
detect: detect:
# Required: width of the frame for the input with the detect role # Optional: width of the frame for the input with the detect role (default: shown below)
width: 1280 width: 1280
# Required: height of the frame for the input with the detect role # Optional: height of the frame for the input with the detect role (default: shown below)
height: 720 height: 720
# Required: desired fps for your camera for the input with the detect role # Optional: desired fps for your camera for the input with the detect role (default: shown below)
# NOTE: Recommended value of 5. Ideally, try and reduce your FPS on the camera. # NOTE: Recommended value of 5. Ideally, try and reduce your FPS on the camera.
fps: 5 fps: 5
# Optional: enables detection for the camera (default: True) # Optional: enables detection for the camera (default: True)

View File

@@ -167,21 +167,6 @@ record:
person: 15 person: 15
``` ```
## `snapshots`
Can be overridden at the camera level. Global snapshot retention settings.
```yaml
# Optional: Configuration for the jpg snapshots written to the clips directory for each event
snapshots:
retain:
# Required: Default retention days (default: shown below)
default: 10
# Optional: Per object retention days
objects:
person: 15
```
### `ffmpeg` ### `ffmpeg`
Can be overridden at the camera level. Can be overridden at the camera level.

View File

@@ -149,9 +149,11 @@ class RuntimeMotionConfig(MotionConfig):
class DetectConfig(BaseModel): class DetectConfig(BaseModel):
height: int = Field(title="Height of the stream for the detect role.") height: int = Field(default=720, title="Height of the stream for the detect role.")
width: int = Field(title="Width of the stream for the detect role.") width: int = Field(default=1280, title="Width of the stream for the detect role.")
fps: int = Field(title="Number of frames per second to process through detection.") fps: int = Field(
default=5, title="Number of frames per second to process through detection."
)
enabled: bool = Field(default=True, title="Detection Enabled.") enabled: bool = Field(default=True, title="Detection Enabled.")
max_disappeared: Optional[int] = Field( max_disappeared: Optional[int] = Field(
title="Maximum number of frames the object can dissapear before detection ends." title="Maximum number of frames the object can dissapear before detection ends."
@@ -332,9 +334,15 @@ class FfmpegConfig(BaseModel):
) )
class CameraRoleEnum(str, Enum):
record = "record"
rtmp = "rtmp"
detect = "detect"
class CameraInput(BaseModel): class CameraInput(BaseModel):
path: str = Field(title="Camera input path.") path: str = Field(title="Camera input path.")
roles: List[str] = Field(title="Roles assigned to this input.") roles: List[CameraRoleEnum] = Field(title="Roles assigned to this input.")
global_args: Union[str, List[str]] = Field( global_args: Union[str, List[str]] = Field(
default_factory=list, title="FFmpeg global arguments." default_factory=list, title="FFmpeg global arguments."
) )
@@ -363,7 +371,7 @@ class CameraFfmpegConfig(FfmpegConfig):
return v return v
class CameraSnapshotsConfig(BaseModel): class SnapshotsConfig(BaseModel):
enabled: bool = Field(default=False, title="Snapshots enabled.") enabled: bool = Field(default=False, title="Snapshots enabled.")
clean_copy: bool = Field( clean_copy: bool = Field(
default=True, title="Create a clean copy of the snapshot image." default=True, title="Create a clean copy of the snapshot image."
@@ -449,9 +457,11 @@ class CameraConfig(BaseModel):
rtmp: CameraRtmpConfig = Field( rtmp: CameraRtmpConfig = Field(
default_factory=CameraRtmpConfig, title="RTMP restreaming configuration." default_factory=CameraRtmpConfig, title="RTMP restreaming configuration."
) )
live: Optional[CameraLiveConfig] = Field(title="Live playback settings.") live: CameraLiveConfig = Field(
snapshots: CameraSnapshotsConfig = Field( default_factory=CameraLiveConfig, title="Live playback settings."
default_factory=CameraSnapshotsConfig, title="Snapshot configuration." )
snapshots: SnapshotsConfig = Field(
default_factory=SnapshotsConfig, title="Snapshot configuration."
) )
mqtt: CameraMqttConfig = Field( mqtt: CameraMqttConfig = Field(
default_factory=CameraMqttConfig, title="MQTT configuration." default_factory=CameraMqttConfig, title="MQTT configuration."
@@ -460,7 +470,9 @@ class CameraConfig(BaseModel):
default_factory=ObjectConfig, title="Object configuration." default_factory=ObjectConfig, title="Object configuration."
) )
motion: Optional[MotionConfig] = Field(title="Motion detection configuration.") motion: Optional[MotionConfig] = Field(title="Motion detection configuration.")
detect: DetectConfig = Field(title="Object detection configuration.") detect: DetectConfig = Field(
default_factory=DetectConfig, title="Object detection configuration."
)
timestamp_style: TimestampStyleConfig = Field( timestamp_style: TimestampStyleConfig = Field(
default_factory=TimestampStyleConfig, title="Timestamp style configuration." default_factory=TimestampStyleConfig, title="Timestamp style configuration."
) )
@@ -620,12 +632,6 @@ class LoggerConfig(BaseModel):
) )
class SnapshotsConfig(BaseModel):
retain: RetainConfig = Field(
default_factory=RetainConfig, title="Global snapshot retention configuration."
)
class FrigateConfig(BaseModel): class FrigateConfig(BaseModel):
mqtt: MqttConfig = Field(title="MQTT Configuration.") mqtt: MqttConfig = Field(title="MQTT Configuration.")
database: DatabaseConfig = Field( database: DatabaseConfig = Field(
@@ -662,8 +668,8 @@ class FrigateConfig(BaseModel):
motion: Optional[MotionConfig] = Field( motion: Optional[MotionConfig] = Field(
title="Global motion detection configuration." title="Global motion detection configuration."
) )
detect: Optional[DetectConfig] = Field( detect: DetectConfig = Field(
title="Global object tracking configuration." default_factory=DetectConfig, title="Global object tracking configuration."
) )
cameras: Dict[str, CameraConfig] = Field(title="Camera configuration.") cameras: Dict[str, CameraConfig] = Field(title="Camera configuration.")
@@ -695,6 +701,11 @@ class FrigateConfig(BaseModel):
{"name": name, **merged_config} {"name": name, **merged_config}
) )
# Default max_disappeared configuration
max_disappeared = camera_config.detect.fps * 5
if camera_config.detect.max_disappeared is None:
camera_config.detect.max_disappeared = max_disappeared
# FFMPEG input substitution # FFMPEG input substitution
for input in camera_config.ffmpeg.inputs: for input in camera_config.ffmpeg.inputs:
input.path = input.path.format(**FRIGATE_ENV_VARS) input.path = input.path.format(**FRIGATE_ENV_VARS)
@@ -742,15 +753,6 @@ class FrigateConfig(BaseModel):
**camera_config.motion.dict(exclude_unset=True), **camera_config.motion.dict(exclude_unset=True),
) )
# Default detect configuration
max_disappeared = camera_config.detect.fps * 5
if camera_config.detect.max_disappeared is None:
camera_config.detect.max_disappeared = max_disappeared
# Default live configuration
if camera_config.live is None:
camera_config.live = CameraLiveConfig()
config.cameras[name] = camera_config config.cameras[name] = camera_config
return config return config

View File

@@ -96,14 +96,14 @@ def create_mqtt_client(config: FrigateConfig, camera_metrics):
threading.current_thread().name = "mqtt" threading.current_thread().name = "mqtt"
if rc != 0: if rc != 0:
if rc == 3: if rc == 3:
logger.error("MQTT Server unavailable") logger.error("Unable to connect to MQTT server: MQTT Server unavailable")
elif rc == 4: elif rc == 4:
logger.error("MQTT Bad username or password") logger.error("Unable to connect to MQTT server: MQTT Bad username or password")
elif rc == 5: elif rc == 5:
logger.error("MQTT Not authorized") logger.error("Unable to connect to MQTT server: MQTT Not authorized")
else: else:
logger.error( logger.error(
"Unable to connect to MQTT: Connection refused. Error code: " "Unable to connect to MQTT server: Connection refused. Error code: "
+ str(rc) + str(rc)
) )

View File

@@ -75,8 +75,9 @@ class BroadcastThread(threading.Thread):
ws_iter = iter(websockets.values()) ws_iter = iter(websockets.values())
for ws in ws_iter: for ws in ws_iter:
if not ws.terminated and ws.environ["PATH_INFO"].endswith( if (
self.camera not ws.terminated
and ws.environ["PATH_INFO"] == f"/{self.camera}"
): ):
try: try:
ws.send(buf, binary=True) ws.send(buf, binary=True)

View File

@@ -78,7 +78,10 @@ class RecordingMaintainer(threading.Thread):
start_time = datetime.datetime.strptime(date, "%Y%m%d%H%M%S") start_time = datetime.datetime.strptime(date, "%Y%m%d%H%M%S")
# Just delete files if recordings are turned off # Just delete files if recordings are turned off
if not self.config.cameras[camera].record.enabled: if (
not camera in self.config.cameras
or not self.config.cameras[camera].record.enabled
):
Path(cache_path).unlink(missing_ok=True) Path(cache_path).unlink(missing_ok=True)
continue continue
@@ -244,29 +247,48 @@ class RecordingCleanup(threading.Thread):
def expire_files(self): def expire_files(self):
logger.debug("Start expire files (legacy).") logger.debug("Start expire files (legacy).")
default_expire = ( default_expire = (
datetime.datetime.now().timestamp() datetime.datetime.now().timestamp()
- SECONDS_IN_DAY * self.config.record.retain_days - SECONDS_IN_DAY * self.config.record.retain_days
) )
delete_before = {} delete_before = {}
for name, camera in self.config.cameras.items(): for name, camera in self.config.cameras.items():
delete_before[name] = ( delete_before[name] = (
datetime.datetime.now().timestamp() datetime.datetime.now().timestamp()
- SECONDS_IN_DAY * camera.record.retain_days - SECONDS_IN_DAY * camera.record.retain_days
) )
for p in Path("/media/frigate/recordings").rglob("*.mp4"): # find all the recordings older than the oldest recording in the db
# Ignore files that have a record in the recordings DB oldest_recording = (
if Recordings.select().where(Recordings.path == str(p)).count(): Recordings.select().order_by(Recordings.start_time.desc()).get()
continue )
oldest_timestamp = (
oldest_recording.start_time
if oldest_recording
else datetime.datetime.now().timestamp()
)
logger.debug(f"Oldest recording in the db: {oldest_timestamp}")
process = sp.run(
["find", RECORD_DIR, "-type", "f", "-newermt", f"@{oldest_timestamp}"],
capture_output=True,
text=True,
)
files_to_check = process.stdout.splitlines()
for f in files_to_check:
p = Path(f)
if p.stat().st_mtime < delete_before.get(p.parent.name, default_expire): if p.stat().st_mtime < delete_before.get(p.parent.name, default_expire):
p.unlink(missing_ok=True) p.unlink(missing_ok=True)
logger.debug("End expire files (legacy).") logger.debug("End expire files (legacy).")
def run(self): def run(self):
# Expire recordings every minute, clean directories every 5 minutes. # Expire recordings every minute, clean directories every hour.
for counter in itertools.cycle(range(5)): for counter in itertools.cycle(range(60)):
if self.stop_event.wait(60): if self.stop_event.wait(60):
logger.info(f"Exiting recording cleanup...") logger.info(f"Exiting recording cleanup...")
break break

View File

@@ -32,8 +32,8 @@ class TestConfig(unittest.TestCase):
assert self.minimal == frigate_config.dict(exclude_unset=True) assert self.minimal == frigate_config.dict(exclude_unset=True)
runtime_config = frigate_config.runtime_config runtime_config = frigate_config.runtime_config
assert "coral" in runtime_config.detectors.keys() assert "cpu" in runtime_config.detectors.keys()
assert runtime_config.detectors["coral"].type == DetectorTypeEnum.edgetpu assert runtime_config.detectors["cpu"].type == DetectorTypeEnum.cpu
def test_invalid_mqtt_config(self): def test_invalid_mqtt_config(self):
config = { config = {
@@ -692,6 +692,198 @@ class TestConfig(unittest.TestCase):
runtime_config = frigate_config.runtime_config runtime_config = frigate_config.runtime_config
assert runtime_config.model.merged_labelmap[0] == "person" assert runtime_config.model.merged_labelmap[0] == "person"
def test_fails_on_invalid_role(self):
config = {
"mqtt": {"host": "mqtt"},
"cameras": {
"back": {
"ffmpeg": {
"inputs": [
{
"path": "rtsp://10.0.0.1:554/video",
"roles": ["detect", "clips"],
},
]
},
"detect": {
"height": 1080,
"width": 1920,
"fps": 5,
},
}
},
}
self.assertRaises(ValidationError, lambda: FrigateConfig(**config))
def test_global_detect(self):
config = {
"mqtt": {"host": "mqtt"},
"detect": {"max_disappeared": 1},
"cameras": {
"back": {
"ffmpeg": {
"inputs": [
{
"path": "rtsp://10.0.0.1:554/video",
"roles": ["detect"],
},
]
},
"detect": {
"height": 1080,
"width": 1920,
"fps": 5,
},
}
},
}
frigate_config = FrigateConfig(**config)
assert config == frigate_config.dict(exclude_unset=True)
runtime_config = frigate_config.runtime_config
assert runtime_config.cameras["back"].detect.max_disappeared == 1
assert runtime_config.cameras["back"].detect.height == 1080
def test_default_detect(self):
config = {
"mqtt": {"host": "mqtt"},
"cameras": {
"back": {
"ffmpeg": {
"inputs": [
{
"path": "rtsp://10.0.0.1:554/video",
"roles": ["detect"],
},
]
}
}
},
}
frigate_config = FrigateConfig(**config)
assert config == frigate_config.dict(exclude_unset=True)
runtime_config = frigate_config.runtime_config
assert runtime_config.cameras["back"].detect.max_disappeared == 25
assert runtime_config.cameras["back"].detect.height == 720
def test_global_detect_merge(self):
config = {
"mqtt": {"host": "mqtt"},
"detect": {"max_disappeared": 1, "height": 720},
"cameras": {
"back": {
"ffmpeg": {
"inputs": [
{
"path": "rtsp://10.0.0.1:554/video",
"roles": ["detect"],
},
]
},
"detect": {
"height": 1080,
"width": 1920,
"fps": 5,
},
}
},
}
frigate_config = FrigateConfig(**config)
assert config == frigate_config.dict(exclude_unset=True)
runtime_config = frigate_config.runtime_config
assert runtime_config.cameras["back"].detect.max_disappeared == 1
assert runtime_config.cameras["back"].detect.height == 1080
assert runtime_config.cameras["back"].detect.width == 1920
def test_global_snapshots(self):
config = {
"mqtt": {"host": "mqtt"},
"snapshots": {"enabled": True},
"cameras": {
"back": {
"ffmpeg": {
"inputs": [
{
"path": "rtsp://10.0.0.1:554/video",
"roles": ["detect"],
},
]
},
"snapshots": {
"height": 100,
},
}
},
}
frigate_config = FrigateConfig(**config)
assert config == frigate_config.dict(exclude_unset=True)
runtime_config = frigate_config.runtime_config
assert runtime_config.cameras["back"].snapshots.enabled
assert runtime_config.cameras["back"].snapshots.height == 100
def test_default_snapshots(self):
config = {
"mqtt": {"host": "mqtt"},
"cameras": {
"back": {
"ffmpeg": {
"inputs": [
{
"path": "rtsp://10.0.0.1:554/video",
"roles": ["detect"],
},
]
}
}
},
}
frigate_config = FrigateConfig(**config)
assert config == frigate_config.dict(exclude_unset=True)
runtime_config = frigate_config.runtime_config
assert runtime_config.cameras["back"].snapshots.bounding_box
assert runtime_config.cameras["back"].snapshots.quality == 70
def test_global_snapshots_merge(self):
config = {
"mqtt": {"host": "mqtt"},
"snapshots": {"bounding_box": False, "height": 300},
"cameras": {
"back": {
"ffmpeg": {
"inputs": [
{
"path": "rtsp://10.0.0.1:554/video",
"roles": ["detect"],
},
]
},
"snapshots": {
"height": 150,
"enabled": True,
},
}
},
}
frigate_config = FrigateConfig(**config)
assert config == frigate_config.dict(exclude_unset=True)
runtime_config = frigate_config.runtime_config
assert runtime_config.cameras["back"].snapshots.bounding_box == False
assert runtime_config.cameras["back"].snapshots.height == 150
assert runtime_config.cameras["back"].snapshots.enabled
if __name__ == "__main__": if __name__ == "__main__":
unittest.main(verbosity=2) unittest.main(verbosity=2)

View File

@@ -23,12 +23,11 @@ export default function App() {
) : ( ) : (
<div className="flex flex-row min-h-screen w-full bg-white dark:bg-gray-900 text-gray-900 dark:text-white"> <div className="flex flex-row min-h-screen w-full bg-white dark:bg-gray-900 text-gray-900 dark:text-white">
<Sidebar /> <Sidebar />
<div className="w-full flex-auto p-2 mt-24 px-4 min-w-0"> <div className="w-full flex-auto p-2 mt-16 px-4 min-w-0">
<Router> <Router>
<AsyncRoute path="/cameras/:camera/editor" getComponent={Routes.getCameraMap} /> <AsyncRoute path="/cameras/:camera/editor" getComponent={Routes.getCameraMap} />
<AsyncRoute path="/cameras/:camera" getComponent={Routes.getCamera} /> <AsyncRoute path="/cameras/:camera" getComponent={Routes.getCamera} />
<AsyncRoute path="/birdseye" getComponent={Routes.getBirdseye} /> <AsyncRoute path="/birdseye" getComponent={Routes.getBirdseye} />
<AsyncRoute path="/events/:eventId" getComponent={Routes.getEvent} />
<AsyncRoute path="/events" getComponent={Routes.getEvents} /> <AsyncRoute path="/events" getComponent={Routes.getEvents} />
<AsyncRoute path="/recording/:camera/:date?/:hour?/:seconds?" getComponent={Routes.getRecording} /> <AsyncRoute path="/recording/:camera/:date?/:hour?/:seconds?" getComponent={Routes.getRecording} />
<AsyncRoute path="/debug" getComponent={Routes.getDebug} /> <AsyncRoute path="/debug" getComponent={Routes.getDebug} />

View File

@@ -63,7 +63,7 @@ export default function AppBar() {
<MenuSeparator /> <MenuSeparator />
<MenuItem icon={FrigateRestartIcon} label="Restart Frigate" onSelect={handleRestart} /> <MenuItem icon={FrigateRestartIcon} label="Restart Frigate" onSelect={handleRestart} />
</Menu> </Menu>
) : null}, ) : null}
{showDialog ? ( {showDialog ? (
<Dialog <Dialog
onDismiss={handleDismissRestartDialog} onDismiss={handleDismissRestartDialog}
@@ -74,7 +74,7 @@ export default function AppBar() {
{ text: 'Cancel', onClick: handleDismissRestartDialog }, { text: 'Cancel', onClick: handleDismissRestartDialog },
]} ]}
/> />
) : null}, ) : null}
{showDialogWait ? ( {showDialogWait ? (
<Dialog <Dialog
title="Restart in progress" title="Restart in progress"

View File

@@ -18,7 +18,7 @@ const initialState = Object.freeze({
const Api = createContext(initialState); const Api = createContext(initialState);
function reducer(state, { type, payload, meta }) { function reducer(state, { type, payload }) {
switch (type) { switch (type) {
case 'REQUEST': { case 'REQUEST': {
const { url, fetchId } = payload; const { url, fetchId } = payload;
@@ -36,22 +36,9 @@ function reducer(state, { type, payload, meta }) {
} }
case 'DELETE': { case 'DELETE': {
const { eventId } = payload; const { eventId } = payload;
return produce(state, (draftState) => { return produce(state, (draftState) => {
Object.keys(draftState.queries).map((url, index) => { Object.keys(draftState.queries).map((url) => {
// If data has no array length then just return state. draftState.queries[url].deletedId = eventId;
if (!('data' in draftState.queries[url]) || !draftState.queries[url].data.length) return state;
//Find the index to remove
const removeIndex = draftState.queries[url].data.map((event) => event.id).indexOf(eventId);
if (removeIndex === -1) return state;
// We need to keep track of deleted items, This will be used to re-calculate "ReachEnd" for auto load new events. Events.jsx
const totDeleted = state.queries[url].deleted || 0;
// Splice the deleted index.
draftState.queries[url].data.splice(removeIndex, 1);
draftState.queries[url].deleted = totDeleted + 1;
}); });
}); });
} }
@@ -111,9 +98,9 @@ export function useFetch(url, fetchId) {
const data = state.queries[url].data || null; const data = state.queries[url].data || null;
const status = state.queries[url].status; const status = state.queries[url].status;
const deleted = state.queries[url].deleted || 0; const deletedId = state.queries[url].deletedId || 0;
return { data, status, deleted }; return { data, status, deletedId };
} }
export function useDelete() { export function useDelete() {

View File

@@ -37,13 +37,13 @@ export default function AppBar({ title: Title, overflowRef, onOverflowClick }) {
return ( return (
<div <div
className={`w-full border-b border-gray-200 dark:border-gray-700 flex items-center align-middle p-4 space-x-2 fixed left-0 right-0 z-10 bg-white dark:bg-gray-900 transform transition-all duration-200 ${ className={`w-full border-b border-gray-200 dark:border-gray-700 flex items-center align-middle p-2 fixed left-0 right-0 z-10 bg-white dark:bg-gray-900 transform transition-all duration-200 ${
!show ? '-translate-y-full' : 'translate-y-0' !show ? '-translate-y-full' : 'translate-y-0'
} ${!atZero ? 'shadow-sm' : ''}`} } ${!atZero ? 'shadow-sm' : ''}`}
data-testid="appbar" data-testid="appbar"
> >
<div className="lg:hidden"> <div className="lg:hidden">
<Button color="black" className="rounded-full w-12 h-12" onClick={handleShowDrawer} type="text"> <Button color="black" className="rounded-full w-10 h-10" onClick={handleShowDrawer} type="text">
<MenuIcon className="w-10 h-10" /> <MenuIcon className="w-10 h-10" />
</Button> </Button>
</div> </div>
@@ -54,7 +54,7 @@ export default function AppBar({ title: Title, overflowRef, onOverflowClick }) {
<Button <Button
aria-label="More options" aria-label="More options"
color="black" color="black"
className="rounded-full w-12 h-12" className="rounded-full w-9 h-9"
onClick={onOverflowClick} onClick={onOverflowClick}
type="text" type="text"
> >

View File

@@ -19,7 +19,7 @@ export default function Dialog({ actions = [], portalRootID = 'dialogs', title,
<div <div
data-testid="scrim" data-testid="scrim"
key="scrim" key="scrim"
className="absolute inset-0 z-10 flex justify-center items-center bg-black bg-opacity-40" className="fixed bg-fixed inset-0 z-10 flex justify-center items-center bg-black bg-opacity-40"
> >
<div <div
role="modal" role="modal"

View File

@@ -22,7 +22,7 @@ export default function NavigationDrawer({ children, header }) {
onClick={handleDismiss} onClick={handleDismiss}
> >
{header ? ( {header ? (
<div className="flex-shrink-0 p-5 flex flex-row items-center justify-between border-b border-gray-200 dark:border-gray-700"> <div className="flex-shrink-0 p-2 flex flex-row items-center justify-between border-b border-gray-200 dark:border-gray-700">
{header} {header}
</div> </div>
) : null} ) : null}

View File

@@ -21,7 +21,7 @@ export default function RecordingPlaylist({ camera, recordings, selectedDate, se
events={recording.events} events={recording.events}
selected={recording.date === selectedDate} selected={recording.date === selectedDate}
> >
{recording.recordings.map((item, i) => ( {recording.recordings.slice().reverse().map((item, i) => (
<div className="mb-2 w-full"> <div className="mb-2 w-full">
<div <div
className={`flex w-full text-md text-white px-8 py-2 mb-2 ${ className={`flex w-full text-md text-white px-8 py-2 mb-2 ${

13
web/src/icons/Close.jsx Normal file
View File

@@ -0,0 +1,13 @@
import { h } from 'preact';
import { memo } from 'preact/compat';
export function Close({ className = '' }) {
return (
<svg className={`fill-current ${className}`} viewBox="0 0 24 24">
<path d="M0 0h24v24H0z" fill="none" />
<path d="M19 6.41L17.59 5 12 10.59 6.41 5 5 6.41 10.59 12 5 17.59 6.41 19 12 13.41 17.59 19 19 17.59 13.41 12 19 6.41z" />
</svg>
);
}
export default memo(Close);

View File

@@ -29,3 +29,12 @@
.jsmpeg canvas { .jsmpeg canvas {
position: static !important; position: static !important;
} }
/*
Event.js
Maintain aspect ratio and scale down the video container
Could not find a proper tailwind css.
*/
.outer-max-width {
max-width: 60%;
}

View File

@@ -1,25 +1,32 @@
import { h, Fragment } from 'preact'; import { h, Fragment } from 'preact';
import { useCallback, useState } from 'preact/hooks'; import { useCallback, useState, useEffect } from 'preact/hooks';
import { route } from 'preact-router';
import ActivityIndicator from '../components/ActivityIndicator'; import ActivityIndicator from '../components/ActivityIndicator';
import Button from '../components/Button'; import Button from '../components/Button';
import Clip from '../icons/Clip'; import Clip from '../icons/Clip';
import Close from '../icons/Close';
import Delete from '../icons/Delete'; import Delete from '../icons/Delete';
import Snapshot from '../icons/Snapshot'; import Snapshot from '../icons/Snapshot';
import Dialog from '../components/Dialog'; import Dialog from '../components/Dialog';
import Heading from '../components/Heading'; import Heading from '../components/Heading';
import Link from '../components/Link';
import VideoPlayer from '../components/VideoPlayer'; import VideoPlayer from '../components/VideoPlayer';
import { FetchStatus, useApiHost, useEvent, useDelete } from '../api'; import { FetchStatus, useApiHost, useEvent, useDelete } from '../api';
import { Table, Thead, Tbody, Th, Tr, Td } from '../components/Table';
export default function Event({ eventId }) { export default function Event({ eventId, close, scrollRef }) {
const apiHost = useApiHost(); const apiHost = useApiHost();
const { data, status } = useEvent(eventId); const { data, status } = useEvent(eventId);
const [showDialog, setShowDialog] = useState(false); const [showDialog, setShowDialog] = useState(false);
const [shouldScroll, setShouldScroll] = useState(true);
const [deleteStatus, setDeleteStatus] = useState(FetchStatus.NONE); const [deleteStatus, setDeleteStatus] = useState(FetchStatus.NONE);
const setDeleteEvent = useDelete(); const setDeleteEvent = useDelete();
useEffect(() => {
// Scroll event into view when component has been mounted.
if (shouldScroll && scrollRef && scrollRef[eventId]) {
scrollRef[eventId].scrollIntoView();
setShouldScroll(false);
}
}, [data, scrollRef, eventId, shouldScroll]);
const handleClickDelete = () => { const handleClickDelete = () => {
setShowDialog(true); setShowDialog(true);
}; };
@@ -40,7 +47,6 @@ export default function Event({ eventId }) {
if (success) { if (success) {
setDeleteStatus(FetchStatus.LOADED); setDeleteStatus(FetchStatus.LOADED);
setShowDialog(false); setShowDialog(false);
route('/events', true);
} }
}, [eventId, setShowDialog, setDeleteEvent]); }, [eventId, setShowDialog, setDeleteEvent]);
@@ -48,18 +54,25 @@ export default function Event({ eventId }) {
return <ActivityIndicator />; return <ActivityIndicator />;
} }
const startime = new Date(data.start_time * 1000);
const endtime = new Date(data.end_time * 1000);
return ( return (
<div className="space-y-4"> <div className="space-y-4">
<div className="flex"> <div className="grid grid-cols-6 gap-4">
<Heading className="flex-grow"> <div class="col-start-1 col-end-8 md:space-x-4">
{data.camera} {data.label} <span className="text-sm">{startime.toLocaleString()}</span> <Button color="blue" href={`${apiHost}/api/events/${eventId}/clip.mp4?download=true`} download>
</Heading> <Clip className="w-6" /> Download Clip
<Button className="self-start" color="red" onClick={handleClickDelete}> </Button>
<Delete className="w-6" /> Delete event <Button color="blue" href={`${apiHost}/api/events/${eventId}/snapshot.jpg?download=true`} download>
</Button> <Snapshot className="w-6" /> Download Snapshot
</Button>
</div>
<div class="col-end-10 col-span-2 space-x-4">
<Button className="self-start" color="red" onClick={handleClickDelete}>
<Delete className="w-6" /> Delete event
</Button>
<Button color="gray" className="self-start" onClick={() => close()}>
<Close className="w-6" /> Close
</Button>
</div>
{showDialog ? ( {showDialog ? (
<Dialog <Dialog
onDismiss={handleDismissDeleteDialog} onDismiss={handleDismissDeleteDialog}
@@ -78,86 +91,42 @@ export default function Event({ eventId }) {
/> />
) : null} ) : null}
</div> </div>
<div className="outer-max-width m-auto">
<Table class="w-full"> <div className="w-full pt-5 relative pb-20">
<Thead> {data.has_clip ? (
<Th>Key</Th> <Fragment>
<Th>Value</Th> <Heading size="lg">Clip</Heading>
</Thead> <VideoPlayer
<Tbody> options={{
<Tr> sources: [
<Td>Camera</Td> {
<Td> src: `${apiHost}/vod/event/${eventId}/index.m3u8`,
<Link href={`/cameras/${data.camera}`}>{data.camera}</Link> type: 'application/vnd.apple.mpegurl',
</Td> },
</Tr> ],
<Tr index={1}> poster: data.has_snapshot
<Td>Timeframe</Td> ? `${apiHost}/clips/${data.camera}-${eventId}.jpg`
<Td> : `data:image/jpeg;base64,${data.thumbnail}`,
{startime.toLocaleString()} {endtime.toLocaleString()} }}
</Td> seekOptions={{ forward: 10, back: 5 }}
</Tr> onReady={() => {}}
<Tr> />
<Td>Score</Td> </Fragment>
<Td>{(data.top_score * 100).toFixed(2)}%</Td> ) : (
</Tr> <Fragment>
<Tr index={1}> <Heading size="sm">{data.has_snapshot ? 'Best Image' : 'Thumbnail'}</Heading>
<Td>Zones</Td> <img
<Td>{data.zones.join(', ')}</Td> src={
</Tr> data.has_snapshot
</Tbody> ? `${apiHost}/clips/${data.camera}-${eventId}.jpg`
</Table> : `data:image/jpeg;base64,${data.thumbnail}`
}
{data.has_clip ? ( alt={`${data.label} at ${(data.top_score * 100).toFixed(1)}% confidence`}
<Fragment> />
<Heading size="lg">Clip</Heading> </Fragment>
<VideoPlayer )}
options={{ </div>
sources: [ </div>
{
src: `${apiHost}/vod/event/${eventId}/index.m3u8`,
type: 'application/vnd.apple.mpegurl',
},
],
poster: data.has_snapshot
? `${apiHost}/clips/${data.camera}-${eventId}.jpg`
: `data:image/jpeg;base64,${data.thumbnail}`,
}}
seekOptions={{ forward: 10, back: 5 }}
onReady={(player) => {}}
/>
<div className="text-center">
<Button
className="mx-2"
color="blue"
href={`${apiHost}/api/events/${eventId}/clip.mp4?download=true`}
download
>
<Clip className="w-6" /> Download Clip
</Button>
<Button
className="mx-2"
color="blue"
href={`${apiHost}/api/events/${eventId}/snapshot.jpg?download=true`}
download
>
<Snapshot className="w-6" /> Download Snapshot
</Button>
</div>
</Fragment>
) : (
<Fragment>
<Heading size="sm">{data.has_snapshot ? 'Best Image' : 'Thumbnail'}</Heading>
<img
src={
data.has_snapshot
? `${apiHost}/clips/${data.camera}-${eventId}.jpg`
: `data:image/jpeg;base64,${data.thumbnail}`
}
alt={`${data.label} at ${(data.top_score * 100).toFixed(1)}% confidence`}
/>
</Fragment>
)}
</div> </div>
); );
} }

View File

@@ -1,10 +1,11 @@
import { h } from 'preact'; import { h, Fragment } from 'preact';
import ActivityIndicator from '../components/ActivityIndicator'; import ActivityIndicator from '../components/ActivityIndicator';
import Heading from '../components/Heading'; import Heading from '../components/Heading';
import Link from '../components/Link'; import Link from '../components/Link';
import Select from '../components/Select'; import Select from '../components/Select';
import produce from 'immer'; import produce from 'immer';
import { route } from 'preact-router'; import { route } from 'preact-router';
import Event from './Event';
import { useIntersectionObserver } from '../hooks'; import { useIntersectionObserver } from '../hooks';
import { FetchStatus, useApiHost, useConfig, useEvents } from '../api'; import { FetchStatus, useApiHost, useConfig, useEvents } from '../api';
import { Table, Thead, Tbody, Tfoot, Th, Tr, Td } from '../components/Table'; import { Table, Thead, Tbody, Tfoot, Th, Tr, Td } from '../components/Table';
@@ -12,9 +13,20 @@ import { useCallback, useEffect, useMemo, useReducer, useState } from 'preact/ho
const API_LIMIT = 25; const API_LIMIT = 25;
const initialState = Object.freeze({ events: [], reachedEnd: false, searchStrings: {} }); const initialState = Object.freeze({ events: [], reachedEnd: false, searchStrings: {}, deleted: 0 });
const reducer = (state = initialState, action) => { const reducer = (state = initialState, action) => {
switch (action.type) { switch (action.type) {
case 'DELETE_EVENT': {
const { deletedId } = action;
return produce(state, (draftState) => {
const idx = draftState.events.findIndex((e) => e.id === deletedId);
if (idx === -1) return state;
draftState.events.splice(idx, 1);
draftState.deleted++;
});
}
case 'APPEND_EVENTS': { case 'APPEND_EVENTS': {
const { const {
meta: { searchString }, meta: { searchString },
@@ -24,6 +36,7 @@ const reducer = (state = initialState, action) => {
return produce(state, (draftState) => { return produce(state, (draftState) => {
draftState.searchStrings[searchString] = true; draftState.searchStrings[searchString] = true;
draftState.events.push(...payload); draftState.events.push(...payload);
draftState.deleted = 0;
}); });
} }
@@ -54,11 +67,13 @@ function removeDefaultSearchKeys(searchParams) {
export default function Events({ path: pathname, limit = API_LIMIT } = {}) { export default function Events({ path: pathname, limit = API_LIMIT } = {}) {
const apiHost = useApiHost(); const apiHost = useApiHost();
const [{ events, reachedEnd, searchStrings }, dispatch] = useReducer(reducer, initialState); const [{ events, reachedEnd, searchStrings, deleted }, dispatch] = useReducer(reducer, initialState);
const { searchParams: initialSearchParams } = new URL(window.location); const { searchParams: initialSearchParams } = new URL(window.location);
const [viewEvent, setViewEvent] = useState(null);
const [searchString, setSearchString] = useState(`${defaultSearchString(limit)}&${initialSearchParams.toString()}`); const [searchString, setSearchString] = useState(`${defaultSearchString(limit)}&${initialSearchParams.toString()}`);
const { data, status, deleted } = useEvents(searchString); const { data, status, deletedId } = useEvents(searchString);
const scrollToRef = {};
useEffect(() => { useEffect(() => {
if (data && !(searchString in searchStrings)) { if (data && !(searchString in searchStrings)) {
dispatch({ type: 'APPEND_EVENTS', payload: data, meta: { searchString } }); dispatch({ type: 'APPEND_EVENTS', payload: data, meta: { searchString } });
@@ -67,7 +82,11 @@ export default function Events({ path: pathname, limit = API_LIMIT } = {}) {
if (data && Array.isArray(data) && data.length + deleted < limit) { if (data && Array.isArray(data) && data.length + deleted < limit) {
dispatch({ type: 'REACHED_END', meta: { searchString } }); dispatch({ type: 'REACHED_END', meta: { searchString } });
} }
}, [data, limit, searchString, searchStrings, deleted]);
if (deletedId) {
dispatch({ type: 'DELETE_EVENT', deletedId });
}
}, [data, limit, searchString, searchStrings, deleted, deletedId]);
const [entry, setIntersectNode] = useIntersectionObserver(); const [entry, setIntersectNode] = useIntersectionObserver();
@@ -100,7 +119,16 @@ export default function Events({ path: pathname, limit = API_LIMIT } = {}) {
[limit, pathname, setSearchString] [limit, pathname, setSearchString]
); );
const viewEventHandler = (id) => {
//Toggle event view
if (viewEvent === id) return setViewEvent(null);
//Set event id to be rendered.
setViewEvent(id);
};
const searchParams = useMemo(() => new URLSearchParams(searchString), [searchString]); const searchParams = useMemo(() => new URLSearchParams(searchString), [searchString]);
return ( return (
<div className="space-y-4 w-full"> <div className="space-y-4 w-full">
<Heading>Events</Heading> <Heading>Events</Heading>
@@ -123,70 +151,83 @@ export default function Events({ path: pathname, limit = API_LIMIT } = {}) {
</Thead> </Thead>
<Tbody> <Tbody>
{events.map( {events.map(
( ({ camera, id, label, start_time: startTime, end_time: endTime, top_score: score, zones }, i) => {
{ camera, id, label, start_time: startTime, end_time: endTime, thumbnail, top_score: score, zones },
i
) => {
const start = new Date(parseInt(startTime * 1000, 10)); const start = new Date(parseInt(startTime * 1000, 10));
const end = new Date(parseInt(endTime * 1000, 10)); const end = new Date(parseInt(endTime * 1000, 10));
const ref = i === events.length - 1 ? lastCellRef : undefined; const ref = i === events.length - 1 ? lastCellRef : undefined;
return ( return (
<Tr data-testid={`event-${id}`} key={id}> <Fragment key={id}>
<Td className="w-40"> <Tr data-testid={`event-${id}`} className={`${viewEvent === id ? 'border-none' : ''}`}>
<a href={`/events/${id}`} ref={ref} data-start-time={startTime} data-reached-end={reachedEnd}> <Td className="w-40">
<img <a
width="150" onClick={() => viewEventHandler(id)}
height="150" ref={ref}
style="min-height: 48px; min-width: 48px;" data-start-time={startTime}
src={`${apiHost}/api/events/${id}/thumbnail.jpg`} data-reached-end={reachedEnd}
>
<img
ref={(el) => (scrollToRef[id] = el)}
width="150"
height="150"
className="cursor-pointer"
style="min-height: 48px; min-width: 48px;"
src={`${apiHost}/api/events/${id}/thumbnail.jpg`}
/>
</a>
</Td>
<Td>
<Filterable
onFilter={handleFilter}
pathname={pathname}
searchParams={searchParams}
paramName="camera"
name={camera}
/> />
</a> </Td>
</Td> <Td>
<Td> <Filterable
<Filterable onFilter={handleFilter}
onFilter={handleFilter} pathname={pathname}
pathname={pathname} searchParams={searchParams}
searchParams={searchParams} paramName="label"
paramName="camera" name={label}
name={camera} />
/> </Td>
</Td> <Td>{(score * 100).toFixed(2)}%</Td>
<Td> <Td>
<Filterable <ul>
onFilter={handleFilter} {zones.map((zone) => (
pathname={pathname} <li>
searchParams={searchParams} <Filterable
paramName="label" onFilter={handleFilter}
name={label} pathname={pathname}
/> searchParams={searchString}
</Td> paramName="zone"
<Td>{(score * 100).toFixed(2)}%</Td> name={zone}
<Td> />
<ul> </li>
{zones.map((zone) => ( ))}
<li> </ul>
<Filterable </Td>
onFilter={handleFilter} <Td>{start.toLocaleDateString()}</Td>
pathname={pathname} <Td>{start.toLocaleTimeString()}</Td>
searchParams={searchString} <Td>{end.toLocaleTimeString()}</Td>
paramName="zone" </Tr>
name={zone} {viewEvent === id ? (
/> <Tr className="border-b-1">
</li> <Td colSpan="8">
))} <Event eventId={id} close={() => setViewEvent(null)} scrollRef={scrollToRef} />
</ul> </Td>
</Td> </Tr>
<Td>{start.toLocaleDateString()}</Td> ) : null}
<Td>{start.toLocaleTimeString()}</Td> </Fragment>
<Td>{end.toLocaleTimeString()}</Td>
</Tr>
); );
} }
)} )}
</Tbody> </Tbody>
<Tfoot> <Tfoot>
<Tr> <Tr>
<Td className="text-center p-4" colspan="8"> <Td className="text-center p-4" colSpan="8">
{status === FetchStatus.LOADING ? <ActivityIndicator /> : reachedEnd ? 'No more events' : null} {status === FetchStatus.LOADING ? <ActivityIndicator /> : reachedEnd ? 'No more events' : null}
</Td> </Td>
</Tr> </Tr>