Merge branch 'release-0.9.0' into datepicker

This commit is contained in:
Bernt Christian Egeland
2021-08-24 19:39:47 +02:00
50 changed files with 1436 additions and 736 deletions
+1 -1
View File
@@ -23,7 +23,7 @@ export default function App() {
) : (
<div className="flex flex-row min-h-screen w-full bg-white dark:bg-gray-900 text-gray-900 dark:text-white">
<Sidebar />
<div className="w-full flex-auto p-2 mt-24 px-4 min-w-0">
<div className="w-full flex-auto p-2 mt-16 px-4 min-w-0">
<Router>
<AsyncRoute path="/cameras/:camera/editor" getComponent={Routes.getCameraMap} />
<AsyncRoute path="/cameras/:camera" getComponent={Routes.getCamera} />
+40
View File
@@ -5,12 +5,18 @@ import Menu, { MenuItem, MenuSeparator } from './components/Menu';
import AutoAwesomeIcon from './icons/AutoAwesome';
import LightModeIcon from './icons/LightMode';
import DarkModeIcon from './icons/DarkMode';
import FrigateRestartIcon from './icons/FrigateRestart';
import Dialog from './components/Dialog';
import { useDarkMode } from './context';
import { useCallback, useRef, useState } from 'preact/hooks';
import { useRestart } from './api/mqtt';
export default function AppBar() {
const [showMoreMenu, setShowMoreMenu] = useState(false);
const [showDialog, setShowDialog] = useState(false);
const [showDialogWait, setShowDialogWait] = useState(false);
const { setDarkMode } = useDarkMode();
const { send: sendRestart } = useRestart();
const handleSelectDarkMode = useCallback(
(value, label) => {
@@ -30,6 +36,21 @@ export default function AppBar() {
setShowMoreMenu(false);
}, [setShowMoreMenu]);
const handleClickRestartDialog = useCallback(() => {
setShowDialog(false);
setShowDialogWait(true);
sendRestart();
}, [setShowDialog]); // eslint-disable-line react-hooks/exhaustive-deps
const handleDismissRestartDialog = useCallback(() => {
setShowDialog(false);
}, [setShowDialog]);
const handleRestart = useCallback(() => {
setShowMoreMenu(false);
setShowDialog(true);
}, [setShowDialog]);
return (
<Fragment>
<BaseAppBar title={LinkedLogo} overflowRef={moreRef} onOverflowClick={handleShowMenu} />
@@ -39,8 +60,27 @@ export default function AppBar() {
<MenuSeparator />
<MenuItem icon={LightModeIcon} label="Light" value="light" onSelect={handleSelectDarkMode} />
<MenuItem icon={DarkModeIcon} label="Dark" value="dark" onSelect={handleSelectDarkMode} />
<MenuSeparator />
<MenuItem icon={FrigateRestartIcon} label="Restart Frigate" onSelect={handleRestart} />
</Menu>
) : null}
{showDialog ? (
<Dialog
onDismiss={handleDismissRestartDialog}
title="Restart Frigate"
text="Are you sure?"
actions={[
{ text: 'Yes', color: 'red', onClick: handleClickRestartDialog },
{ text: 'Cancel', onClick: handleDismissRestartDialog },
]}
/>
) : null}
{showDialogWait ? (
<Dialog
title="Restart in progress"
text="Please wait a few seconds for the restart to complete before reloading the page."
/>
) : null}
</Fragment>
);
}
+5 -5
View File
@@ -107,12 +107,12 @@ describe('MqttProvider', () => {
);
});
test('prefills the clips/detect/snapshots state from config', async () => {
test('prefills the recordings/detect/snapshots state from config', async () => {
jest.spyOn(Date, 'now').mockReturnValue(123456);
const config = {
cameras: {
front: { name: 'front', detect: { enabled: true }, clips: { enabled: false }, snapshots: { enabled: true } },
side: { name: 'side', detect: { enabled: false }, clips: { enabled: false }, snapshots: { enabled: false } },
front: { name: 'front', detect: { enabled: true }, record: { enabled: false }, snapshots: { enabled: true } },
side: { name: 'side', detect: { enabled: false }, record: { enabled: false }, snapshots: { enabled: false } },
},
};
render(
@@ -122,10 +122,10 @@ describe('MqttProvider', () => {
);
await screen.findByTestId('data');
expect(screen.getByTestId('front/detect/state')).toHaveTextContent('{"lastUpdate":123456,"payload":"ON"}');
expect(screen.getByTestId('front/clips/state')).toHaveTextContent('{"lastUpdate":123456,"payload":"OFF"}');
expect(screen.getByTestId('front/recordings/state')).toHaveTextContent('{"lastUpdate":123456,"payload":"OFF"}');
expect(screen.getByTestId('front/snapshots/state')).toHaveTextContent('{"lastUpdate":123456,"payload":"ON"}');
expect(screen.getByTestId('side/detect/state')).toHaveTextContent('{"lastUpdate":123456,"payload":"OFF"}');
expect(screen.getByTestId('side/clips/state')).toHaveTextContent('{"lastUpdate":123456,"payload":"OFF"}');
expect(screen.getByTestId('side/recordings/state')).toHaveTextContent('{"lastUpdate":123456,"payload":"OFF"}');
expect(screen.getByTestId('side/snapshots/state')).toHaveTextContent('{"lastUpdate":123456,"payload":"OFF"}');
});
});
+13 -4
View File
@@ -41,8 +41,8 @@ export function MqttProvider({
useEffect(() => {
Object.keys(config.cameras).forEach((camera) => {
const { name, clips, detect, snapshots } = config.cameras[camera];
dispatch({ topic: `${name}/clips/state`, payload: clips.enabled ? 'ON' : 'OFF' });
const { name, record, detect, snapshots } = config.cameras[camera];
dispatch({ topic: `${name}/recordings/state`, payload: record.enabled ? 'ON' : 'OFF' });
dispatch({ topic: `${name}/detect/state`, payload: detect.enabled ? 'ON' : 'OFF' });
dispatch({ topic: `${name}/snapshots/state`, payload: snapshots.enabled ? 'ON' : 'OFF' });
});
@@ -101,12 +101,12 @@ export function useDetectState(camera) {
return { payload, send, connected };
}
export function useClipsState(camera) {
export function useRecordingsState(camera) {
const {
value: { payload },
send,
connected,
} = useMqtt(`${camera}/clips/state`, `${camera}/clips/set`);
} = useMqtt(`${camera}/recordings/state`, `${camera}/recordings/set`);
return { payload, send, connected };
}
@@ -118,3 +118,12 @@ export function useSnapshotsState(camera) {
} = useMqtt(`${camera}/snapshots/state`, `${camera}/snapshots/set`);
return { payload, send, connected };
}
export function useRestart() {
const {
value: { payload },
send,
connected,
} = useMqtt('restart', 'restart');
return { payload, send, connected };
}
+3 -3
View File
@@ -37,13 +37,13 @@ export default function AppBar({ title: Title, overflowRef, onOverflowClick }) {
return (
<div
className={`w-full border-b border-gray-200 dark:border-gray-700 flex items-center align-middle p-4 space-x-2 fixed left-0 right-0 z-10 bg-white dark:bg-gray-900 transform transition-all duration-200 ${
className={`w-full border-b border-gray-200 dark:border-gray-700 flex items-center align-middle p-2 fixed left-0 right-0 z-10 bg-white dark:bg-gray-900 transform transition-all duration-200 ${
!show ? '-translate-y-full' : 'translate-y-0'
} ${!atZero ? 'shadow-sm' : ''}`}
data-testid="appbar"
>
<div className="lg:hidden">
<Button color="black" className="rounded-full w-12 h-12" onClick={handleShowDrawer} type="text">
<Button color="black" className="rounded-full w-10 h-10" onClick={handleShowDrawer} type="text">
<MenuIcon className="w-10 h-10" />
</Button>
</div>
@@ -54,7 +54,7 @@ export default function AppBar({ title: Title, overflowRef, onOverflowClick }) {
<Button
aria-label="More options"
color="black"
className="rounded-full w-12 h-12"
className="rounded-full w-9 h-9"
onClick={onOverflowClick}
type="text"
>
+37
View File
@@ -0,0 +1,37 @@
import { h } from 'preact';
import { useCallback, useState } from 'preact/hooks';
export default function ButtonsTabbed({
viewModes = [''],
setViewMode = null,
setHeader = null,
headers = [''],
className = 'text-gray-600 py-0 px-4 block hover:text-gray-500',
selectedClassName = `${className} focus:outline-none border-b-2 font-medium border-gray-500`
}) {
const [selected, setSelected] = useState(0);
const captitalize = (str) => { return (`${str.charAt(0).toUpperCase()}${str.slice(1)}`); };
const getHeader = useCallback((i) => {
return (headers.length === viewModes.length ? headers[i] : captitalize(viewModes[i]));
}, [headers, viewModes]);
const handleClick = useCallback((i) => {
setSelected(i);
setViewMode && setViewMode(viewModes[i]);
setHeader && setHeader(getHeader(i));
}, [setViewMode, setHeader, setSelected, viewModes, getHeader]);
setHeader && setHeader(getHeader(selected));
return (
<nav className="flex justify-end">
{viewModes.map((item, i) => {
return (
<button onClick={() => handleClick(i)} className={i === selected ? selectedClassName : className}>
{captitalize(item)}
</button>
);
})}
</nav>
);
}
+2 -1
View File
@@ -12,7 +12,8 @@ export default function CameraImage({ camera, onload, searchParams = '', stretch
const canvasRef = useRef(null);
const [{ width: availableWidth }] = useResizeObserver(containerRef);
const { name, width, height } = config.cameras[camera];
const { name } = config.cameras[camera];
const { width, height } = config.cameras[camera].detect;
const aspectRatio = width / height;
const scaledHeight = useMemo(() => {
+1 -1
View File
@@ -12,7 +12,7 @@ export default function JSMpegPlayer({ camera }) {
playerRef.current,
url,
{},
{protocols: [], audio: false}
{protocols: [], audio: false, videoBufferSize: 1024*1024*4}
);
const fullscreen = () => {
+1 -1
View File
@@ -22,7 +22,7 @@ export default function NavigationDrawer({ children, header }) {
onClick={handleDismiss}
>
{header ? (
<div className="flex-shrink-0 p-5 flex flex-row items-center justify-between border-b border-gray-200 dark:border-gray-700">
<div className="flex-shrink-0 p-2 flex flex-row items-center justify-between border-b border-gray-200 dark:border-gray-700">
{header}
</div>
) : null}
@@ -7,7 +7,7 @@ import { render, screen } from '@testing-library/preact';
describe('CameraImage', () => {
beforeEach(() => {
jest.spyOn(Api, 'useConfig').mockImplementation(() => {
return { data: { cameras: { front: { name: 'front', width: 1280, height: 720 } } } };
return { data: { cameras: { front: { name: 'front', detect: { width: 1280, height: 720 } } } } };
});
jest.spyOn(Api, 'useApiHost').mockReturnValue('http://base-url.local:5000');
jest.spyOn(Hooks, 'useResizeObserver').mockImplementation(() => [{ width: 0 }]);
+13
View File
@@ -0,0 +1,13 @@
import { h } from 'preact';
import { memo } from 'preact/compat';
export function FrigateRestart({ className = '' }) {
return (
<svg className={`fill-current ${className}`} viewBox="0 0 24 24">
<rect fill="none" height="24" width="24" />
<path d="M12 6v3l4-4-4-4v3c-4.42 0-8 3.58-8 8 0 1.57.46 3.03 1.24 4.26L6.7 14.8c-.45-.83-.7-1.79-.7-2.8 0-3.31 2.69-6 6-6zm6.76 1.74L17.3 9.2c.44.84.7 1.79.7 2.8 0 3.31-2.69 6-6 6v-3l-4 4 4 4v-3c4.42 0 8-3.58 8-8 0-1.57-.46-3.03-1.24-4.26z" />
</svg>
);
}
export default memo(FrigateRestart);
+2 -10
View File
@@ -7,6 +7,7 @@ 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, useConfig } from '../api';
@@ -112,16 +113,7 @@ export default function Camera({ camera }) {
return (
<div className="space-y-4">
<Heading size="2xl">{camera}</Heading>
<div>
<nav className="flex justify-end">
<button onClick={() => setViewMode('live')} className={viewMode === 'live' ? 'text-gray-600 py-0 px-4 block hover:text-gray-500 focus:outline-none border-b-2 font-medium border-gray-500' : 'text-gray-600 py-0 px-4 block hover:text-gray-500'}>
Live
</button>
<button onClick={() => setViewMode('debug')} className={viewMode === 'debug' ? 'text-gray-600 py-0 px-4 block hover:text-gray-500 focus:outline-none border-b-2 font-medium border-gray-500' : 'text-gray-600 py-0 px-4 block hover:text-gray-500'}>
Debug
</button>
</nav>
</div>
<ButtonsTabbed viewModes={['live', 'debug']} setViewMode={setViewMode} />
{player}
+5 -2
View File
@@ -15,13 +15,16 @@ export default function CameraMasks({ camera, url }) {
const cameraConfig = config.cameras[camera];
const {
width,
height,
motion: { mask: motionMask },
objects: { filters: objectFilters },
zones,
} = cameraConfig;
const {
width,
height,
} = cameraConfig.detect;
const [{ width: scaledWidth }] = useResizeObserver(imageRef);
const imageScale = scaledWidth / width;
+6 -6
View File
@@ -5,7 +5,7 @@ import CameraImage from '../components/CameraImage';
import ClipIcon from '../icons/Clip';
import MotionIcon from '../icons/Motion';
import SnapshotIcon from '../icons/Snapshot';
import { useDetectState, useClipsState, useSnapshotsState } from '../api/mqtt';
import { useDetectState, useRecordingsState, useSnapshotsState } from '../api/mqtt';
import { useConfig, FetchStatus } from '../api';
import { useMemo } from 'preact/hooks';
@@ -25,7 +25,7 @@ export default function Cameras() {
function Camera({ name, conf }) {
const { payload: detectValue, send: sendDetect } = useDetectState(name);
const { payload: clipValue, send: sendClips } = useClipsState(name);
const { payload: recordValue, send: sendRecordings } = useRecordingsState(name);
const { payload: snapshotValue, send: sendSnapshots } = useSnapshotsState(name);
const href = `/cameras/${name}`;
const buttons = useMemo(() => {
@@ -46,11 +46,11 @@ function Camera({ name, conf }) {
},
},
{
name: `Toggle clips ${clipValue === 'ON' ? 'off' : 'on'}`,
name: `Toggle recordings ${recordValue === 'ON' ? 'off' : 'on'}`,
icon: ClipIcon,
color: clipValue === 'ON' ? 'blue' : 'gray',
color: recordValue === 'ON' ? 'blue' : 'gray',
onClick: () => {
sendClips(clipValue === 'ON' ? 'OFF' : 'ON');
sendRecordings(recordValue === 'ON' ? 'OFF' : 'ON');
},
},
{
@@ -62,7 +62,7 @@ function Camera({ name, conf }) {
},
},
],
[detectValue, sendDetect, clipValue, sendClips, snapshotValue, sendSnapshots]
[detectValue, sendDetect, recordValue, sendRecordings, snapshotValue, sendSnapshots]
);
return (
+14 -4
View File
@@ -115,8 +115,8 @@ export default function Event({ eventId }) {
options={{
sources: [
{
src: `${apiHost}/clips/${data.camera}-${eventId}.mp4`,
type: 'video/mp4',
src: `${apiHost}/vod/event/${eventId}/index.m3u8`,
type: 'application/vnd.apple.mpegurl',
},
],
poster: data.has_snapshot
@@ -127,10 +127,20 @@ export default function Event({ eventId }) {
onReady={(player) => {}}
/>
<div className="text-center">
<Button className="mx-2" color="blue" href={`${apiHost}/clips/${data.camera}-${eventId}.mp4`} download>
<Button
className="mx-2"
color="blue"
href={`${apiHost}/api/events/${eventId}/clip.mp4?download=true`}
download
>
<Clip className="w-6" /> Download Clip
</Button>
<Button className="mx-2" color="blue" href={`${apiHost}/clips/${data.camera}-${eventId}.jpg`} download>
<Button
className="mx-2"
color="blue"
href={`${apiHost}/api/events/${eventId}/snapshot.jpg?download=true`}
download
>
<Snapshot className="w-6" /> Download Snapshot
</Button>
</div>
+6 -6
View File
@@ -51,13 +51,13 @@ describe('Cameras Route', () => {
test('buttons toggle detect, clips, and snapshots', async () => {
const sendDetect = jest.fn();
const sendClips = jest.fn();
const sendRecordings = jest.fn();
const sendSnapshots = jest.fn();
jest.spyOn(Mqtt, 'useDetectState').mockImplementation(() => {
return { payload: 'ON', send: sendDetect };
});
jest.spyOn(Mqtt, 'useClipsState').mockImplementation(() => {
return { payload: 'OFF', send: sendClips };
jest.spyOn(Mqtt, 'useRecordingsState').mockImplementation(() => {
return { payload: 'OFF', send: sendRecordings };
});
jest.spyOn(Mqtt, 'useSnapshotsState').mockImplementation(() => {
return { payload: 'ON', send: sendSnapshots };
@@ -72,11 +72,11 @@ describe('Cameras Route', () => {
fireEvent.click(screen.getAllByLabelText('Toggle snapshots off')[0]);
expect(sendSnapshots).toHaveBeenCalledWith('OFF');
fireEvent.click(screen.getAllByLabelText('Toggle clips on')[0]);
expect(sendClips).toHaveBeenCalledWith('ON');
fireEvent.click(screen.getAllByLabelText('Toggle recordings on')[0]);
expect(sendRecordings).toHaveBeenCalledWith('ON');
expect(sendDetect).toHaveBeenCalledTimes(1);
expect(sendSnapshots).toHaveBeenCalledTimes(1);
expect(sendClips).toHaveBeenCalledTimes(1);
expect(sendRecordings).toHaveBeenCalledTimes(1);
});
});