Use new UI (#8983)

* fixup build

* swap frontends
This commit is contained in:
Blake Blackshear
2023-12-16 16:20:59 +00:00
parent a2c6f45454
commit bdebb99b5a
286 changed files with 20010 additions and 20007 deletions

View File

@@ -0,0 +1,141 @@
import { h, Fragment } from 'preact';
import { usePersistence } from '../context';
import ActivityIndicator from '../components/ActivityIndicator';
import JSMpegPlayer from '../components/JSMpegPlayer';
import Heading from '../components/Heading';
import WebRtcPlayer from '../components/WebRtcPlayer';
import '../components/MsePlayer';
import useSWR from 'swr';
import { useMemo } from 'preact/hooks';
import CameraControlPanel from '../components/CameraControlPanel';
import { baseUrl } from '../api/baseUrl';
export default function Birdseye() {
const { data: config } = useSWR('config');
const [viewSource, setViewSource, sourceIsLoaded] = usePersistence('birdseye-source', getDefaultLiveMode(config));
const sourceValues = ['mse', 'webrtc', 'jsmpeg'];
const ptzCameras = useMemo(() => {
if (!config) {
return [];
}
return Object.entries(config.cameras)
.filter(([_, conf]) => conf.onvif?.host && conf.onvif.host != '')
.map(([_, camera]) => camera.name);
}, [config]);
const [isMaxWidth, setIsMaxWidth] = usePersistence('birdseye-width', false);
if (!config || !sourceIsLoaded) {
return <ActivityIndicator />;
}
let player;
const playerClass = ptzCameras.length || isMaxWidth ? 'w-full' : 'max-w-5xl xl:w-1/2';
if (viewSource == 'mse' && config.birdseye.restream) {
if ('MediaSource' in window || 'ManagedMediaSource' in window) {
player = (
<Fragment>
<div className={playerClass}>
<video-stream
mode="mse"
src={new URL(`${baseUrl.replace(/^http/, 'ws')}live/webrtc/api/ws?src=birdseye`)}
/>
</div>
</Fragment>
);
} else {
player = (
<Fragment>
<div className="w-5xl text-center text-sm">
MSE is only supported on iOS 17.1+. You'll need to update if available or use jsmpeg / webRTC streams. See the docs for more info.
</div>
</Fragment>
);
}
} else if (viewSource == 'webrtc') {
player = (
<Fragment>
<div className={playerClass}>
<WebRtcPlayer camera="birdseye" />
</div>
</Fragment>
);
} else {
player = (
<Fragment>
<div className={playerClass}>
<JSMpegPlayer camera="birdseye" />
</div>
</Fragment>
);
}
return (
<div className="space-y-4 p-2 px-4">
<div className="flex justify-between">
<Heading className="p-2" size="2xl">
Birdseye
</Heading>
{!ptzCameras.length && (
<button
className="bg-gray-500 hover:bg-gray-700 text-white font-bold py-2 px-4 rounded hidden md:inline"
onClick={() => setIsMaxWidth(!isMaxWidth)}
>
Toggle width
</button>
)}
{config.birdseye.restream && (
<select
className="basis-1/8 cursor-pointer rounded dark:bg-slate-800"
value={viewSource}
onChange={(e) => setViewSource(e.target.value)}
key="width-changer"
>
{sourceValues.map((item) => (
<option key={item} value={item}>
{item}
</option>
))}
</select>
)}
</div>
<div className="xl:flex justify-between">
<div className={playerClass}>
{' '}
{/* Use dynamic class */}
{player}
</div>
{ptzCameras.length ? (
<div className="dark:bg-gray-800 shadow-md hover:shadow-lg rounded-lg transition-shadow p-4 w-full sm:w-min xl:h-min xl:w-1/2">
<Heading size="sm">Control Panel</Heading>
{ptzCameras.map((camera) => (
<div className="p-4" key={camera}>
<Heading size="lg">{camera.replaceAll('_', ' ')}</Heading>
<CameraControlPanel camera={camera} />
</div>
))}
</div>
) : null}
</div>
</div>
);
}
function getDefaultLiveMode(config) {
if (config) {
if (config.birdseye.restream) {
return config.ui.live_mode;
}
return 'jsmpeg';
}
return undefined;
}

View File

@@ -0,0 +1,234 @@
import { h, Fragment } from 'preact';
import AutoUpdatingCameraImage from '../components/AutoUpdatingCameraImage';
import ActivityIndicator from '../components/ActivityIndicator';
import JSMpegPlayer from '../components/JSMpegPlayer';
import Button from '../components/Button';
import Card from '../components/Card';
import Heading from '../components/Heading';
import Link from '../components/Link';
import SettingsIcon from '../icons/Settings';
import Switch from '../components/Switch';
import ButtonsTabbed from '../components/ButtonsTabbed';
import { usePersistence } from '../context';
import { useCallback, useMemo, useState } from 'preact/hooks';
import { useApiHost } from '../api';
import useSWR from 'swr';
import WebRtcPlayer from '../components/WebRtcPlayer';
import '../components/MsePlayer';
import CameraControlPanel from '../components/CameraControlPanel';
import { baseUrl } from '../api/baseUrl';
const emptyObject = Object.freeze({});
export default function Camera({ camera }) {
const { data: config } = useSWR('config');
const { data: trackedLabels } = useSWR(['labels', { camera }]);
const apiHost = useApiHost();
const [showSettings, setShowSettings] = useState(false);
const [viewMode, setViewMode] = useState('live');
const cameraConfig = config?.cameras[camera];
const restreamEnabled =
cameraConfig && Object.keys(config.go2rtc.streams || {}).includes(cameraConfig.live.stream_name);
const jsmpegWidth = cameraConfig
? Math.round(cameraConfig.live.height * (cameraConfig.detect.width / cameraConfig.detect.height))
: 0;
const [viewSource, setViewSource, sourceIsLoaded] = usePersistence(
`${camera}-source`,
getDefaultLiveMode(config, cameraConfig, restreamEnabled)
);
const sourceValues = restreamEnabled ? ['mse', 'webrtc', 'jsmpeg'] : ['jsmpeg'];
const [options, setOptions] = usePersistence(`${camera}-feed`, emptyObject);
const handleSetOption = useCallback(
(id, value) => {
const newOptions = { ...options, [id]: value };
setOptions(newOptions);
},
[options, setOptions]
);
const searchParams = useMemo(
() =>
new URLSearchParams(
Object.keys(options).reduce((memo, key) => {
memo.push([key, options[key] === true ? '1' : '0']);
return memo;
}, [])
),
[options]
);
const handleToggleSettings = useCallback(() => {
setShowSettings(!showSettings);
}, [showSettings, setShowSettings]);
if (!cameraConfig || !sourceIsLoaded) {
return <ActivityIndicator />;
}
if (!restreamEnabled) {
setViewSource('jsmpeg');
}
const optionContent = showSettings ? (
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4">
<Switch
checked={options['bbox']}
id="bbox"
onChange={handleSetOption}
label="Bounding box"
labelPosition="after"
/>
<Switch
checked={options['timestamp']}
id="timestamp"
onChange={handleSetOption}
label="Timestamp"
labelPosition="after"
/>
<Switch checked={options['zones']} id="zones" onChange={handleSetOption} label="Zones" labelPosition="after" />
<Switch
checked={options['mask']}
id="mask"
onChange={handleSetOption}
label="Motion Masks"
labelPosition="after"
/>
<Switch
checked={options['motion']}
id="motion"
onChange={handleSetOption}
label="Motion boxes"
labelPosition="after"
/>
<Switch
checked={options['regions']}
id="regions"
onChange={handleSetOption}
label="Regions"
labelPosition="after"
/>
<Link href={`/cameras/${camera}/editor`}>Mask & Zone creator</Link>
</div>
) : null;
let player;
if (viewMode === 'live') {
if (viewSource == 'mse' && restreamEnabled) {
if ('MediaSource' in window || 'ManagedMediaSource' in window) {
player = (
<Fragment>
<div className="max-w-5xl">
<video-stream
mode="mse"
src={
new URL(`${baseUrl.replace(/^http/, 'ws')}live/webrtc/api/ws?src=${cameraConfig.live.stream_name}`)
}
/>
</div>
</Fragment>
);
} else {
player = (
<Fragment>
<div className="w-5xl text-center text-sm">
MSE is only supported on iOS 17.1+. You'll need to update if available or use jsmpeg / webRTC streams. See the docs for more info.
</div>
</Fragment>
);
}
} else if (viewSource == 'webrtc' && restreamEnabled) {
player = (
<Fragment>
<div className="max-w-5xl">
<WebRtcPlayer camera={cameraConfig.live.stream_name} />
</div>
</Fragment>
);
} else {
player = (
<Fragment>
<div>
<JSMpegPlayer camera={camera} width={jsmpegWidth} height={cameraConfig.live.height} />
</div>
</Fragment>
);
}
} else if (viewMode === 'debug') {
player = (
<Fragment>
<div>
<AutoUpdatingCameraImage camera={camera} searchParams={searchParams} />
</div>
<Button onClick={handleToggleSettings} type="text">
<span className="w-5 h-5">
<SettingsIcon />
</span>{' '}
<span>{showSettings ? 'Hide' : 'Show'} Options</span>
</Button>
{showSettings ? <Card header="Options" elevated={false} content={optionContent} /> : null}
</Fragment>
);
}
return (
<div className="space-y-4 p-2 px-4">
<div className="flex justify-between">
<Heading className="p-2" size="2xl">
{camera.replaceAll('_', ' ')}
</Heading>
<select
className="basis-1/8 cursor-pointer rounded dark:bg-slate-800"
value={viewSource}
onChange={(e) => setViewSource(e.target.value)}
>
{sourceValues.map((item) => (
<option key={item} value={item}>
{item}
</option>
))}
</select>
</div>
<ButtonsTabbed viewModes={['live', 'debug']} currentViewMode={viewMode} setViewMode={setViewMode} />
{player}
{cameraConfig?.onvif?.host && (
<div className="dark:bg-gray-800 shadow-md hover:shadow-lg rounded-lg transition-shadow p-4 w-full sm:w-min">
<Heading size="sm">Control Panel</Heading>
<CameraControlPanel camera={camera} />
</div>
)}
<div className="space-y-4">
<Heading size="sm">Tracked objects</Heading>
<div className="flex flex-wrap justify-start">
{(trackedLabels || []).map((objectType) => (
<Card
className="mb-4 mr-4"
key={objectType}
header={objectType}
href={`/events?cameras=${camera}&labels=${encodeURIComponent(objectType)}`}
media={<img src={`${apiHost}api/${camera}/${encodeURIComponent(objectType)}/thumbnail.jpg`} />}
/>
))}
</div>
</div>
</div>
);
}
function getDefaultLiveMode(config, cameraConfig, restreamEnabled) {
if (cameraConfig) {
if (restreamEnabled) {
return config.ui.live_mode;
}
return 'jsmpeg';
}
return undefined;
}

View File

@@ -0,0 +1,752 @@
import { h } from 'preact';
import Card from '../components/Card.jsx';
import Button from '../components/Button.jsx';
import Heading from '../components/Heading.jsx';
import Switch from '../components/Switch.jsx';
import { useResizeObserver } from '../hooks';
import { useCallback, useMemo, useRef, useState } from 'preact/hooks';
import { useApiHost } from '../api';
import useSWR from 'swr';
import axios from 'axios';
export default function CameraMasks({ camera }) {
const { data: config } = useSWR('config');
const apiHost = useApiHost();
const imageRef = useRef(null);
const [snap, setSnap] = useState(true);
const cameraConfig = config.cameras[camera];
const {
motion: { mask: motionMask },
objects: { filters: objectFilters },
zones,
} = cameraConfig;
const { width, height } = cameraConfig.detect;
const [{ width: scaledWidth }] = useResizeObserver(imageRef);
const imageScale = scaledWidth / width;
const [motionMaskPoints, setMotionMaskPoints] = useState(
Array.isArray(motionMask)
? motionMask.map((mask) => getPolylinePoints(mask))
: motionMask
? [getPolylinePoints(motionMask)]
: []
);
const [zonePoints, setZonePoints] = useState(
Object.keys(zones).reduce((memo, zone) => ({ ...memo, [zone]: getPolylinePoints(zones[zone].coordinates) }), {})
);
const [objectMaskPoints, setObjectMaskPoints] = useState(
Object.keys(objectFilters).reduce(
(memo, name) => ({
...memo,
[name]: Array.isArray(objectFilters[name].mask)
? objectFilters[name].mask.map((mask) => getPolylinePoints(mask))
: objectFilters[name].mask
? [getPolylinePoints(objectFilters[name].mask)]
: [],
}),
{}
)
);
const [editing, setEditing] = useState({ set: motionMaskPoints, key: 0, fn: setMotionMaskPoints });
const [success, setSuccess] = useState();
const [error, setError] = useState();
const handleUpdateEditable = useCallback(
(newPoints) => {
let newSet;
if (Array.isArray(editing.set)) {
newSet = [...editing.set];
newSet[editing.key] = newPoints;
} else if (editing.subkey !== undefined) {
newSet = { ...editing.set };
newSet[editing.key][editing.subkey] = newPoints;
} else {
newSet = { ...editing.set, [editing.key]: newPoints };
}
editing.set = newSet;
editing.fn(newSet);
},
[editing]
);
// Motion mask methods
const handleAddMask = useCallback(() => {
const newMotionMaskPoints = [...motionMaskPoints, []];
setMotionMaskPoints(newMotionMaskPoints);
setEditing({ set: newMotionMaskPoints, key: newMotionMaskPoints.length - 1, fn: setMotionMaskPoints });
}, [motionMaskPoints, setMotionMaskPoints]);
const handleEditMask = useCallback(
(key) => {
setEditing({ set: motionMaskPoints, key, fn: setMotionMaskPoints });
},
[setEditing, motionMaskPoints, setMotionMaskPoints]
);
const handleRemoveMask = useCallback(
(key) => {
const newMotionMaskPoints = [...motionMaskPoints];
newMotionMaskPoints.splice(key, 1);
setMotionMaskPoints(newMotionMaskPoints);
},
[motionMaskPoints, setMotionMaskPoints]
);
const handleCopyMotionMasks = useCallback(() => {
const textToCopy = ` motion:
mask:
${motionMaskPoints.map((mask) => ` - ${polylinePointsToPolyline(mask)}`).join('\n')}`;
if (window.navigator.clipboard && window.navigator.clipboard.writeText) {
// Use Clipboard API if available
window.navigator.clipboard.writeText(textToCopy).catch((err) => {
throw new Error('Failed to copy text: ', err);
});
} else {
// Fallback to document.execCommand('copy')
const textarea = document.createElement('textarea');
textarea.value = textToCopy;
document.body.appendChild(textarea);
textarea.select();
try {
const successful = document.execCommand('copy');
if (!successful) {
throw new Error('Failed to copy text');
}
} catch (err) {
throw new Error('Failed to copy text: ', err);
}
document.body.removeChild(textarea);
}
}, [motionMaskPoints]);
const handleSaveMotionMasks = useCallback(async () => {
try {
const queryParameters = motionMaskPoints
.map((mask, index) => `cameras.${camera}.motion.mask.${index}=${polylinePointsToPolyline(mask)}`)
.join('&');
const endpoint = `config/set?${queryParameters}`;
const response = await axios.put(endpoint);
if (response.status === 200) {
setSuccess(response.data.message);
}
} catch (error) {
if (error.response) {
setError(error.response.data.message);
} else {
setError(error.message);
}
}
}, [camera, motionMaskPoints]);
// Zone methods
const handleEditZone = useCallback(
(key) => {
setEditing({ set: zonePoints, key, fn: setZonePoints });
},
[setEditing, zonePoints, setZonePoints]
);
const handleAddZone = useCallback(() => {
const n = Object.keys(zonePoints).filter((name) => name.startsWith('zone_')).length;
const zoneName = `zone_${n}`;
const newZonePoints = { ...zonePoints, [zoneName]: [] };
setZonePoints(newZonePoints);
setEditing({ set: newZonePoints, key: zoneName, fn: setZonePoints });
}, [zonePoints, setZonePoints]);
const handleRemoveZone = useCallback(
(key) => {
const newZonePoints = { ...zonePoints };
delete newZonePoints[key];
setZonePoints(newZonePoints);
},
[zonePoints, setZonePoints]
);
const handleCopyZones = useCallback(async () => {
const textToCopy = ` zones:
${Object.keys(zonePoints)
.map(
(zoneName) => ` ${zoneName}:
coordinates: ${polylinePointsToPolyline(zonePoints[zoneName])}`
)
.join('\n')}`;
if (window.navigator.clipboard && window.navigator.clipboard.writeText) {
// Use Clipboard API if available
window.navigator.clipboard.writeText(textToCopy).catch((err) => {
throw new Error('Failed to copy text: ', err);
});
} else {
// Fallback to document.execCommand('copy')
const textarea = document.createElement('textarea');
textarea.value = textToCopy;
document.body.appendChild(textarea);
textarea.select();
try {
const successful = document.execCommand('copy');
if (!successful) {
throw new Error('Failed to copy text');
}
} catch (err) {
throw new Error('Failed to copy text: ', err);
}
document.body.removeChild(textarea);
}
}, [zonePoints]);
const handleSaveZones = useCallback(async () => {
try {
const queryParameters = Object.keys(zonePoints)
.map(
(zoneName) =>
`cameras.${camera}.zones.${zoneName}.coordinates=${polylinePointsToPolyline(zonePoints[zoneName])}`
)
.join('&');
const endpoint = `config/set?${queryParameters}`;
const response = await axios.put(endpoint);
if (response.status === 200) {
setSuccess(response.data);
}
} catch (error) {
if (error.response) {
setError(error.response.data.message);
} else {
setError(error.message);
}
}
}, [camera, zonePoints]);
// Object methods
const handleEditObjectMask = useCallback(
(key, subkey) => {
setEditing({ set: objectMaskPoints, key, subkey, fn: setObjectMaskPoints });
},
[setEditing, objectMaskPoints, setObjectMaskPoints]
);
const handleAddObjectMask = useCallback(() => {
const n = Object.keys(objectMaskPoints).filter((name) => name.startsWith('object_')).length;
const newObjectName = `object_${n}`;
const newObjectMaskPoints = { ...objectMaskPoints, [newObjectName]: [[]] };
setObjectMaskPoints(newObjectMaskPoints);
setEditing({ set: newObjectMaskPoints, key: newObjectName, subkey: 0, fn: setObjectMaskPoints });
}, [objectMaskPoints, setObjectMaskPoints, setEditing]);
const handleRemoveObjectMask = useCallback(
(key, subkey) => {
const newObjectMaskPoints = { ...objectMaskPoints };
delete newObjectMaskPoints[key][subkey];
setObjectMaskPoints(newObjectMaskPoints);
},
[objectMaskPoints, setObjectMaskPoints]
);
const handleCopyObjectMasks = useCallback(async () => {
await window.navigator.clipboard.writeText(` objects:
filters:
${Object.keys(objectMaskPoints)
.map((objectName) =>
objectMaskPoints[objectName].length
? ` ${objectName}:
mask: ${polylinePointsToPolyline(objectMaskPoints[objectName])}`
: ''
)
.filter(Boolean)
.join('\n')}`);
}, [objectMaskPoints]);
const handleSaveObjectMasks = useCallback(async () => {
try {
const queryParameters = Object.keys(objectMaskPoints)
.filter((objectName) => objectMaskPoints[objectName].length > 0)
.map(
(objectName, index) =>
`cameras.${camera}.objects.filters.${objectName}.mask.${index}=${polylinePointsToPolyline(
objectMaskPoints[objectName]
)}`
)
.join('&');
const endpoint = `config/set?${queryParameters}`;
const response = await axios.put(endpoint);
if (response.status === 200) {
setSuccess(response.data);
}
} catch (error) {
if (error.response) {
setError(error.response.data.message);
} else {
setError(error.message);
}
}
}, [camera, objectMaskPoints]);
const handleAddToObjectMask = useCallback(
(key) => {
const newObjectMaskPoints = { ...objectMaskPoints, [key]: [...objectMaskPoints[key], []] };
setObjectMaskPoints(newObjectMaskPoints);
setEditing({
set: newObjectMaskPoints,
key,
subkey: newObjectMaskPoints[key].length - 1,
fn: setObjectMaskPoints,
});
},
[objectMaskPoints, setObjectMaskPoints, setEditing]
);
const handleChangeSnap = useCallback(
(id, value) => {
setSnap(value);
},
[setSnap]
);
return (
<div className="flex-col space-y-4 p-2 px-4">
<Heading size="2xl">{camera} mask & zone creator</Heading>
<Card
content={
<div>
<p>This tool can help you create masks & zones for your {camera} camera.</p>
<ul>
<li>Click to add a point.</li>
<li>Click and hold on an existing point to move it.</li>
<li>Right-Click on an existing point to delete it.</li>
</ul>
</div>
}
header="Instructions"
/>
<Card
content={
<p>
When done, copy each mask configuration into your <code className="font-mono">config.yml</code> file restart
your Frigate instance to save your changes.
</p>
}
header="Warning"
/>
{success && <div className="max-h-20 text-green-500">{success}</div>}
{error && <div className="p-4 overflow-scroll text-red-500 whitespace-pre-wrap">{error}</div>}
<div className="space-y-4">
<div className="relative">
<img ref={imageRef} src={`${apiHost}api/${camera}/latest.jpg`} />
<EditableMask
onChange={handleUpdateEditable}
points={'subkey' in editing ? editing.set[editing.key][editing.subkey] : editing.set[editing.key]}
scale={imageScale}
snap={snap}
width={width}
height={height}
setError={setError}
/>
</div>
<div className="max-w-xs">
<Switch checked={snap} label="Snap to edges" labelPosition="after" onChange={handleChangeSnap} />
</div>
</div>
<div className="flex-col space-y-4">
<MaskValues
editing={editing}
title="Motion masks"
onCopy={handleCopyMotionMasks}
onSave={handleSaveMotionMasks}
onCreate={handleAddMask}
onEdit={handleEditMask}
onRemove={handleRemoveMask}
points={motionMaskPoints}
yamlPrefix={'motion:\n mask:'}
yamlKeyPrefix={maskYamlKeyPrefix}
/>
<MaskValues
editing={editing}
title="Zones"
onCopy={handleCopyZones}
onSave={handleSaveZones}
onCreate={handleAddZone}
onEdit={handleEditZone}
onRemove={handleRemoveZone}
points={zonePoints}
yamlPrefix="zones:"
yamlKeyPrefix={zoneYamlKeyPrefix}
/>
<MaskValues
isMulti
editing={editing}
title="Object masks"
onAdd={handleAddToObjectMask}
onCopy={handleCopyObjectMasks}
onSave={handleSaveObjectMasks}
onCreate={handleAddObjectMask}
onEdit={handleEditObjectMask}
onRemove={handleRemoveObjectMask}
points={objectMaskPoints}
yamlPrefix={'objects:\n filters:'}
yamlKeyPrefix={objectYamlKeyPrefix}
/>
</div>
</div>
);
}
function maskYamlKeyPrefix() {
return ' - ';
}
function zoneYamlKeyPrefix(_points, key) {
return ` ${key}:
coordinates: `;
}
function objectYamlKeyPrefix() {
return ' - ';
}
const MaskInset = 20;
function boundedSize(value, maxValue, snap) {
const newValue = Math.min(Math.max(0, Math.round(value)), maxValue);
if (snap) {
if (newValue <= MaskInset) {
return 0;
} else if (maxValue - newValue <= MaskInset) {
return maxValue;
}
}
return newValue;
}
function EditableMask({ onChange, points, scale, snap, width, height, setError }) {
const boundingRef = useRef(null);
const handleMovePoint = useCallback(
(index, newX, newY) => {
if (newX < 0 && newY < 0) {
return;
}
const x = boundedSize(newX / scale, width, snap);
const y = boundedSize(newY / scale, height, snap);
const newPoints = [...points];
newPoints[index] = [x, y];
onChange(newPoints);
},
[height, width, onChange, scale, points, snap]
);
// Add a new point between the closest two other points
const handleAddPoint = useCallback(
(event) => {
if (!points) {
setError('You must choose an item to edit or add a new item before adding a point.');
return
}
const { offsetX, offsetY } = event;
const scaledX = boundedSize((offsetX - MaskInset) / scale, width, snap);
const scaledY = boundedSize((offsetY - MaskInset) / scale, height, snap);
const newPoint = [scaledX, scaledY];
const { index } = points.reduce(
(result, point, i) => {
const nextPoint = points.length === i + 1 ? points[0] : points[i + 1];
const distance0 = Math.sqrt(Math.pow(point[0] - newPoint[0], 2) + Math.pow(point[1] - newPoint[1], 2));
const distance1 = Math.sqrt(Math.pow(point[0] - nextPoint[0], 2) + Math.pow(point[1] - nextPoint[1], 2));
const distance = distance0 + distance1;
return distance < result.distance ? { distance, index: i } : result;
},
{ distance: Infinity, index: -1 }
);
const newPoints = [...points];
newPoints.splice(index, 0, newPoint);
onChange(newPoints);
},
[height, width, scale, points, onChange, snap, setError]
);
const handleRemovePoint = useCallback(
(index) => {
const newPoints = [...points];
newPoints.splice(index, 1);
onChange(newPoints);
},
[points, onChange]
);
const scaledPoints = useMemo(() => scalePolylinePoints(points, scale), [points, scale]);
return (
<div
className="absolute"
style={`top: -${MaskInset}px; right: -${MaskInset}px; bottom: -${MaskInset}px; left: -${MaskInset}px`}
>
{!scaledPoints
? null
: scaledPoints.map(([x, y], i) => (
<PolyPoint
key={i}
boundingRef={boundingRef}
index={i}
onMove={handleMovePoint}
onRemove={handleRemovePoint}
x={x + MaskInset}
y={y + MaskInset}
/>
))}
<div className="absolute inset-0 right-0 bottom-0" onClick={handleAddPoint} ref={boundingRef} />
<svg
width="100%"
height="100%"
className="absolute pointer-events-none"
style={`top: ${MaskInset}px; right: ${MaskInset}px; bottom: ${MaskInset}px; left: ${MaskInset}px`}
>
{!scaledPoints ? null : (
<g>
<polyline points={polylinePointsToPolyline(scaledPoints)} fill="rgba(244,0,0,0.5)" />
</g>
)}
</svg>
</div>
);
}
function MaskValues({
isMulti = false,
editing,
title,
onAdd,
onCopy,
onSave,
onCreate,
onEdit,
onRemove,
points,
yamlPrefix,
yamlKeyPrefix,
}) {
const [showButtons, setShowButtons] = useState(false);
const handleMousein = useCallback(() => {
setShowButtons(true);
}, [setShowButtons]);
const handleMouseout = useCallback(
(event) => {
const el = event.toElement || event.relatedTarget;
if (!el || el.parentNode === event.target) {
return;
}
setShowButtons(false);
},
[setShowButtons]
);
const handleEdit = useCallback(
(event) => {
const { key, subkey } = event.target.dataset;
onEdit(key, subkey);
},
[onEdit]
);
const handleRemove = useCallback(
(event) => {
const { key, subkey } = event.target.dataset;
onRemove(key, subkey);
},
[onRemove]
);
const handleAdd = useCallback(
(event) => {
const { key } = event.target.dataset;
onAdd(key);
},
[onAdd]
);
return (
<div className="overflow-hidden" onMouseOver={handleMousein} onMouseOut={handleMouseout}>
<div className="flex space-x-4">
<Heading className="flex-grow self-center" size="base">
{title}
</Heading>
<Button onClick={onCopy}>Copy</Button>
<Button onClick={onCreate}>Add</Button>
<Button onClick={onSave}>Save</Button>
</div>
<pre className="relative overflow-auto font-mono text-gray-900 dark:text-gray-100 rounded bg-gray-100 dark:bg-gray-800 p-2">
{yamlPrefix}
{Object.keys(points).map((mainkey) => {
if (isMulti) {
return (
<div key={mainkey}>
{` ${mainkey}:\n mask:\n`}
{onAdd && showButtons ? (
<Button className="absolute -mt-12 right-0 font-sans" data-key={mainkey} onClick={handleAdd}>
{`Add to ${mainkey}`}
</Button>
) : null}
{points[mainkey].map((item, subkey) => (
<Item
key={subkey}
mainkey={mainkey}
subkey={subkey}
editing={editing}
handleEdit={handleEdit}
handleRemove={handleRemove}
points={item}
showButtons={showButtons}
yamlKeyPrefix={yamlKeyPrefix}
/>
))}
</div>
);
}
return (
<Item
key={mainkey}
mainkey={mainkey}
editing={editing}
handleAdd={onAdd ? handleAdd : undefined}
handleEdit={handleEdit}
handleRemove={handleRemove}
points={points[mainkey]}
showButtons={showButtons}
yamlKeyPrefix={yamlKeyPrefix}
/>
);
})}
</pre>
</div>
);
}
function Item({ mainkey, subkey, editing, handleEdit, points, showButtons, _handleAdd, handleRemove, yamlKeyPrefix }) {
return (
<span
data-key={mainkey}
data-subkey={subkey}
className={`block hover:text-blue-400 cursor-pointer relative ${
editing.key === mainkey && editing.subkey === subkey ? 'text-blue-800 dark:text-blue-600' : ''
}`}
onClick={handleEdit}
title="Click to edit"
>
{`${yamlKeyPrefix(points, mainkey, subkey)}${polylinePointsToPolyline(points)}`}
{showButtons ? (
<Button
className="absolute top-0 right-0"
color="red"
data-key={mainkey}
data-subkey={subkey}
onClick={handleRemove}
>
Remove
</Button>
) : null}
</span>
);
}
function getPolylinePoints(polyline) {
if (!polyline) {
return;
}
return polyline.split(',').reduce((memo, point, i) => {
if (i % 2) {
memo[memo.length - 1].push(parseInt(point, 10));
} else {
memo.push([parseInt(point, 10)]);
}
return memo;
}, []);
}
function scalePolylinePoints(polylinePoints, scale) {
if (!polylinePoints) {
return;
}
return polylinePoints.map(([x, y]) => [Math.round(x * scale), Math.round(y * scale)]);
}
function polylinePointsToPolyline(polylinePoints) {
if (!polylinePoints) {
return;
}
return polylinePoints.reduce((memo, [x, y]) => `${memo}${x},${y},`, '').replace(/,$/, '');
}
const PolyPointRadius = 10;
function PolyPoint({ boundingRef, index, x, y, onMove, onRemove }) {
const [hidden, setHidden] = useState(false);
const handleDragOver = useCallback(
(event) => {
if (
!boundingRef.current ||
(event.target !== boundingRef.current && !boundingRef.current.contains(event.target))
) {
return;
}
onMove(index, event.layerX - PolyPointRadius * 2, event.layerY - PolyPointRadius * 2);
},
[onMove, index, boundingRef]
);
const handleDragStart = useCallback(() => {
boundingRef.current && boundingRef.current.addEventListener('dragover', handleDragOver, false);
setHidden(true);
}, [setHidden, boundingRef, handleDragOver]);
const handleDragEnd = useCallback(() => {
boundingRef.current && boundingRef.current.removeEventListener('dragover', handleDragOver);
setHidden(false);
}, [setHidden, boundingRef, handleDragOver]);
const handleRightClick = useCallback(
(event) => {
event.preventDefault();
onRemove(index);
},
[onRemove, index]
);
const handleClick = useCallback((event) => {
event.stopPropagation();
event.preventDefault();
}, []);
return (
<div
className={`${hidden ? 'opacity-0' : ''} bg-gray-900 rounded-full absolute z-20`}
style={`top: ${y - PolyPointRadius}px; left: ${x - PolyPointRadius}px; width: 20px; height: 20px;`}
draggable
onClick={handleClick}
onContextMenu={handleRightClick}
onDragStart={handleDragStart}
onDragEnd={handleDragEnd}
/>
);
}

View File

@@ -0,0 +1,76 @@
import { h, Fragment } from 'preact';
import JSMpegPlayer from '../components/JSMpegPlayer';
import Heading from '../components/Heading';
import { useState } from 'preact/hooks';
import useSWR from 'swr';
import { Tabs, TextTab } from '../components/Tabs';
import { LiveChip } from '../components/LiveChip';
import { DebugCamera } from '../components/DebugCamera';
import HistoryViewer from '../components/HistoryViewer/HistoryViewer.tsx';
export default function Camera({ camera }) {
const { data: config } = useSWR('config');
const [playerType, setPlayerType] = useState('live');
const cameraConfig = config?.cameras[camera];
const handleTabChange = (index) => {
if (index === 0) {
setPlayerType('history');
} else if (index === 1) {
setPlayerType('live');
} else if (index === 2) {
setPlayerType('debug');
}
};
return (
<div className='flex bg-white dark:bg-black w-full h-full justify-center'>
<div className='relative max-w-screen-md flex-grow w-full'>
<div className='absolute top-0 text-white w-full'>
<div className='flex pt-4 pl-4 items-center w-full h-16 z10'>
{(playerType === 'live' || playerType === 'debug') && (
<Fragment>
<Heading size='xl' className='mr-2 text-black dark:text-white'>
{camera}
</Heading>
<LiveChip className='text-green-400 border-2 border-solid border-green-400 bg-opacity-40 dark:bg-opacity-10' />
</Fragment>
)}
</div>
</div>
<div className='flex flex-col justify-center h-full'>
<RenderPlayer camera={camera} cameraConfig={cameraConfig} playerType={playerType} />
</div>
<div className='absolute flex justify-center bottom-8 w-full'>
<Tabs selectedIndex={1} onChange={handleTabChange} className='justify'>
<TextTab text='History' />
<TextTab text='Live' />
<TextTab text='Debug' />
</Tabs>
</div>
</div>
</div>
);
}
const RenderPlayer = function ({ camera, cameraConfig, playerType }) {
const liveWidth = Math.round(cameraConfig.live.height * (cameraConfig.detect.width / cameraConfig.detect.height));
if (playerType === 'live') {
return (
<Fragment>
<div>
<JSMpegPlayer camera={camera} width={liveWidth} height={cameraConfig.live.height} />
</div>
</Fragment>
);
} else if (playerType === 'history') {
return <HistoryViewer camera={camera} />;
} else if (playerType === 'debug') {
return <DebugCamera camera={camera} />;
}
return <Fragment />;
};

View File

@@ -0,0 +1,186 @@
import { h, Fragment } from 'preact';
import ActivityIndicator from '../components/ActivityIndicator';
import Card from '../components/Card';
import CameraImage from '../components/CameraImage';
import AudioIcon from '../icons/Audio';
import ClipIcon from '../icons/Clip';
import MotionIcon from '../icons/Motion';
import SettingsIcon from '../icons/Settings';
import SnapshotIcon from '../icons/Snapshot';
import { useAudioState, useDetectState, useRecordingsState, useSnapshotsState } from '../api/ws';
import { useMemo } from 'preact/hooks';
import useSWR from 'swr';
import { useRef, useState } from 'react';
import { useResizeObserver } from '../hooks';
import Dialog from '../components/Dialog';
import Switch from '../components/Switch';
import Heading from '../components/Heading';
import Button from '../components/Button';
export default function Cameras() {
const { data: config } = useSWR('config');
const containerRef = useRef(null);
const [{ width: containerWidth }] = useResizeObserver(containerRef);
// Add scrollbar width (when visible) to the available observer width to eliminate screen juddering.
// https://github.com/blakeblackshear/frigate/issues/1657
let scrollBarWidth = 0;
if (window.innerWidth && document.body.offsetWidth) {
scrollBarWidth = window.innerWidth - document.body.offsetWidth;
}
const availableWidth = scrollBarWidth ? containerWidth + scrollBarWidth : containerWidth;
return !config ? (
<ActivityIndicator />
) : (
<div className="grid grid-cols-1 3xl:grid-cols-3 md:grid-cols-2 gap-4 p-2 px-4" ref={containerRef}>
<SortedCameras config={config} unsortedCameras={config.cameras} availableWidth={availableWidth} />
</div>
);
}
function SortedCameras({ config, unsortedCameras, availableWidth }) {
const sortedCameras = useMemo(
() =>
Object.entries(unsortedCameras)
.filter(([_, conf]) => conf.ui.dashboard)
.sort(([_, aConf], [__, bConf]) => aConf.ui.order - bConf.ui.order),
[unsortedCameras]
);
return (
<Fragment>
{sortedCameras.map(([camera, conf]) => (
<Camera key={camera} name={camera} config={config.cameras[camera]} conf={conf} availableWidth={availableWidth} />
))}
</Fragment>
);
}
function Camera({ name, config, availableWidth }) {
const { payload: detectValue, send: sendDetect } = useDetectState(name);
const { payload: recordValue, send: sendRecordings } = useRecordingsState(name);
const { payload: snapshotValue, send: sendSnapshots } = useSnapshotsState(name);
const { payload: audioValue, send: sendAudio } = useAudioState(name);
const [cameraOptions, setCameraOptions] = useState('');
const href = `/cameras/${name}`;
const buttons = useMemo(() => {
return [
{ name: 'Events', href: `/events?cameras=${name}` },
{ name: 'Recordings', href: `/recording/${name}` },
];
}, [name]);
const cleanName = useMemo(() => {
return `${name.replaceAll('_', ' ')}`;
}, [name]);
const icons = useMemo(
() => (availableWidth < 448 ? [
{
icon: SettingsIcon,
color: 'gray',
onClick: () => {
setCameraOptions(config.name);
},
},
] : [
{
name: `Toggle detect ${detectValue === 'ON' ? 'off' : 'on'}`,
icon: MotionIcon,
color: detectValue === 'ON' ? 'blue' : 'gray',
onClick: () => {
sendDetect(detectValue === 'ON' ? 'OFF' : 'ON', true);
},
},
{
name: config.record.enabled_in_config
? `Toggle recordings ${recordValue === 'ON' ? 'off' : 'on'}`
: 'Recordings must be enabled in the config to be turned on in the UI.',
icon: ClipIcon,
color: config.record.enabled_in_config ? (recordValue === 'ON' ? 'blue' : 'gray') : 'red',
onClick: () => {
if (config.record.enabled_in_config) {
sendRecordings(recordValue === 'ON' ? 'OFF' : 'ON', true);
}
},
},
{
name: `Toggle snapshots ${snapshotValue === 'ON' ? 'off' : 'on'}`,
icon: SnapshotIcon,
color: snapshotValue === 'ON' ? 'blue' : 'gray',
onClick: () => {
sendSnapshots(snapshotValue === 'ON' ? 'OFF' : 'ON', true);
},
},
config.audio.enabled_in_config
? {
name: `Toggle audio detection ${audioValue === 'ON' ? 'off' : 'on'}`,
icon: AudioIcon,
color: audioValue === 'ON' ? 'blue' : 'gray',
onClick: () => {
sendAudio(audioValue === 'ON' ? 'OFF' : 'ON', true);
},
}
: null,
]).filter((button) => button != null),
[config, availableWidth, setCameraOptions, audioValue, sendAudio, detectValue, sendDetect, recordValue, sendRecordings, snapshotValue, sendSnapshots]
);
return (
<Fragment>
{cameraOptions && (
<Dialog>
<div className="p-4">
<Heading size="md">{`${name.replaceAll('_', ' ')} Settings`}</Heading>
<Switch
className="my-3"
checked={detectValue == 'ON'}
id="detect"
onChange={() => sendDetect(detectValue === 'ON' ? 'OFF' : 'ON', true)}
label="Detect"
labelPosition="before"
/>
{config.record.enabled_in_config && <Switch
className="my-3"
checked={recordValue == 'ON'}
id="record"
onChange={() => sendRecordings(recordValue === 'ON' ? 'OFF' : 'ON', true)}
label="Recordings"
labelPosition="before"
/>}
<Switch
className="my-3"
checked={snapshotValue == 'ON'}
id="snapshot"
onChange={() => sendSnapshots(snapshotValue === 'ON' ? 'OFF' : 'ON', true)}
label="Snapshots"
labelPosition="before"
/>
{config.audio.enabled_in_config && <Switch
className="my-3"
checked={audioValue == 'ON'}
id="audio"
onChange={() => sendAudio(audioValue === 'ON' ? 'OFF' : 'ON', true)}
label="Audio Detection"
labelPosition="before"
/>}
</div>
<div className="p-2 flex justify-start flex-row-reverse space-x-2">
<Button className="ml-2" onClick={() => setCameraOptions('')} type="text">
Close
</Button>
</div>
</Dialog>
)}
<Card
buttons={buttons}
href={href}
header={cleanName}
icons={icons}
media={<CameraImage camera={name} stretch />}
/>
</Fragment>
);
}

View File

@@ -0,0 +1,118 @@
import { h } from 'preact';
import useSWR from 'swr';
import axios from 'axios';
import { useApiHost } from '../api';
import ActivityIndicator from '../components/ActivityIndicator';
import Heading from '../components/Heading';
import { useEffect, useState } from 'preact/hooks';
import Button from '../components/Button';
import { editor, Uri } from 'monaco-editor';
import { setDiagnosticsOptions } from 'monaco-yaml';
import copy from 'copy-to-clipboard';
export default function Config() {
const apiHost = useApiHost();
const { data: config } = useSWR('config/raw');
const [success, setSuccess] = useState();
const [error, setError] = useState();
const onHandleSaveConfig = async (e, save_option) => {
if (e) {
e.stopPropagation();
}
axios
.post(`config/save?save_option=${save_option}`, window.editor.getValue(), {
headers: { 'Content-Type': 'text/plain' },
})
.then((response) => {
if (response.status === 200) {
setError('');
setSuccess(response.data.message);
}
})
.catch((error) => {
setSuccess('');
if (error.response) {
setError(error.response.data.message);
} else {
setError(error.message);
}
});
};
const handleCopyConfig = async () => {
copy(window.editor.getValue());
};
useEffect(() => {
if (!config) {
return;
}
if (document.getElementById('container').children.length > 0) {
// we don't need to recreate the editor if it already exists
return;
}
const modelUri = Uri.parse('a://b/api/config/schema.json');
let yamlModel;
if (editor.getModels().length > 0) {
yamlModel = editor.getModel(modelUri);
} else {
yamlModel = editor.createModel(config, 'yaml', modelUri);
}
setDiagnosticsOptions({
enableSchemaRequest: true,
hover: true,
completion: true,
validate: true,
format: true,
schemas: [
{
uri: `${apiHost}api/config/schema.json`,
fileMatch: [String(modelUri)],
},
],
});
window.editor = editor.create(document.getElementById('container'), {
language: 'yaml',
model: yamlModel,
scrollBeyondLastLine: false,
theme: 'vs-dark',
});
});
if (!config) {
return <ActivityIndicator />;
}
return (
<div className="space-y-4 p-2 px-4 h-full">
<div className="flex justify-between">
<Heading>Config</Heading>
<div>
<Button className="mx-2" onClick={(e) => handleCopyConfig(e)}>
Copy Config
</Button>
<Button className="mx-2" onClick={(e) => onHandleSaveConfig(e, 'restart')}>
Save & Restart
</Button>
<Button className="mx-2" onClick={(e) => onHandleSaveConfig(e, 'saveonly')}>
Save Only
</Button>
</div>
</div>
{success && <div className="max-h-20 text-green-500">{success}</div>}
{error && <div className="p-4 overflow-scroll text-red-500 whitespace-pre-wrap">{error}</div>}
<div id="container" className="h-full" />
</div>
);
}

View File

@@ -0,0 +1,942 @@
import { h, Fragment } from 'preact';
import { route } from 'preact-router';
import ActivityIndicator from '../components/ActivityIndicator';
import Heading from '../components/Heading';
import { Tabs, TextTab } from '../components/Tabs';
import Link from '../components/Link';
import { useApiHost } from '../api';
import useSWR from 'swr';
import useSWRInfinite from 'swr/infinite';
import axios from 'axios';
import { useState, useRef, useCallback, useMemo } from 'preact/hooks';
import VideoPlayer from '../components/VideoPlayer';
import { StarRecording } from '../icons/StarRecording';
import { Submitted } from '../icons/Submitted';
import { Snapshot } from '../icons/Snapshot';
import { UploadPlus } from '../icons/UploadPlus';
import { Clip } from '../icons/Clip';
import { Zone } from '../icons/Zone';
import { Camera } from '../icons/Camera';
import { Clock } from '../icons/Clock';
import { Delete } from '../icons/Delete';
import { Download } from '../icons/Download';
import Menu, { MenuItem } from '../components/Menu';
import CalendarIcon from '../icons/Calendar';
import Calendar from '../components/Calendar';
import Button from '../components/Button';
import Dialog from '../components/Dialog';
import MultiSelect from '../components/MultiSelect';
import { formatUnixTimestampToDateTime, getDurationFromTimestamps } from '../utils/dateUtil';
import TimeAgo from '../components/TimeAgo';
import Timepicker from '../components/TimePicker';
import TimelineSummary from '../components/TimelineSummary';
import TimelineEventOverlay from '../components/TimelineEventOverlay';
import { Score } from '../icons/Score';
import { About } from '../icons/About';
import MenuIcon from '../icons/Menu';
import { MenuOpen } from '../icons/MenuOpen';
const API_LIMIT = 25;
const daysAgo = (num) => {
let date = new Date();
date.setDate(date.getDate() - num);
return new Date(date.getFullYear(), date.getMonth(), date.getDate()).getTime() / 1000;
};
const monthsAgo = (num) => {
let date = new Date();
date.setMonth(date.getMonth() - num);
return new Date(date.getFullYear(), date.getMonth(), date.getDate()).getTime() / 1000;
};
export default function Events({ path, ...props }) {
const apiHost = useApiHost();
const { data: config } = useSWR('config');
const timezone = useMemo(() => config?.ui?.timezone || Intl.DateTimeFormat().resolvedOptions().timeZone, [config]);
const [searchParams, setSearchParams] = useState({
before: null,
after: null,
cameras: props.cameras ?? 'all',
labels: props.labels ?? 'all',
zones: props.zones ?? 'all',
sub_labels: props.sub_labels ?? 'all',
time_range: '00:00,24:00',
timezone,
favorites: props.favorites ?? 0,
is_submitted: props.is_submitted ?? -1,
event: props.event,
});
const [state, setState] = useState({
showDownloadMenu: false,
showDatePicker: false,
showCalendar: false,
showPlusSubmit: false,
});
const [plusSubmitEvent, setPlusSubmitEvent] = useState({
id: null,
label: null,
validBox: null,
});
const [uploading, setUploading] = useState([]);
const [viewEvent, setViewEvent] = useState(props.event);
const [eventOverlay, setEventOverlay] = useState();
const [eventDetailType, setEventDetailType] = useState('clip');
const [downloadEvent, setDownloadEvent] = useState({
id: null,
label: null,
box: null,
has_clip: false,
has_snapshot: false,
plus_id: undefined,
end_time: null,
});
const [deleteFavoriteState, setDeleteFavoriteState] = useState({
deletingFavoriteEventId: null,
showDeleteFavorite: false,
});
const [showInProgress, setShowInProgress] = useState((props.event || props.cameras || props.labels) == null);
const eventsFetcher = useCallback(
(path, params) => {
if (searchParams.event) {
path = `${path}/${searchParams.event}`;
return axios.get(path).then((res) => [res.data]);
}
params = { ...params, in_progress: 0, include_thumbnails: 0, limit: API_LIMIT };
return axios.get(path, { params }).then((res) => res.data);
},
[searchParams]
);
const getKey = useCallback(
(index, prevData) => {
if (index > 0) {
const lastDate = prevData[prevData.length - 1].start_time;
const pagedParams = { ...searchParams, before: lastDate };
return ['events', pagedParams];
}
return ['events', searchParams];
},
[searchParams]
);
const { data: ongoingEvents, mutate: refreshOngoingEvents } = useSWR([
'events',
{ in_progress: 1, include_thumbnails: 0 },
]);
const {
data: eventPages,
mutate: refreshEvents,
size,
setSize,
isValidating,
} = useSWRInfinite(getKey, eventsFetcher);
const mutate = () => {
refreshEvents();
refreshOngoingEvents();
};
const { data: allLabels } = useSWR(['labels']);
const { data: allSubLabels } = useSWR(['sub_labels', { split_joined: 1 }]);
const filterValues = useMemo(
() => ({
cameras: Object.keys(config?.cameras || {}),
zones: [
...Object.values(config?.cameras || {})
.reduce((memo, camera) => {
memo = memo.concat(Object.keys(camera?.zones || {}));
return memo;
}, [])
.filter((value, i, self) => self.indexOf(value) === i),
'None',
],
labels: Object.values(allLabels || {}),
sub_labels: (allSubLabels || []).length > 0 ? [...Object.values(allSubLabels), 'None'] : [],
}),
[config, allLabels, allSubLabels]
);
const onSave = async (e, eventId, save) => {
e.stopPropagation();
let response;
if (save) {
response = await axios.post(`events/${eventId}/retain`);
} else {
response = await axios.delete(`events/${eventId}/retain`);
}
if (response.status === 200) {
mutate();
}
};
const onDelete = async (e, eventId, saved) => {
e.stopPropagation();
if (saved) {
setDeleteFavoriteState({ deletingFavoriteEventId: eventId, showDeleteFavorite: true });
} else {
const response = await axios.delete(`events/${eventId}`);
if (response.status === 200) {
mutate();
}
}
};
const onToggleNamedFilter = (name, item) => {
let items;
if (searchParams[name] == 'all') {
const currentItems = Array.from(filterValues[name]);
// don't remove all if only one option
if (currentItems.length > 1) {
currentItems.splice(currentItems.indexOf(item), 1);
items = currentItems.join(',');
} else {
items = ['all'];
}
} else {
let currentItems = searchParams[name].length > 0 ? searchParams[name].split(',') : [];
if (currentItems.includes(item)) {
// don't remove the last item in the filter list
if (currentItems.length > 1) {
currentItems.splice(currentItems.indexOf(item), 1);
}
items = currentItems.join(',');
} else if (currentItems.length + 1 == filterValues[name].length) {
items = ['all'];
} else {
currentItems.push(item);
items = currentItems.join(',');
}
}
onFilter(name, items);
};
const onEventFrameSelected = (event, frame, seekSeconds) => {
if (this.player) {
this.player.pause();
this.player.currentTime(seekSeconds);
setEventOverlay(frame);
}
};
const datePicker = useRef();
const downloadButton = useRef();
const onDownloadClick = (e, event) => {
e.stopPropagation();
setDownloadEvent((_prev) => ({
id: event.id,
box: event?.data?.box || event.box,
label: event.label,
has_clip: event.has_clip,
has_snapshot: event.has_snapshot,
plus_id: event.plus_id,
end_time: event.end_time,
}));
downloadButton.current = e.target;
setState({ ...state, showDownloadMenu: true });
};
const showSubmitToPlus = (event_id, label, box, e) => {
if (e) {
e.stopPropagation();
}
// if any of the box coordinates are > 1, then the box data is from an older version
// and not valid to submit to plus with the snapshot image
setPlusSubmitEvent({ id: event_id, label, validBox: !box.some((d) => d > 1) });
setState({ ...state, showDownloadMenu: false, showPlusSubmit: true });
};
const handleSelectDateRange = useCallback(
(dates) => {
setShowInProgress(false);
setSearchParams({ ...searchParams, before: dates.before, after: dates.after });
setState({ ...state, showDatePicker: false });
},
[searchParams, setSearchParams, state, setState]
);
const handleSelectTimeRange = useCallback(
(timeRange) => {
setSearchParams({ ...searchParams, time_range: timeRange });
},
[searchParams]
);
const onFilter = useCallback(
(name, value) => {
setShowInProgress(false);
const updatedParams = { ...searchParams, [name]: value };
setSearchParams(updatedParams);
const queryString = Object.keys(updatedParams)
.map((key) => {
if (updatedParams[key] && updatedParams[key] != 'all') {
return `${key}=${updatedParams[key]}`;
}
return null;
})
.filter((val) => val)
.join('&');
route(`${path}?${queryString}`);
},
[path, searchParams, setSearchParams]
);
const onClickFilterSubmitted = useCallback(() => {
if (++searchParams.is_submitted > 1) {
searchParams.is_submitted = -1;
}
onFilter('is_submitted', searchParams.is_submitted);
}, [searchParams, onFilter]);
const isDone = (eventPages?.[eventPages.length - 1]?.length ?? 0) < API_LIMIT;
// hooks for infinite scroll
const observer = useRef();
const lastEventRef = useCallback(
(node) => {
if (isValidating) return;
if (observer.current) observer.current.disconnect();
try {
observer.current = new IntersectionObserver((entries) => {
if (entries[0].isIntersecting && !isDone) {
setSize(size + 1);
}
});
if (node) observer.current.observe(node);
} catch (e) {
// no op
}
},
[size, setSize, isValidating, isDone]
);
const onSendToPlus = async (id, false_positive, validBox) => {
if (uploading.includes(id)) {
return;
}
setUploading((prev) => [...prev, id]);
const response = false_positive
? await axios.put(`events/${id}/false_positive`)
: await axios.post(`events/${id}/plus`, validBox ? { include_annotation: 1 } : {});
if (response.status === 200) {
mutate(
(pages) =>
pages.map((page) =>
page.map((event) => {
if (event.id === id) {
return { ...event, plus_id: response.data.plus_id };
}
return event;
})
),
false
);
}
setUploading((prev) => prev.filter((i) => i !== id));
if (state.showDownloadMenu && downloadEvent.id === id) {
setState({ ...state, showDownloadMenu: false });
}
setState({ ...state, showPlusSubmit: false });
};
const handleEventDetailTabChange = (index) => {
setEventDetailType(index == 0 ? 'clip' : 'image');
};
if (!config) {
return <ActivityIndicator />;
}
return (
<div className="space-y-4 p-2 px-4 w-full">
<Heading>Events</Heading>
<div className="flex flex-wrap gap-2 items-center">
<MultiSelect
className="basis-1/5 cursor-pointer rounded dark:bg-slate-800"
title="Cameras"
options={filterValues.cameras}
selection={searchParams.cameras}
onToggle={(item) => onToggleNamedFilter('cameras', item)}
onShowAll={() => onFilter('cameras', ['all'])}
onSelectSingle={(item) => onFilter('cameras', item)}
/>
<MultiSelect
className="basis-1/5 cursor-pointer rounded dark:bg-slate-800"
title="Labels"
options={filterValues.labels}
selection={searchParams.labels}
onToggle={(item) => onToggleNamedFilter('labels', item)}
onShowAll={() => onFilter('labels', ['all'])}
onSelectSingle={(item) => onFilter('labels', item)}
/>
<MultiSelect
className="basis-1/5 cursor-pointer rounded dark:bg-slate-800"
title="Zones"
options={filterValues.zones}
selection={searchParams.zones}
onToggle={(item) => onToggleNamedFilter('zones', item)}
onShowAll={() => onFilter('zones', ['all'])}
onSelectSingle={(item) => onFilter('zones', item)}
/>
{filterValues.sub_labels.length > 0 && (
<MultiSelect
className="basis-1/5 cursor-pointer rounded dark:bg-slate-800"
title="Sub Labels"
options={filterValues.sub_labels}
selection={searchParams.sub_labels}
onToggle={(item) => onToggleNamedFilter('sub_labels', item)}
onShowAll={() => onFilter('sub_labels', ['all'])}
onSelectSingle={(item) => onFilter('sub_labels', item)}
/>
)}
{searchParams.event && (
<Button className="ml-2" onClick={() => onFilter('event', null)} type="text">
View All
</Button>
)}
<div className="ml-auto flex">
{config.plus.enabled && (
<Submitted
className="h-10 w-10 text-yellow-300 cursor-pointer ml-auto"
onClick={() => onClickFilterSubmitted()}
inner_fill={searchParams.is_submitted == 1 ? 'currentColor' : 'gray'}
outer_stroke={searchParams.is_submitted >= 0 ? 'currentColor' : 'gray'}
/>
)}
<StarRecording
className="h-10 w-10 text-yellow-300 cursor-pointer ml-auto"
onClick={() => onFilter('favorites', searchParams.favorites ? 0 : 1)}
fill={searchParams.favorites == 1 ? 'currentColor' : 'none'}
/>
</div>
<div ref={datePicker} className="ml-right">
<CalendarIcon
className="h-8 w-8 cursor-pointer"
onClick={() => setState({ ...state, showDatePicker: true })}
/>
</div>
</div>
{state.showDownloadMenu && (
<Menu onDismiss={() => setState({ ...state, showDownloadMenu: false })} relativeTo={downloadButton}>
{downloadEvent.has_snapshot && (
<MenuItem
icon={Snapshot}
label="Download Snapshot"
value="snapshot"
href={`${apiHost}api/events/${downloadEvent.id}/snapshot.jpg?download=true`}
download
/>
)}
{downloadEvent.has_clip && (
<MenuItem
icon={Clip}
label="Download Clip"
value="clip"
href={`${apiHost}api/events/${downloadEvent.id}/clip.mp4?download=true`}
download
/>
)}
{(event?.data?.type || 'object') == 'object' &&
downloadEvent.end_time &&
downloadEvent.has_snapshot &&
!downloadEvent.plus_id && (
<MenuItem
icon={UploadPlus}
label={uploading.includes(downloadEvent.id) ? 'Uploading...' : 'Send to Frigate+'}
value="plus"
onSelect={() => showSubmitToPlus(downloadEvent.id, downloadEvent.label, downloadEvent.box)}
/>
)}
{downloadEvent.plus_id && (
<MenuItem
icon={UploadPlus}
label={'Sent to Frigate+'}
value="plus"
onSelect={() => setState({ ...state, showDownloadMenu: false })}
/>
)}
</Menu>
)}
{state.showDatePicker && (
<Menu
className="rounded-t-none"
onDismiss={() => setState({ ...state, setShowDatePicker: false })}
relativeTo={datePicker}
>
<MenuItem label="All" value={{ before: null, after: null }} onSelect={handleSelectDateRange} />
<MenuItem label="Today" value={{ before: null, after: daysAgo(0) }} onSelect={handleSelectDateRange} />
<MenuItem
label="Yesterday"
value={{ before: daysAgo(0), after: daysAgo(1) }}
onSelect={handleSelectDateRange}
/>
<MenuItem label="Last 7 Days" value={{ before: null, after: daysAgo(7) }} onSelect={handleSelectDateRange} />
<MenuItem label="This Month" value={{ before: null, after: monthsAgo(0) }} onSelect={handleSelectDateRange} />
<MenuItem
label="Last Month"
value={{ before: monthsAgo(0), after: monthsAgo(1) }}
onSelect={handleSelectDateRange}
/>
<MenuItem
label="Custom Range"
value="custom"
onSelect={() => {
setState({ ...state, showCalendar: true, showDatePicker: false });
}}
/>
</Menu>
)}
{state.showCalendar && (
<span>
<Menu
className="rounded-t-none"
onDismiss={() => setState({ ...state, showCalendar: false })}
relativeTo={datePicker}
>
<Calendar
onChange={handleSelectDateRange}
dateRange={{ before: searchParams.before * 1000 || null, after: searchParams.after * 1000 || null }}
close={() => setState({ ...state, showCalendar: false })}
>
<Timepicker timeRange={searchParams.time_range} onChange={handleSelectTimeRange} />
</Calendar>
</Menu>
</span>
)}
{state.showPlusSubmit && (
<Dialog>
{config.plus.enabled ? (
<>
<div className="p-4">
<Heading size="lg">Submit to Frigate+</Heading>
<img
className="flex-grow-0"
src={`${apiHost}api/events/${plusSubmitEvent.id}/snapshot.jpg`}
alt={`${plusSubmitEvent.label}`}
/>
{plusSubmitEvent.validBox ? (
<p className="mb-2">
Objects in locations you want to avoid are not false positives. Submitting them as false positives
will confuse the model.
</p>
) : (
<p className="mb-2">
Events prior to version 0.13 can only be submitted to Frigate+ without annotations.
</p>
)}
</div>
{plusSubmitEvent.validBox ? (
<div className="p-2 flex justify-start flex-row-reverse space-x-2">
<Button className="ml-2" onClick={() => setState({ ...state, showPlusSubmit: false })} type="text">
{uploading.includes(plusSubmitEvent.id) ? 'Close' : 'Cancel'}
</Button>
<Button
className="ml-2"
color="red"
onClick={() => onSendToPlus(plusSubmitEvent.id, true, plusSubmitEvent.validBox)}
disabled={uploading.includes(plusSubmitEvent.id)}
type="text"
>
This is not a {plusSubmitEvent.label}
</Button>
<Button
className="ml-2"
color="green"
onClick={() => onSendToPlus(plusSubmitEvent.id, false, plusSubmitEvent.validBox)}
disabled={uploading.includes(plusSubmitEvent.id)}
type="text"
>
This is a {plusSubmitEvent.label}
</Button>
</div>
) : (
<div className="p-2 flex justify-start flex-row-reverse space-x-2">
<Button
className="ml-2"
onClick={() => setState({ ...state, showPlusSubmit: false })}
disabled={uploading.includes(plusSubmitEvent.id)}
type="text"
>
{uploading.includes(plusSubmitEvent.id) ? 'Close' : 'Cancel'}
</Button>
<Button
className="ml-2"
onClick={() => onSendToPlus(plusSubmitEvent.id, false, plusSubmitEvent.validBox)}
disabled={uploading.includes(plusSubmitEvent.id)}
type="text"
>
Submit to Frigate+
</Button>
</div>
)}
</>
) : (
<>
<div className="p-4">
<Heading size="lg">Setup a Frigate+ Account</Heading>
<p className="mb-2">In order to submit images to Frigate+, you first need to setup an account.</p>
<a
className="text-blue-500 hover:underline"
href="https://plus.frigate.video"
target="_blank"
rel="noopener noreferrer"
>
https://plus.frigate.video
</a>
</div>
<div className="p-2 flex justify-start flex-row-reverse space-x-2">
<Button className="ml-2" onClick={() => setState({ ...state, showPlusSubmit: false })} type="text">
Close
</Button>
</div>
</>
)}
</Dialog>
)}
{deleteFavoriteState.showDeleteFavorite && (
<Dialog>
<div className="p-4">
<Heading size="lg">Delete Saved Event?</Heading>
<p className="mb-2">Confirm deletion of saved event.</p>
</div>
<div className="p-2 flex justify-start flex-row-reverse space-x-2">
<Button
className="ml-2"
onClick={() => setDeleteFavoriteState({ ...state, showDeleteFavorite: false })}
type="text"
>
Cancel
</Button>
<Button
className="ml-2"
color="red"
onClick={(e) => {
setDeleteFavoriteState({ ...state, showDeleteFavorite: false });
onDelete(e, deleteFavoriteState.deletingFavoriteEventId, false);
}}
type="text"
>
Delete
</Button>
</div>
</Dialog>
)}
<div className="space-y-2">
{ongoingEvents ? (
<div>
<div className="flex">
<Heading className="py-4" size="sm">
Ongoing Events
</Heading>
<Button
className="rounded-full"
type="text"
color="gray"
aria-label="Events for currently tracked objects. Recordings are only saved based on your retain settings. See the recording docs for more info."
>
<About className="w-5" />
</Button>
<Button
className="rounded-full ml-auto"
type="iconOnly"
color="blue"
onClick={() => setShowInProgress(!showInProgress)}
>
{showInProgress ? <MenuOpen className="w-6" /> : <MenuIcon className="w-6" />}
</Button>
</div>
{showInProgress &&
ongoingEvents.map((event, _) => {
return (
<Event
className="my-2"
key={event.id}
config={config}
event={event}
eventDetailType={eventDetailType}
eventOverlay={eventOverlay}
viewEvent={viewEvent}
setViewEvent={setViewEvent}
uploading={uploading}
handleEventDetailTabChange={handleEventDetailTabChange}
onEventFrameSelected={onEventFrameSelected}
onDelete={onDelete}
onDispose={() => {
this.player = null;
}}
onDownloadClick={onDownloadClick}
onReady={(player) => {
this.player = player;
this.player.on('playing', () => {
setEventOverlay(undefined);
});
}}
onSave={onSave}
showSubmitToPlus={showSubmitToPlus}
/>
);
})}
</div>
) : null}
<Heading className="py-4" size="sm">
Past Events
</Heading>
{eventPages ? (
eventPages.map((page, i) => {
const lastPage = eventPages.length === i + 1;
return page.map((event, j) => {
const lastEvent = lastPage && page.length === j + 1;
return (
<Event
key={event.id}
config={config}
event={event}
eventDetailType={eventDetailType}
eventOverlay={eventOverlay}
viewEvent={viewEvent}
setViewEvent={setViewEvent}
lastEvent={lastEvent}
lastEventRef={lastEventRef}
uploading={uploading}
handleEventDetailTabChange={handleEventDetailTabChange}
onEventFrameSelected={onEventFrameSelected}
onDelete={onDelete}
onDispose={() => {
this.player = null;
}}
onDownloadClick={onDownloadClick}
onReady={(player) => {
this.player = player;
this.player.on('playing', () => {
setEventOverlay(undefined);
});
}}
onSave={onSave}
showSubmitToPlus={showSubmitToPlus}
/>
);
});
})
) : (
<ActivityIndicator />
)}
</div>
<div>{isDone ? null : <ActivityIndicator />}</div>
</div>
);
}
function Event({
className = '',
config,
event,
eventDetailType,
eventOverlay,
viewEvent,
setViewEvent,
lastEvent,
lastEventRef,
uploading,
handleEventDetailTabChange,
onEventFrameSelected,
onDelete,
onDispose,
onDownloadClick,
onReady,
onSave,
showSubmitToPlus,
}) {
const apiHost = useApiHost();
return (
<div className={className}>
<div
ref={lastEvent ? lastEventRef : false}
className="flex bg-slate-100 dark:bg-slate-800 rounded cursor-pointer min-w-[330px]"
onClick={() => (viewEvent === event.id ? setViewEvent(null) : setViewEvent(event.id))}
>
<div
className="relative rounded-l flex-initial min-w-[125px] h-[125px] bg-contain bg-no-repeat bg-center"
style={{
'background-image': `url(${apiHost}api/events/${event.id}/thumbnail.jpg)`,
}}
>
<StarRecording
className="h-6 w-6 text-yellow-300 absolute top-1 right-1 cursor-pointer"
onClick={(e) => onSave(e, event.id, !event.retain_indefinitely)}
fill={event.retain_indefinitely ? 'currentColor' : 'none'}
/>
{event.end_time ? null : (
<div className="bg-slate-300 dark:bg-slate-700 absolute bottom-0 text-center w-full uppercase text-sm rounded-bl">
In progress
</div>
)}
</div>
<div className="m-2 flex grow">
<div className="flex flex-col grow">
<div className="capitalize text-lg font-bold">
{event.label.replaceAll('_', ' ')}
{event.sub_label ? `: ${event.sub_label.replaceAll('_', ' ')}` : null}
</div>
<div className="text-sm flex">
<Clock className="h-5 w-5 mr-2 inline" />
{formatUnixTimestampToDateTime(event.start_time, { ...config.ui })}
<div className="hidden sm:inline">
<span className="m-1">-</span>
<TimeAgo time={event.start_time * 1000} dense />
</div>
<div className="hidden sm:inline">
<span className="m-1" />( {getDurationFromTimestamps(event.start_time, event.end_time)} )
</div>
</div>
<div className="capitalize text-sm flex align-center mt-1">
<Camera className="h-5 w-5 mr-2 inline" />
{event.camera.replaceAll('_', ' ')}
</div>
{event.zones.length ? (
<div className="capitalize text-sm flex align-center">
<Zone className="w-5 h-5 mr-2 inline" />
{event.zones.join(', ').replaceAll('_', ' ')}
</div>
) : null}
<div className="capitalize text-sm flex align-center">
<Score className="w-5 h-5 mr-2 inline" />
{(event?.data?.top_score || event.top_score || 0) == 0
? null
: `${event.label}: ${((event?.data?.top_score || event.top_score) * 100).toFixed(0)}%`}
{(event?.data?.sub_label_score || 0) == 0
? null
: `, ${event.sub_label}: ${(event?.data?.sub_label_score * 100).toFixed(0)}%`}
</div>
</div>
<div class="hidden sm:flex flex-col justify-end mr-2">
{event.end_time && event.has_snapshot && (event?.data?.type || 'object') == 'object' && (
<Fragment>
{event.plus_id ? (
<div className="uppercase text-xs underline">
<Link
href={`https://plus.frigate.video/dashboard/edit-image/?id=${event.plus_id}`}
target="_blank"
rel="nofollow"
>
Edit in Frigate+
</Link>
</div>
) : (
<Button
color="gray"
disabled={uploading.includes(event.id)}
onClick={(e) => showSubmitToPlus(event.id, event.label, event?.data?.box || event.box, e)}
>
{uploading.includes(event.id) ? 'Uploading...' : 'Send to Frigate+'}
</Button>
)}
</Fragment>
)}
</div>
<div class="flex flex-col">
<Delete
className="h-6 w-6 cursor-pointer"
stroke="#f87171"
onClick={(e) => onDelete(e, event.id, event.retain_indefinitely)}
/>
<Download
className="h-6 w-6 mt-auto"
stroke={event.has_clip || event.has_snapshot ? '#3b82f6' : '#cbd5e1'}
onClick={(e) => onDownloadClick(e, event)}
/>
</div>
</div>
</div>
{viewEvent !== event.id ? null : (
<div className="space-y-4">
<div className="mx-auto max-w-7xl">
<div className="flex justify-center w-full py-2">
<Tabs
selectedIndex={event.has_clip && eventDetailType == 'clip' ? 0 : 1}
onChange={handleEventDetailTabChange}
className="justify"
>
<TextTab text="Clip" disabled={!event.has_clip} />
<TextTab text={event.has_snapshot ? 'Snapshot' : 'Thumbnail'} />
</Tabs>
</div>
<div>
{eventDetailType == 'clip' && event.has_clip ? (
<div>
<TimelineSummary
event={event}
onFrameSelected={(frame, seekSeconds) => onEventFrameSelected(event, frame, seekSeconds)}
/>
<div>
<VideoPlayer
options={{
preload: 'auto',
autoplay: true,
sources: [
{
src: `${apiHost}vod/event/${event.id}/master.m3u8`,
type: 'application/vnd.apple.mpegurl',
},
],
}}
seekOptions={{ forward: 10, backward: 5 }}
onReady={onReady}
onDispose={onDispose}
>
{eventOverlay ? (
<TimelineEventOverlay eventOverlay={eventOverlay} cameraConfig={config.cameras[event.camera]} />
) : null}
</VideoPlayer>
</div>
</div>
) : null}
{eventDetailType == 'image' || !event.has_clip ? (
<div className="flex justify-center">
<img
className="flex-grow-0"
src={
event.has_snapshot
? `${apiHost}api/events/${event.id}/snapshot.jpg?bbox=1`
: `${apiHost}api/events/${event.id}/thumbnail.jpg`
}
alt={`${event.label} at ${((event?.data?.top_score || event.top_score) * 100).toFixed(
0
)}% confidence`}
/>
</div>
) : null}
</div>
</div>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,263 @@
import Heading from '../components/Heading';
import { useState } from 'preact/hooks';
import useSWR, { mutate } from 'swr';
import Button from '../components/Button';
import axios from 'axios';
import { baseUrl } from '../api/baseUrl';
import { Fragment } from 'preact';
import ActivityIndicator from '../components/ActivityIndicator';
import { Play } from '../icons/Play';
import { Delete } from '../icons/Delete';
import LargeDialog from '../components/DialogLarge';
import VideoPlayer from '../components/VideoPlayer';
import Dialog from '../components/Dialog';
export default function Export() {
const { data: config } = useSWR('config');
const { data: exports } = useSWR('exports/', (url) => axios({ baseURL: baseUrl, url }).then((res) => res.data));
// Export States
const [camera, setCamera] = useState('select');
const [playback, setPlayback] = useState('select');
const [message, setMessage] = useState({ text: '', error: false });
const currentDate = new Date();
currentDate.setHours(0, 0, 0, 0);
const offsetMs = currentDate.getTimezoneOffset() * 60 * 1000;
const localDate = new Date(currentDate.getTime() - offsetMs);
const localISODate = localDate.toISOString().split('T')[0];
const [startDate, setStartDate] = useState(localISODate);
const [startTime, setStartTime] = useState('00:00:00');
const [endDate, setEndDate] = useState(localISODate);
const [endTime, setEndTime] = useState('23:59:59');
// Export States
const [selectedClip, setSelectedClip] = useState();
const [deleteClip, setDeleteClip] = useState();
const onHandleExport = () => {
if (camera == 'select') {
setMessage({ text: 'A camera needs to be selected.', error: true });
return;
}
if (playback == 'select') {
setMessage({ text: 'A playback factor needs to be selected.', error: true });
return;
}
if (!startDate || !startTime || !endDate || !endTime) {
setMessage({ text: 'A start and end time needs to be selected', error: true });
return;
}
const start = new Date(`${startDate}T${startTime}`).getTime() / 1000;
const end = new Date(`${endDate}T${endTime}`).getTime() / 1000;
if (end <= start) {
setMessage({ text: 'The end time must be after the start time.', error: true });
return;
}
axios
.post(`export/${camera}/start/${start}/end/${end}`, { playback })
.then((response) => {
if (response.status == 200) {
setMessage({ text: 'Successfully started export. View the file in the /exports folder.', error: false });
}
})
.catch((error) => {
if (error.response?.data?.message) {
setMessage({ text: `Failed to start export: ${error.response.data.message}`, error: true });
} else {
setMessage({ text: `Failed to start export: ${error.message}`, error: true });
}
});
};
const onHandleDelete = (clip) => {
axios.delete(`export/${clip}`).then((response) => {
if (response.status == 200) {
setDeleteClip();
mutate();
}
});
};
return (
<div className="space-y-4 p-2 px-4 w-full">
<Heading>Export</Heading>
{message.text && (
<div className={`max-h-20 ${message.error ? 'text-red-500' : 'text-green-500'}`}>{message.text}</div>
)}
{selectedClip && (
<LargeDialog>
<div>
<Heading className="p-2">Playback</Heading>
<VideoPlayer
options={{
preload: 'auto',
autoplay: true,
sources: [
{
src: `${baseUrl}exports/${selectedClip}`,
type: 'video/mp4',
},
],
}}
seekOptions={{ forward: 10, backward: 5 }}
onReady={(player) => {
this.player = player;
}}
onDispose={() => {
this.player = null;
}}
/>
</div>
<div className="p-2 flex justify-start flex-row-reverse space-x-2">
<Button className="ml-2" onClick={() => setSelectedClip('')} type="text">
Close
</Button>
</div>
</LargeDialog>
)}
{deleteClip && (
<Dialog>
<div className="p-4">
<Heading size="lg">Delete Export?</Heading>
<p className="py-4 mb-2">Confirm deletion of {deleteClip}.</p>
</div>
<div className="p-2 flex justify-start flex-row-reverse space-x-2">
<Button className="ml-2" onClick={() => setDeleteClip('')} type="text">
Close
</Button>
<Button className="ml-2" color="red" onClick={() => onHandleDelete(deleteClip)} type="text">
Delete
</Button>
</div>
</Dialog>
)}
<div className="xl:flex justify-between">
<div>
<div>
<select
className="me-2 cursor-pointer rounded dark:bg-slate-800"
value={camera}
onChange={(e) => setCamera(e.target.value)}
>
<option value="select">Select A Camera</option>
{Object.keys(config?.cameras || {}).map((item) => (
<option key={item} value={item}>
{item.replaceAll('_', ' ')}
</option>
))}
</select>
<select
className="ms-2 cursor-pointer rounded dark:bg-slate-800"
value={playback}
onChange={(e) => setPlayback(e.target.value)}
>
<option value="select">Select A Playback Factor</option>
<option value="realtime">Realtime</option>
<option value="timelapse_25x">Timelapse</option>
</select>
</div>
<div>
<Heading className="py-2" size="sm">
From:
</Heading>
<input
className="dark:bg-slate-800"
id="startDate"
type="date"
value={startDate}
onChange={(e) => setStartDate(e.target.value)}
/>
<input
className="dark:bg-slate-800"
id="startTime"
type="time"
value={startTime}
step="1"
onChange={(e) => setStartTime(e.target.value)}
/>
<Heading className="py-2" size="sm">
To:
</Heading>
<input
className="dark:bg-slate-800"
id="endDate"
type="date"
value={endDate}
onChange={(e) => setEndDate(e.target.value)}
/>
<input
className="dark:bg-slate-800"
id="endTime"
type="time"
value={endTime}
step="1"
onChange={(e) => setEndTime(e.target.value)}
/>
</div>
<Button className="my-4" onClick={() => onHandleExport()}>
Submit
</Button>
</div>
{exports && (
<div className="p-4 bg-gray-200 dark:bg-gray-800 xl:w-1/2">
<Heading size="md">Exports</Heading>
<Exports
exports={exports}
onSetClip={(clip) => setSelectedClip(clip)}
onDeleteClip={(clip) => setDeleteClip(clip)}
/>
</div>
)}
</div>
</div>
);
}
function Exports({ exports, onSetClip, onDeleteClip }) {
return (
<Fragment>
{exports.map((item) => (
<div className="my-4 p-4 bg-gray-100 dark:bg-gray-700" key={item.name}>
{item.name.startsWith('in_progress') ? (
<div className="flex justify-start text-center items-center">
<div>
<ActivityIndicator size="sm" />
</div>
<div className="px-2">{item.name.substring(12, item.name.length - 4)}</div>
</div>
) : (
<div className="flex justify-start items-center">
<Button type="iconOnly" onClick={() => onSetClip(item.name)}>
<Play className="h-6 w-6 text-green-600" />
</Button>
<a
className="text-blue-500 hover:underline overflow-hidden"
href={`${baseUrl}exports/${item.name}`}
download
>
{item.name.substring(0, item.name.length - 4)}
</a>
<Button className="ml-auto" type="iconOnly" onClick={() => onDeleteClip(item.name)}>
<Delete className="h-6 w-6" stroke="#f87171" />
</Button>
</div>
)}
</div>
))}
</Fragment>
);
}

View File

@@ -0,0 +1,50 @@
import { h } from 'preact';
import Heading from '../components/Heading';
import { useCallback, useEffect, useState } from 'preact/hooks';
import ButtonsTabbed from '../components/ButtonsTabbed';
import useSWR from 'swr';
import Button from '../components/Button';
import copy from 'copy-to-clipboard';
export default function Logs() {
const [logService, setLogService] = useState('frigate');
const [logs, setLogs] = useState('frigate');
const { data: frigateLogs } = useSWR('logs/frigate');
const { data: go2rtcLogs } = useSWR('logs/go2rtc');
const { data: nginxLogs } = useSWR('logs/nginx');
const handleCopyLogs = useCallback(() => {
copy(logs);
}, [logs]);
useEffect(() => {
switch (logService) {
case 'frigate':
setLogs(frigateLogs);
break;
case 'go2rtc':
setLogs(go2rtcLogs);
break;
case 'nginx':
setLogs(nginxLogs);
break;
}
}, [frigateLogs, go2rtcLogs, nginxLogs, logService, setLogs]);
return (
<div className="space-y-4 p-2 px-4">
<Heading>Logs</Heading>
<ButtonsTabbed viewModes={['frigate', 'go2rtc', 'nginx']} currentViewMode={logService} setViewMode={setLogService} />
<Button className="" onClick={handleCopyLogs}>
Copy to Clipboard
</Button>
<div className="overflow-auto font-mono text-sm text-gray-900 dark:text-gray-100 rounded bg-gray-100 dark:bg-gray-800 p-2 whitespace-pre-wrap">
{logs}
</div>
</div>
);
}

View File

@@ -0,0 +1,165 @@
import { h } from 'preact';
import { parseISO, endOfHour, startOfHour, getUnixTime } from 'date-fns';
import { useEffect, useMemo } from 'preact/hooks';
import ActivityIndicator from '../components/ActivityIndicator';
import Heading from '../components/Heading';
import RecordingPlaylist from '../components/RecordingPlaylist';
import VideoPlayer from '../components/VideoPlayer';
import { useApiHost } from '../api';
import useSWR from 'swr';
export default function Recording({ camera, date, hour = '00', minute = '00', second = '00' }) {
const { data: config } = useSWR('config');
const currentDate = useMemo(
() => (date ? parseISO(`${date}T${hour || '00'}:${minute || '00'}:${second || '00'}`) : new Date()),
[date, hour, minute, second]
);
const timezone = useMemo(() => config.ui?.timezone || Intl.DateTimeFormat().resolvedOptions().timeZone, [config]);
const apiHost = useApiHost();
const { data: recordingsSummary } = useSWR([`${camera}/recordings/summary`, { timezone }], {
revalidateOnFocus: false,
});
const recordingParams = {
before: getUnixTime(endOfHour(currentDate)),
after: getUnixTime(startOfHour(currentDate)),
};
const { data: recordings } = useSWR([`${camera}/recordings`, recordingParams], { revalidateOnFocus: false });
// calculates the seek seconds by adding up all the seconds in the segments prior to the playback time
const seekSeconds = useMemo(() => {
if (!recordings) {
return undefined;
}
const currentUnix = getUnixTime(currentDate);
const hourStart = getUnixTime(startOfHour(currentDate));
let seekSeconds = 0;
recordings.every((segment) => {
// if the next segment is past the desired time, stop calculating
if (segment.start_time > currentUnix) {
return false;
}
// if the segment starts before the hour, skip the seconds before the hour
const start = segment.start_time < hourStart ? hourStart : segment.start_time;
// if the segment ends after the selected time, use the selected time for end
const end = segment.end_time > currentUnix ? currentUnix : segment.end_time;
seekSeconds += end - start;
return true;
});
return seekSeconds;
}, [recordings, currentDate]);
const playlist = useMemo(() => {
if (!recordingsSummary) {
return [];
}
const selectedDayRecordingData = recordingsSummary.find((s) => !date || s.day === date);
if (!selectedDayRecordingData) {
return [];
}
const [year, month, day] = selectedDayRecordingData.day.split('-');
return selectedDayRecordingData.hours
.map((h) => {
return {
name: h.hour,
description: `${camera} recording @ ${h.hour}:00.`,
sources: [
{
src: `${apiHost}vod/${year}-${month}/${day}/${h.hour}/${camera}/${timezone.replaceAll(
'/',
','
)}/master.m3u8`,
type: 'application/vnd.apple.mpegurl',
},
],
};
})
.reverse();
}, [apiHost, date, recordingsSummary, camera, timezone]);
const playlistIndex = useMemo(() => {
const index = playlist.findIndex((item) => item.name === hour);
if (index === -1) {
return 0;
}
return index;
}, [playlist, hour]);
useEffect(() => {
if (this.player) {
this.player.playlist(playlist);
}
}, [playlist]);
useEffect(() => {
if (this.player) {
this.player.playlist.currentItem(playlistIndex);
}
}, [playlistIndex]);
useEffect(() => {
if (seekSeconds === undefined) {
return;
}
if (this.player) {
// if the playlist has moved on to the next item, then reset
if (this.player.playlist.currentItem() !== playlistIndex) {
this.player.playlist.currentItem(playlistIndex);
}
this.player.currentTime(seekSeconds);
// try and play since the user is likely to have interacted with the dom
this.player.play();
}
}, [seekSeconds, playlistIndex]);
if (!recordingsSummary || !config) {
return <ActivityIndicator />;
}
if (recordingsSummary.length === 0) {
return (
<div className="space-y-4">
<Heading>{camera} Recordings</Heading>
<div class="bg-yellow-100 border-l-4 border-yellow-500 text-yellow-700 p-4" role="alert">
<p class="font-bold">No Recordings Found</p>
<p>Make sure you have enabled the record role in your configuration for the {camera} camera.</p>
</div>
</div>
);
}
return (
<div className="space-y-4 p-2 px-4">
<Heading>{camera.replaceAll('_', ' ')} Recordings</Heading>
<div className="text-xs">Dates and times are based on the timezone {timezone}</div>
<VideoPlayer
options={{
preload: 'auto',
}}
onReady={(player) => {
player.on('ratechange', () => player.defaultPlaybackRate(player.playbackRate()));
if (player.playlist) {
player.playlist(playlist);
player.playlist.autoadvance(0);
player.playlist.currentItem(playlistIndex);
if (seekSeconds !== undefined) {
player.currentTime(seekSeconds);
}
this.player = player;
}
}}
onDispose={() => {
this.player = null;
}}
>
<RecordingPlaylist camera={camera} recordings={recordingsSummary} selectedDate={currentDate} />
</VideoPlayer>
</div>
);
}

View File

@@ -0,0 +1,175 @@
import { h, Fragment } from 'preact';
import ActivityIndicator from '../components/ActivityIndicator';
import Heading from '../components/Heading';
import { useWs } from '../api/ws';
import useSWR from 'swr';
import { Table, Tbody, Thead, Tr, Th, Td } from '../components/Table';
import Link from '../components/Link';
import Button from '../components/Button';
import { About } from '../icons/About';
const emptyObject = Object.freeze({});
export default function Storage() {
const { data: storage } = useSWR('recordings/storage');
const {
value: { payload: stats },
} = useWs('stats');
const { data: initialStats } = useSWR('stats');
const { service } = stats || initialStats || emptyObject;
if (!service || !storage) {
return <ActivityIndicator />;
}
const getUnitSize = (MB) => {
if (isNaN(MB) || MB < 0) return 'Invalid number';
if (MB < 1024) return `${MB} MiB`;
if (MB < 1048576) return `${(MB / 1024).toFixed(2)} GiB`;
return `${(MB / 1048576).toFixed(2)} TiB`;
};
let storage_usage;
if (
service &&
service['storage']['/media/frigate/recordings']['total'] != service['storage']['/media/frigate/clips']['total']
) {
storage_usage = (
<Fragment>
<Tr>
<Td>Recordings</Td>
<Td>{getUnitSize(service['storage']['/media/frigate/recordings']['used'])}</Td>
<Td>{getUnitSize(service['storage']['/media/frigate/recordings']['total'])}</Td>
</Tr>
<Tr>
<Td>Snapshots</Td>
<Td>{getUnitSize(service['storage']['/media/frigate/clips']['used'])}</Td>
<Td>{getUnitSize(service['storage']['/media/frigate/clips']['total'])}</Td>
</Tr>
</Fragment>
);
} else {
storage_usage = (
<Fragment>
<Tr>
<Td>Recordings & Snapshots</Td>
<Td>{getUnitSize(service['storage']['/media/frigate/recordings']['used'])}</Td>
<Td>{getUnitSize(service['storage']['/media/frigate/recordings']['total'])}</Td>
</Tr>
</Fragment>
);
}
return (
<div className="space-y-4 p-2 px-4">
<Heading>Storage</Heading>
<Fragment>
<Heading size="lg">Overview</Heading>
<div data-testid="overview-types" className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="dark:bg-gray-800 shadow-md hover:shadow-lg rounded-lg transition-shadow">
<div className="flex justify-start">
<div className="text-lg flex justify-between p-4">Data</div>
<Button
className="rounded-full"
type="text"
color="gray"
aria-label="Overview of total used storage and total capacity of the drives that hold the recordings and snapshots directories."
>
<About className="w-5" />
</Button>
</div>
<div className="p-2">
<Table className="w-full">
<Thead>
<Tr>
<Th>Location</Th>
<Th>Used</Th>
<Th>Total</Th>
</Tr>
</Thead>
<Tbody>{storage_usage}</Tbody>
</Table>
</div>
</div>
<div className="dark:bg-gray-800 shadow-md hover:shadow-lg rounded-lg transition-shadow">
<div className="flex justify-start">
<div className="text-lg flex justify-between p-4">Memory</div>
<Button
className="rounded-full"
type="text"
color="gray"
aria-label="Overview of used and total memory in frigate process."
>
<About className="w-5" />
</Button>
</div>
<div className="p-2">
<Table className="w-full">
<Thead>
<Tr>
<Th>Location</Th>
<Th>Used</Th>
<Th>Total</Th>
</Tr>
</Thead>
<Tbody>
<Tr>
<Td>/dev/shm</Td>
<Td>{getUnitSize(service['storage']['/dev/shm']['used'])}</Td>
<Td>{getUnitSize(service['storage']['/dev/shm']['total'])}</Td>
</Tr>
<Tr>
<Td>/tmp/cache</Td>
<Td>{getUnitSize(service['storage']['/tmp/cache']['used'])}</Td>
<Td>{getUnitSize(service['storage']['/tmp/cache']['total'])}</Td>
</Tr>
</Tbody>
</Table>
</div>
</div>
</div>
<div className="flex justify-start">
<Heading size="lg">Cameras</Heading>
<Button
className="rounded-full"
type="text"
color="gray"
aria-label="Overview of per-camera storage usage and bandwidth."
>
<About className="w-5" />
</Button>
</div>
<div data-testid="detectors" className="grid grid-cols-1 3xl:grid-cols-3 md:grid-cols-2 gap-4">
{Object.entries(storage).map(([name, camera]) => (
<div key={name} className="dark:bg-gray-800 shadow-md hover:shadow-lg rounded-lg transition-shadow">
<div className="capitalize text-lg flex justify-between p-4">
<Link href={`/cameras/${name}`}>{name.replaceAll('_', ' ')}</Link>
</div>
<div className="p-2">
<Table className="w-full">
<Thead>
<Tr>
<Th>Usage</Th>
<Th>Stream Bandwidth</Th>
</Tr>
</Thead>
<Tbody>
<Tr>
<Td>{Math.round(camera['usage_percent'] ?? 0)}%</Td>
<Td>{camera['bandwidth'] ? `${getUnitSize(camera['bandwidth'])}/hr` : 'Calculating...'}</Td>
</Tr>
</Tbody>
</Table>
</div>
</div>
))}
</div>
</Fragment>
</div>
);
}

View File

@@ -0,0 +1,126 @@
import { h } from 'preact';
import ArrowDropdown from '../icons/ArrowDropdown';
import ArrowDropup from '../icons/ArrowDropup';
import Button from '../components/Button';
import Dialog from '../components/Dialog';
import Heading from '../components/Heading';
import Select from '../components/Select';
import Switch from '../components/Switch';
import TextField from '../components/TextField';
import { useCallback, useState } from 'preact/hooks';
export default function StyleGuide() {
const [switches, setSwitches] = useState({ 0: false, 1: true, 2: false, 3: false });
const [showDialog, setShowDialog] = useState(false);
const handleSwitch = useCallback(
(id, checked) => {
setSwitches({ ...switches, [id]: checked });
},
[switches]
);
const handleDismissDialog = () => {
setShowDialog(false);
};
return (
<div className="p-2 px-4">
<Heading size="md">Button</Heading>
<div className="flex space-x-4 mb-4">
<Button>Default</Button>
<Button color="red">Danger</Button>
<Button color="green">Save</Button>
<Button color="gray">Gray</Button>
<Button disabled>Disabled</Button>
</div>
<div className="flex space-x-4 mb-4">
<Button type="text">Default</Button>
<Button color="red" type="text">
Danger
</Button>
<Button color="green" type="text">
Save
</Button>
<Button color="gray" type="text">
Gray
</Button>
<Button disabled type="text">
Disabled
</Button>
</div>
<div className="flex space-x-4 mb-4">
<Button type="outlined">Default</Button>
<Button color="red" type="outlined">
Danger
</Button>
<Button color="green" type="outlined">
Save
</Button>
<Button color="gray" type="outlined">
Gray
</Button>
<Button disabled type="outlined">
Disabled
</Button>
</div>
<Heading size="md">Dialog</Heading>
<Button
onClick={() => {
setShowDialog(true);
}}
>
Show Dialog
</Button>
{showDialog ? (
<Dialog
onDismiss={handleDismissDialog}
title="This is a dialog"
text="Would you like to see more?"
actions={[
{ text: 'Yes', color: 'red', onClick: handleDismissDialog },
{ text: 'No', onClick: handleDismissDialog },
]}
/>
) : null}
<Heading size="md">Switch</Heading>
<div className="flex-col space-y-4 max-w-4xl">
<Switch label="Disabled, off" labelPosition="after" />
<Switch label="Disabled, on" labelPosition="after" checked />
<Switch
label="Enabled, (off initial)"
labelPosition="after"
checked={switches[0]}
id={0}
onChange={handleSwitch}
/>
<Switch
label="Enabled, (on initial)"
labelPosition="after"
checked={switches[1]}
id={1}
onChange={handleSwitch}
/>
<Switch checked={switches[2]} id={2} label="Label before" onChange={handleSwitch} />
<Switch checked={switches[3]} id={3} label="Label after" labelPosition="after" onChange={handleSwitch} />
</div>
<Heading size="md">Select</Heading>
<div className="flex space-x-4 mb-4 max-w-4xl">
<Select label="Basic select box" options={['All', 'None', 'Other']} selected="None" />
</div>
<Heading size="md">TextField</Heading>
<div className="flex-col space-y-4 max-w-4xl">
<TextField label="Default text field" />
<TextField label="Pre-filled" value="This is my pre-filled value" />
<TextField label="With help" helpText="This is some help text" />
<TextField label="Leading icon" leadingIcon={ArrowDropdown} />
<TextField label="Trailing icon" trailingIcon={ArrowDropup} />
</div>
</div>
);
}

View File

@@ -0,0 +1,487 @@
import { h, Fragment } from 'preact';
import ActivityIndicator from '../components/ActivityIndicator';
import Button from '../components/Button';
import Heading from '../components/Heading';
import Link from '../components/Link';
import { useWs } from '../api/ws';
import useSWR from 'swr';
import axios from 'axios';
import { Table, Tbody, Thead, Tr, Th, Td } from '../components/Table';
import { useState } from 'preact/hooks';
import Dialog from '../components/Dialog';
import TimeAgo from '../components/TimeAgo';
import copy from 'copy-to-clipboard';
import { About } from '../icons/About';
import { WebUI } from '../icons/WebUI';
const emptyObject = Object.freeze({});
export default function System() {
const [state, setState] = useState({ showFfprobe: false, ffprobe: '' });
const { data: config } = useSWR('config');
const {
value: { payload: stats },
} = useWs('stats');
const { data: initialStats } = useSWR('stats');
const {
cpu_usages,
gpu_usages,
bandwidth_usages,
detectors,
service = {},
detection_fps: _,
processes,
cameras,
} = stats || initialStats || emptyObject;
const detectorNames = Object.keys(detectors || emptyObject);
const gpuNames = Object.keys(gpu_usages || emptyObject);
const cameraNames = Object.keys(cameras || emptyObject);
const processesNames = Object.keys(processes || emptyObject);
const { data: go2rtc } = useSWR('go2rtc/api');
const onHandleFfprobe = async (camera, e) => {
if (e) {
e.stopPropagation();
}
setState({ ...state, showFfprobe: true });
const response = await axios.get('ffprobe', {
params: {
paths: `camera:${camera}`,
},
});
if (response.status === 200) {
setState({ ...state, showFfprobe: true, ffprobe: response.data });
} else {
setState({ ...state, showFfprobe: true, ffprobe: 'There was an error getting the ffprobe output.' });
}
};
const onCopyFfprobe = async () => {
copy(JSON.stringify(state.ffprobe).replace(/[\\\s]+/gi, ''));
setState({ ...state, ffprobe: '', showFfprobe: false });
};
const onHandleVainfo = async (e) => {
if (e) {
e.stopPropagation();
}
const response = await axios.get('vainfo');
if (response.status === 200) {
setState({
...state,
showVainfo: true,
vainfo: response.data,
});
} else {
setState({ ...state, showVainfo: true, vainfo: 'There was an error getting the vainfo output.' });
}
};
const onCopyVainfo = async () => {
copy(JSON.stringify(state.vainfo).replace(/[\\\s]+/gi, ''));
setState({ ...state, vainfo: '', showVainfo: false });
};
return (
<div className="space-y-4 p-2 px-4">
<div className="flex justify-between">
<Heading>
System <span className="text-sm">{service.version}</span>
</Heading>
{config && (
<span class="p-1">
go2rtc {go2rtc && `${go2rtc.version} `}
<Link
className="text-blue-500 hover:underline"
target="_blank"
rel="noopener noreferrer"
href="/api/go2rtc/streams"
>
streams info
</Link>
</span>
)}
</div>
{service.last_updated && (
<p>
<span>
Last refreshed: <TimeAgo time={service.last_updated * 1000} dense />
</span>
</p>
)}
{state.showFfprobe && (
<Dialog>
<div className="p-4 mb-2 max-h-96 whitespace-pre-line overflow-auto">
<Heading size="lg">Ffprobe Output</Heading>
{state.ffprobe != '' ? (
<div>
{state.ffprobe.map((stream, idx) => (
<div key={idx} className="mb-2 max-h-96 whitespace-pre-line">
<div>Stream {idx}:</div>
<div className="px-2">Return Code: {stream.return_code}</div>
<br />
{stream.return_code == 0 ? (
<div>
{stream.stdout.streams.map((codec, idx) => (
<div className="px-2" key={idx}>
{codec.width ? (
<div>
<div>Video:</div>
<br />
<div>Codec: {codec.codec_long_name}</div>
<div>
Resolution: {codec.width}x{codec.height}
</div>
<div>FPS: {codec.avg_frame_rate == '0/0' ? 'Unknown' : codec.avg_frame_rate}</div>
<br />
</div>
) : (
<div>
<div>Audio:</div>
<br />
<div>Codec: {codec.codec_long_name}</div>
<br />
</div>
)}
</div>
))}
</div>
) : (
<div className="px-2">
<div>Error: {stream.stderr}</div>
</div>
)}
</div>
))}
</div>
) : (
<ActivityIndicator />
)}
</div>
<div className="p-2 flex justify-start flex-row-reverse space-x-2">
<Button className="ml-2" onClick={() => onCopyFfprobe()} type="text">
Copy
</Button>
<Button
className="ml-2"
onClick={() => setState({ ...state, ffprobe: '', showFfprobe: false })}
type="text"
>
Close
</Button>
</div>
</Dialog>
)}
{state.showVainfo && (
<Dialog>
<div className="p-4 overflow-auto whitespace-pre-line">
<Heading size="lg">Vainfo Output</Heading>
{state.vainfo != '' ? (
<div className="mb-2 max-h-96 whitespace-pre-line">
<div className="">Return Code: {state.vainfo.return_code}</div>
<br />
<div className="">Process {state.vainfo.return_code == 0 ? 'Output' : 'Error'}:</div>
<br />
<div>{state.vainfo.return_code == 0 ? state.vainfo.stdout : state.vainfo.stderr}</div>
</div>
) : (
<ActivityIndicator />
)}
</div>
<div className="p-2 flex justify-start flex-row-reverse space-x-2 whitespace-pre-wrap">
<Button className="ml-2" onClick={() => onCopyVainfo()} type="text">
Copy
</Button>
<Button className="ml-2" onClick={() => setState({ ...state, vainfo: '', showVainfo: false })} type="text">
Close
</Button>
</div>
</Dialog>
)}
{!detectors ? (
<div>
<ActivityIndicator />
</div>
) : (
<Fragment>
<div className="flex justify-start">
<Heading className="self-center" size="lg">
Detectors
</Heading>
<Button
className="rounded-full"
type="text"
color="gray"
aria-label="Momentary resource usage of each process that is controlling the object detector. CPU % is for a single core."
>
<About className="w-5" />
</Button>
</div>
<div data-testid="detectors" className="grid grid-cols-1 3xl:grid-cols-3 md:grid-cols-2 gap-4">
{detectorNames.map((detector) => (
<div key={detector} className="dark:bg-gray-800 shadow-md hover:shadow-lg rounded-lg transition-shadow">
<div className="text-lg flex justify-between p-4">{detector}</div>
<div className="p-2">
<Table className="w-full">
<Thead>
<Tr>
<Th>P-ID</Th>
<Th>Inference Speed</Th>
<Th>CPU %</Th>
<Th>Memory %</Th>
{config.telemetry.network_bandwidth && <Th>Network Bandwidth</Th>}
</Tr>
</Thead>
<Tbody>
<Tr>
<Td>{detectors[detector]['pid']}</Td>
<Td>{detectors[detector]['inference_speed']} ms</Td>
<Td>{cpu_usages[detectors[detector]['pid']]?.['cpu'] || '- '}%</Td>
<Td>{cpu_usages[detectors[detector]['pid']]?.['mem'] || '- '}%</Td>
{config.telemetry.network_bandwidth && (
<Td>{bandwidth_usages[detectors[detector]['pid']]?.['bandwidth'] || '- '}KB/s</Td>
)}
</Tr>
</Tbody>
</Table>
</div>
</div>
))}
</div>
<div className="text-lg flex justify-between">
<div className="flex justify-start">
<Heading className="self-center" size="lg">
GPUs
</Heading>
<Button
className="rounded-full"
type="text"
color="gray"
aria-label="Momentary resource usage of each GPU. Intel GPUs do not support memory stats."
>
<About className="w-5" />
</Button>
</div>
<Button onClick={(e) => onHandleVainfo(e)}>vainfo</Button>
</div>
{!gpu_usages ? (
<div className="p-4">
<Link href={'https://docs.frigate.video/configuration/hardware_acceleration'}>
Hardware acceleration has not been setup, see the docs to setup hardware acceleration.
</Link>
</div>
) : (
<div data-testid="gpus" className="grid grid-cols-1 3xl:grid-cols-3 md:grid-cols-2 gap-4">
{gpuNames.map((gpu) => (
<div key={gpu} className="dark:bg-gray-800 shadow-md hover:shadow-lg rounded-lg transition-shadow">
<div className="text-lg flex justify-between p-4">{gpu}</div>
<div className="p-2">
{gpu_usages[gpu]['gpu'] == -1 ? (
<div className="p-4">
There was an error getting usage stats. This does not mean hardware acceleration is not working.
Either your GPU does not support this or Frigate does not have proper access to get statistics.
This is expected for the Home Assistant addon.
</div>
) : (
<Table className="w-full">
<Thead>
<Tr>
<Th>GPU %</Th>
<Th>Memory %</Th>
{'dec' in gpu_usages[gpu] && <Th>Decoder %</Th>}
{'enc' in gpu_usages[gpu] && <Th>Encoder %</Th>}
</Tr>
</Thead>
<Tbody>
<Tr>
<Td>{gpu_usages[gpu]['gpu']}</Td>
<Td>{gpu_usages[gpu]['mem']}</Td>
{'dec' in gpu_usages[gpu] && <Td>{gpu_usages[gpu]['dec']}</Td>}
{'enc' in gpu_usages[gpu] && <Td>{gpu_usages[gpu]['enc']}</Td>}
</Tr>
</Tbody>
</Table>
)}
</div>
</div>
))}
</div>
)}
<div className="flex justify-start">
<Heading className="self-center" size="lg">
Cameras
</Heading>
<Button
className="rounded-full"
type="text"
color="gray"
aria-label="Momentary resource usage of each process interacting with the camera stream. CPU % is for a single core."
>
<About className="w-5" />
</Button>
</div>
{!cameras ? (
<ActivityIndicator />
) : (
<div data-testid="cameras" className="grid grid-cols-1 3xl:grid-cols-3 md:grid-cols-2 gap-4">
{cameraNames.map(
(camera) =>
config.cameras[camera]['enabled'] && (
<div
key={camera}
className="dark:bg-gray-800 shadow-md hover:shadow-lg rounded-lg transition-shadow"
>
<div className="capitalize text-lg flex justify-between p-4">
<Link href={`/cameras/${camera}`}>{camera.replaceAll('_', ' ')}</Link>
<div className="flex">
{config.cameras[camera]['webui_url'] && (
<Button href={config.cameras[camera]['webui_url']} target="_blank">
Web UI
<WebUI className="ml-1 h-4 w-4" fill="white" stroke="white" />
</Button>
)}
<Button className="ml-2" onClick={(e) => onHandleFfprobe(camera, e)}>
ffprobe
</Button>
</div>
</div>
<div className="p-2">
<Table className="w-full">
<Thead>
<Tr>
<Th>Process</Th>
<Th>P-ID</Th>
<Th>FPS</Th>
<Th>CPU %</Th>
<Th>Memory %</Th>
{config.telemetry.network_bandwidth && <Th>Network Bandwidth</Th>}
</Tr>
</Thead>
<Tbody>
<Tr key="ffmpeg" index="0">
<Td>
ffmpeg
<Button
className="rounded-full"
type="text"
color="gray"
aria-label={cpu_usages[cameras[camera]['ffmpeg_pid']]?.['cmdline']}
onClick={() => copy(cpu_usages[cameras[camera]['ffmpeg_pid']]?.['cmdline'])}
>
<About className="w-3" />
</Button>
</Td>
<Td>{cameras[camera]['ffmpeg_pid'] || '- '}</Td>
<Td>{cameras[camera]['camera_fps'] || '- '}</Td>
<Td>{cpu_usages[cameras[camera]['ffmpeg_pid']]?.['cpu'] || '- '}%</Td>
<Td>{cpu_usages[cameras[camera]['ffmpeg_pid']]?.['mem'] || '- '}%</Td>
{config.telemetry.network_bandwidth && (
<Td>{bandwidth_usages[cameras[camera]['ffmpeg_pid']]?.['bandwidth'] || '- '}KB/s</Td>
)}
</Tr>
<Tr key="capture" index="1">
<Td>Capture</Td>
<Td>{cameras[camera]['capture_pid'] || '- '}</Td>
<Td>{cameras[camera]['process_fps'] || '- '}</Td>
<Td>{cpu_usages[cameras[camera]['capture_pid']]?.['cpu'] || '- '}%</Td>
<Td>{cpu_usages[cameras[camera]['capture_pid']]?.['mem'] || '- '}%</Td>
{config.telemetry.network_bandwidth && <Td>-</Td>}
</Tr>
<Tr key="detect" index="2">
<Td>Detect</Td>
<Td>{cameras[camera]['pid'] || '- '}</Td>
{(() => {
if (cameras[camera]['pid'] && cameras[camera]['detection_enabled'] == 1)
return (
<Td>
{cameras[camera]['detection_fps']} ({cameras[camera]['skipped_fps']} skipped)
</Td>
);
else if (cameras[camera]['pid'] && cameras[camera]['detection_enabled'] == 0)
return <Td>disabled</Td>;
return <Td>- </Td>;
})()}
<Td>{cpu_usages[cameras[camera]['pid']]?.['cpu'] || '- '}%</Td>
<Td>{cpu_usages[cameras[camera]['pid']]?.['mem'] || '- '}%</Td>
{config.telemetry.network_bandwidth && <Td>-</Td>}
</Tr>
</Tbody>
</Table>
</div>
</div>
)
)}
</div>
)}
<div className="flex justify-start">
<Heading className="self-center" size="lg">
Other Processes
</Heading>
<Button
className="rounded-full"
type="text"
color="gray"
aria-label="Momentary resource usage for other important processes. CPU % is for a single core."
>
<About className="w-5" />
</Button>
</div>
<div data-testid="cameras" className="grid grid-cols-1 3xl:grid-cols-3 md:grid-cols-2 gap-4">
{processesNames.map((process) => (
<div key={process} className="dark:bg-gray-800 shadow-md hover:shadow-lg rounded-lg transition-shadow">
<div className="capitalize text-lg flex justify-between p-4">
<div className="text-lg flex justify-between">{process}</div>
</div>
<div className="p-2">
<Table className="w-full">
<Thead>
<Tr>
<Th>P-ID</Th>
<Th>CPU %</Th>
<Th>Avg CPU %</Th>
<Th>Memory %</Th>
{config.telemetry.network_bandwidth && <Th>Network Bandwidth</Th>}
</Tr>
</Thead>
<Tbody>
<Tr key="other" index="0">
<Td>{processes[process]['pid'] || '- '}</Td>
<Td>{cpu_usages[processes[process]['pid']]?.['cpu'] || '- '}%</Td>
<Td>{cpu_usages[processes[process]['pid']]?.['cpu_average'] || '- '}%</Td>
<Td>{cpu_usages[processes[process]['pid']]?.['mem'] || '- '}%</Td>
{config.telemetry.network_bandwidth && (
<Td>{bandwidth_usages[processes[process]['pid']]?.['bandwidth'] || '- '}KB/s</Td>
)}
</Tr>
</Tbody>
</Table>
</div>
</div>
))}
</div>
<p>System stats update automatically every {config.mqtt.stats_interval} seconds.</p>
</Fragment>
)}
</div>
);
}

View File

@@ -0,0 +1,59 @@
import { h } from 'preact';
import * as AutoUpdatingCameraImage from '../../components/AutoUpdatingCameraImage';
import * as WS from '../../api/ws';
import Camera from '../Camera';
import { set as setData } from 'idb-keyval';
import * as JSMpegPlayer from '../../components/JSMpegPlayer';
import { fireEvent, render, screen, waitForElementToBeRemoved } from 'testing-library';
describe('Camera Route', () => {
beforeEach(() => {
vi.spyOn(AutoUpdatingCameraImage, 'default').mockImplementation(({ searchParams }) => {
return <div data-testid="mock-image">{searchParams.toString()}</div>;
});
vi.spyOn(JSMpegPlayer, 'default').mockImplementation(() => {
return <div data-testid="mock-jsmpeg" />;
});
vi.spyOn(WS, 'WsProvider').mockImplementation(({ children }) => children);
});
// eslint-disable-next-line jest/no-disabled-tests
test.skip('reads camera feed options from persistence', async () => {
setData('front-source', 'mse')
setData('front-feed', {
bbox: true,
timestamp: false,
zones: true,
mask: false,
motion: true,
regions: false,
});
render(<Camera camera="front" />);
await waitForElementToBeRemoved(() => screen.queryByLabelText('Loading…'), { timeout: 100 });
fireEvent.click(screen.queryByText('Debug'));
fireEvent.click(screen.queryByText('Show Options'));
expect(screen.queryByTestId('mock-image')).toHaveTextContent(
'bbox=1&timestamp=0&zones=1&mask=0&motion=1&regions=0'
);
});
// eslint-disable-next-line jest/no-disabled-tests
test.skip('updates camera feed options to persistence', async () => {
setData('front-feed', {});
render(<Camera camera="front" />);
await waitForElementToBeRemoved(() => screen.queryByLabelText('Loading…'), { timeout: 100 });
fireEvent.click(screen.queryByText('Debug'));
fireEvent.click(screen.queryByText('Show Options'));
fireEvent.change(screen.queryByTestId('bbox-input'), { target: { checked: true } });
fireEvent.change(screen.queryByTestId('timestamp-input'), { target: { checked: true } });
fireEvent.click(screen.queryByText('Hide Options'));
expect(screen.queryByTestId('mock-image')).toHaveTextContent('bbox=1&timestamp=1');
});
});

View File

@@ -0,0 +1,73 @@
/* eslint-disable jest/no-disabled-tests */
import { h } from 'preact';
import * as CameraImage from '../../components/CameraImage';
import * as Hooks from '../../hooks';
import * as WS from '../../api/ws';
import Cameras from '../Cameras';
import { fireEvent, render, screen, waitForElementToBeRemoved } from 'testing-library';
describe('Cameras Route', () => {
beforeEach(() => {
vi.spyOn(CameraImage, 'default').mockImplementation(() => <div data-testid="camera-image" />);
vi.spyOn(WS, 'useWs').mockImplementation(() => ({ value: { payload: 'OFF' }, send: vi.fn() }));
vi.spyOn(Hooks, 'useResizeObserver').mockImplementation(() => [{ width: 1000 }]);
});
test('shows an ActivityIndicator if not yet loaded', async () => {
render(<Cameras />);
expect(screen.queryByLabelText('Loading…')).toBeInTheDocument();
});
test.skip('shows cameras', async () => {
render(<Cameras />);
await waitForElementToBeRemoved(() => screen.queryByLabelText('Loading…'));
expect(screen.queryByText('front')).toBeInTheDocument();
expect(screen.queryByText('front').closest('a')).toHaveAttribute('href', '/cameras/front');
expect(screen.queryByText('side')).toBeInTheDocument();
expect(screen.queryByText('side').closest('a')).toHaveAttribute('href', '/cameras/side');
});
test.skip('shows recordings link', async () => {
render(<Cameras />);
await waitForElementToBeRemoved(() => screen.queryByLabelText('Loading…'));
expect(screen.queryAllByText('Recordings')).toHaveLength(2);
});
test.skip('buttons toggle detect, clips, and snapshots', async () => {
const sendDetect = vi.fn();
const sendRecordings = vi.fn();
const sendSnapshots = vi.fn();
vi.spyOn(WS, 'useDetectState').mockImplementation(() => {
return { payload: 'ON', send: sendDetect };
});
vi.spyOn(WS, 'useRecordingsState').mockImplementation(() => {
return { payload: 'OFF', send: sendRecordings };
});
vi.spyOn(WS, 'useSnapshotsState').mockImplementation(() => {
return { payload: 'ON', send: sendSnapshots };
});
render(<Cameras />);
await waitForElementToBeRemoved(() => screen.queryByLabelText('Loading…'));
fireEvent.click(screen.getAllByLabelText('Toggle detect off')[0]);
expect(sendDetect).toHaveBeenCalledWith('OFF', true);
expect(sendDetect).toHaveBeenCalledTimes(1);
fireEvent.click(screen.getAllByLabelText('Toggle snapshots off')[0]);
expect(sendSnapshots).toHaveBeenCalledWith('OFF', true);
fireEvent.click(screen.getAllByLabelText('Toggle recordings on')[0]);
expect(sendRecordings).toHaveBeenCalledWith('ON', true);
expect(sendDetect).toHaveBeenCalledTimes(1);
expect(sendSnapshots).toHaveBeenCalledTimes(1);
expect(sendRecordings).toHaveBeenCalledTimes(1);
});
});

View File

@@ -0,0 +1,21 @@
import { h } from 'preact';
import Events from '../Events';
import { render, screen, waitForElementToBeRemoved } from 'testing-library';
describe('Events Route', () => {
beforeEach(() => {});
test('shows an ActivityIndicator if not yet loaded', async () => {
render(<Events limit={5} path="/events" />);
expect(screen.queryByLabelText('Loading…')).toBeInTheDocument();
});
// eslint-disable-next-line jest/no-disabled-tests
test.skip('does not show ActivityIndicator after loaded', async () => {
render(<Events limit={5} path="/events" />);
await waitForElementToBeRemoved(() => screen.queryByLabelText('Loading…'));
expect(screen.queryByLabelText('Loading…')).not.toBeInTheDocument();
});
});

View File

@@ -0,0 +1,28 @@
import { h } from 'preact';
import * as CameraImage from '../../components/CameraImage';
import * as WS from '../../api/ws';
import * as Hooks from '../../hooks';
import Cameras from '../Cameras';
import { render, screen, waitForElementToBeRemoved } from 'testing-library';
describe('Recording Route', () => {
beforeEach(() => {
vi.spyOn(CameraImage, 'default').mockImplementation(() => <div data-testid="camera-image" />);
vi.spyOn(WS, 'useWs').mockImplementation(() => ({ value: { payload: 'OFF' }, send: jest.fn() }));
vi.spyOn(Hooks, 'useResizeObserver').mockImplementation(() => [{ width: 1000 }]);
});
test('shows an ActivityIndicator if not yet loaded', async () => {
render(<Cameras />);
expect(screen.queryByLabelText('Loading…')).toBeInTheDocument();
});
// eslint-disable-next-line jest/no-disabled-tests
test.skip('shows no recordings warning', async () => {
render(<Cameras />);
await waitForElementToBeRemoved(() => screen.queryByLabelText('Loading…'));
expect(screen.queryAllByText('No Recordings Found')).toHaveLength(0);
});
});

View File

@@ -0,0 +1,29 @@
import { h } from 'preact';
import System from '../System';
import { render, screen, waitForElementToBeRemoved } from 'testing-library';
describe('System Route', () => {
beforeEach(() => {});
test('shows an ActivityIndicator if stats are null', async () => {
render(<System />);
expect(screen.queryByLabelText('Loading…')).toBeInTheDocument();
});
// eslint-disable-next-line jest/no-disabled-tests
test.skip('shows stats and config', async () => {
render(<System />);
await waitForElementToBeRemoved(() => screen.queryByLabelText('Loading…'));
expect(screen.queryByTestId('detectors')).toBeInTheDocument();
expect(screen.queryByText('coral')).toBeInTheDocument();
expect(screen.queryByTestId('cameras')).toBeInTheDocument();
expect(screen.queryByText('front')).toBeInTheDocument();
expect(screen.queryByText('side')).toBeInTheDocument();
expect(screen.queryByText('Config')).toBeInTheDocument();
expect(screen.queryByRole('button', { name: 'Copy to Clipboard' })).toBeInTheDocument();
});
});

View File

@@ -0,0 +1,59 @@
export async function getCameraMap(_url, _cb, _props) {
const module = await import('./CameraMap.jsx');
return module.default;
}
export async function getCamera(_url, _cb, _props) {
const module = await import('./Camera.jsx');
return module.default;
}
export async function getCameraV2(_url, _cb, _props) {
const module = await import('./Camera_V2.jsx');
return module.default;
}
export async function getBirdseye(_url, _cb, _props) {
const module = await import('./Birdseye.jsx');
return module.default;
}
export async function getEvents(_url, _cb, _props) {
const module = await import('./Events.jsx');
return module.default;
}
export async function getExports(_url, _cb, _props) {
const module = await import('./Export.jsx');
return module.default;
}
export async function getRecording(_url, _cb, _props) {
const module = await import('./Recording.jsx');
return module.default;
}
export async function getSystem(_url, _cb, _props) {
const module = await import('./System.jsx');
return module.default;
}
export async function getStorage(_url, _cb, _props) {
const module = await import('./Storage.jsx');
return module.default;
}
export async function getConfig(_url, _cb, _props) {
const module = await import('./Config.jsx');
return module.default;
}
export async function getLogs(_url, _cb, _props) {
const module = await import('./Logs.jsx');
return module.default;
}
export async function getStyleGuide(_url, _cb, _props) {
const module = await import('./StyleGuide.jsx');
return module.default;
}