forked from Github/frigate
141
web-old/src/routes/Birdseye.jsx
Normal file
141
web-old/src/routes/Birdseye.jsx
Normal 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;
|
||||
}
|
||||
234
web-old/src/routes/Camera.jsx
Normal file
234
web-old/src/routes/Camera.jsx
Normal 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;
|
||||
}
|
||||
752
web-old/src/routes/CameraMap.jsx
Normal file
752
web-old/src/routes/CameraMap.jsx
Normal 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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
76
web-old/src/routes/Camera_V2.jsx
Normal file
76
web-old/src/routes/Camera_V2.jsx
Normal 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 />;
|
||||
};
|
||||
186
web-old/src/routes/Cameras.jsx
Normal file
186
web-old/src/routes/Cameras.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
118
web-old/src/routes/Config.jsx
Normal file
118
web-old/src/routes/Config.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
942
web-old/src/routes/Events.jsx
Normal file
942
web-old/src/routes/Events.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
263
web-old/src/routes/Export.jsx
Normal file
263
web-old/src/routes/Export.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
50
web-old/src/routes/Logs.jsx
Normal file
50
web-old/src/routes/Logs.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
165
web-old/src/routes/Recording.jsx
Normal file
165
web-old/src/routes/Recording.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
175
web-old/src/routes/Storage.jsx
Normal file
175
web-old/src/routes/Storage.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
126
web-old/src/routes/StyleGuide.jsx
Normal file
126
web-old/src/routes/StyleGuide.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
487
web-old/src/routes/System.jsx
Normal file
487
web-old/src/routes/System.jsx
Normal 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>
|
||||
);
|
||||
}
|
||||
59
web-old/src/routes/__tests__/Camera.test.jsx
Normal file
59
web-old/src/routes/__tests__/Camera.test.jsx
Normal 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×tamp=0&zones=1&mask=0&motion=1®ions=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×tamp=1');
|
||||
});
|
||||
});
|
||||
73
web-old/src/routes/__tests__/Cameras.test.jsx
Normal file
73
web-old/src/routes/__tests__/Cameras.test.jsx
Normal 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);
|
||||
});
|
||||
});
|
||||
21
web-old/src/routes/__tests__/Events.test.jsx
Normal file
21
web-old/src/routes/__tests__/Events.test.jsx
Normal 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();
|
||||
});
|
||||
});
|
||||
28
web-old/src/routes/__tests__/Recording.test.jsx
Normal file
28
web-old/src/routes/__tests__/Recording.test.jsx
Normal 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);
|
||||
});
|
||||
});
|
||||
29
web-old/src/routes/__tests__/System.test.jsx
Normal file
29
web-old/src/routes/__tests__/System.test.jsx
Normal 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();
|
||||
});
|
||||
});
|
||||
59
web-old/src/routes/index.js
Normal file
59
web-old/src/routes/index.js
Normal 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;
|
||||
}
|
||||
Reference in New Issue
Block a user