+ {buttons.length || content || icons.length ? (
+
{content || null}
{buttons.length ? (
@@ -36,6 +37,12 @@ export default function Box({
{name}
))}
+
+ {icons.map(({ name, icon: Icon, ...props }) => (
+
+ ))}
) : null}
diff --git a/web/src/components/Tooltip.jsx b/web/src/components/Tooltip.jsx
new file mode 100644
index 000000000..b0faadaef
--- /dev/null
+++ b/web/src/components/Tooltip.jsx
@@ -0,0 +1,61 @@
+import { h } from 'preact';
+import { createPortal } from 'preact/compat';
+import { useEffect, useRef, useState } from 'preact/hooks';
+
+const TIP_SPACE = 20;
+
+export default function Tooltip({ relativeTo, text }) {
+ const [position, setPosition] = useState({ top: -Infinity, left: -Infinity });
+ const portalRoot = document.getElementById('tooltips');
+ const ref = useRef();
+
+ useEffect(() => {
+ if (ref && ref.current && relativeTo && relativeTo.current) {
+ const windowWidth = window.innerWidth;
+ const {
+ x: relativeToX,
+ y: relativeToY,
+ width: relativeToWidth,
+ height: relativeToHeight,
+ } = relativeTo.current.getBoundingClientRect();
+ const { width: tipWidth, height: tipHeight } = ref.current.getBoundingClientRect();
+
+ const left = relativeToX + Math.round(relativeToWidth / 2) + window.scrollX;
+ const top = relativeToY + Math.round(relativeToHeight / 2) + window.scrollY;
+
+ let newTop = top - TIP_SPACE - tipHeight;
+ let newLeft = left - Math.round(tipWidth / 2);
+ // too far right
+ if (newLeft + tipWidth + TIP_SPACE > windowWidth - window.scrollX) {
+ newLeft = left - tipWidth - TIP_SPACE;
+ newTop = top - Math.round(tipHeight / 2);
+ }
+ // too far left
+ else if (newLeft < TIP_SPACE + window.scrollX) {
+ newLeft = left + TIP_SPACE;
+ newTop = top - Math.round(tipHeight / 2);
+ }
+ // too close to top
+ else if (newTop <= TIP_SPACE + window.scrollY) {
+ newTop = top + tipHeight + TIP_SPACE;
+ }
+
+ setPosition({ left: newLeft, top: newTop });
+ }
+ }, [relativeTo, ref]);
+
+ const tooltip = (
+
= 0 ? 'opacity-100' : ''
+ }`}
+ ref={ref}
+ style={position.top >= 0 ? position : null}
+ >
+ {text}
+
+ );
+
+ return portalRoot ? createPortal(tooltip, portalRoot) : tooltip;
+}
diff --git a/web/src/components/__tests__/Toolltip.test.jsx b/web/src/components/__tests__/Toolltip.test.jsx
new file mode 100644
index 000000000..d2d8e0c63
--- /dev/null
+++ b/web/src/components/__tests__/Toolltip.test.jsx
@@ -0,0 +1,115 @@
+import { h, createRef } from 'preact';
+import Tooltip from '../Tooltip';
+import { render, screen } from '@testing-library/preact';
+
+describe('Tooltip', () => {
+ test('renders in a relative position', async () => {
+ jest
+ .spyOn(window.HTMLElement.prototype, 'getBoundingClientRect')
+ // relativeTo
+ .mockReturnValueOnce({
+ x: 100,
+ y: 100,
+ width: 50,
+ height: 10,
+ })
+ // tooltip
+ .mockReturnValueOnce({ width: 40, height: 15 });
+
+ const ref = createRef();
+ render(
+
+ );
+
+ const tooltip = await screen.findByRole('tooltip');
+ const style = window.getComputedStyle(tooltip);
+ expect(style.left).toEqual('105px');
+ expect(style.top).toEqual('70px');
+ });
+
+ test('if too far right, renders to the left', async () => {
+ window.innerWidth = 1024;
+ jest
+ .spyOn(window.HTMLElement.prototype, 'getBoundingClientRect')
+ // relativeTo
+ .mockReturnValueOnce({
+ x: 1000,
+ y: 100,
+ width: 24,
+ height: 10,
+ })
+ // tooltip
+ .mockReturnValueOnce({ width: 50, height: 15 });
+
+ const ref = createRef();
+ render(
+
+ );
+
+ const tooltip = await screen.findByRole('tooltip');
+ const style = window.getComputedStyle(tooltip);
+ expect(style.left).toEqual('942px');
+ expect(style.top).toEqual('97px');
+ });
+
+ test('if too far left, renders to the right', async () => {
+ jest
+ .spyOn(window.HTMLElement.prototype, 'getBoundingClientRect')
+ // relativeTo
+ .mockReturnValueOnce({
+ x: 0,
+ y: 100,
+ width: 24,
+ height: 10,
+ })
+ // tooltip
+ .mockReturnValueOnce({ width: 50, height: 15 });
+
+ const ref = createRef();
+ render(
+
+ );
+
+ const tooltip = await screen.findByRole('tooltip');
+ const style = window.getComputedStyle(tooltip);
+ expect(style.left).toEqual('32px');
+ expect(style.top).toEqual('97px');
+ });
+
+ test('if too close to top, renders to the bottom', async () => {
+ window.scrollY = 90;
+ jest
+ .spyOn(window.HTMLElement.prototype, 'getBoundingClientRect')
+ // relativeTo
+ .mockReturnValueOnce({
+ x: 100,
+ y: 100,
+ width: 24,
+ height: 10,
+ })
+ // tooltip
+ .mockReturnValueOnce({ width: 50, height: 15 });
+
+ const ref = createRef();
+ render(
+
+ );
+
+ const tooltip = await screen.findByRole('tooltip');
+ const style = window.getComputedStyle(tooltip);
+ expect(style.left).toEqual('87px');
+ expect(style.top).toEqual('160px');
+ });
+});
diff --git a/web/src/icons/Clip.jsx b/web/src/icons/Clip.jsx
new file mode 100644
index 000000000..4635f3ccc
--- /dev/null
+++ b/web/src/icons/Clip.jsx
@@ -0,0 +1,13 @@
+import { h } from 'preact';
+import { memo } from 'preact/compat';
+
+export function Clip({ className = '' }) {
+ return (
+
+ );
+}
+
+export default memo(Clip);
diff --git a/web/src/icons/Motion.jsx b/web/src/icons/Motion.jsx
new file mode 100644
index 000000000..18320d465
--- /dev/null
+++ b/web/src/icons/Motion.jsx
@@ -0,0 +1,12 @@
+import { h } from 'preact';
+import { memo } from 'preact/compat';
+
+export function Motion({ className = '' }) {
+ return (
+
+ );
+}
+
+export default memo(Motion);
diff --git a/web/src/icons/Snapshot.jsx b/web/src/icons/Snapshot.jsx
new file mode 100644
index 000000000..9990571bc
--- /dev/null
+++ b/web/src/icons/Snapshot.jsx
@@ -0,0 +1,14 @@
+import { h } from 'preact';
+import { memo } from 'preact/compat';
+
+export function Snapshot({ className = '' }) {
+ return (
+
+ );
+}
+
+export default memo(Snapshot);
diff --git a/web/src/routes/Cameras.jsx b/web/src/routes/Cameras.jsx
index 6dc0a00cc..60b9de159 100644
--- a/web/src/routes/Cameras.jsx
+++ b/web/src/routes/Cameras.jsx
@@ -2,6 +2,10 @@ import { h } from 'preact';
import ActivityIndicator from '../components/ActivityIndicator';
import Card from '../components/Card';
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 { useConfig, FetchStatus } from '../api';
import { useMemo } from 'preact/hooks';
@@ -20,8 +24,42 @@ export default function Cameras() {
}
function Camera({ name }) {
+ const { payload: detectValue, send: sendDetect } = useDetectState(name);
+ const { payload: clipValue, send: sendClips } = useClipsState(name);
+ const { payload: snapshotValue, send: sendSnapshots } = useSnapshotsState(name);
const href = `/cameras/${name}`;
const buttons = useMemo(() => [{ name: 'Events', href: `/events?camera=${name}` }], [name]);
+ const icons = useMemo(
+ () => [
+ {
+ name: `Toggle detect ${detectValue === 'ON' ? 'off' : 'on'}`,
+ icon: MotionIcon,
+ color: detectValue === 'ON' ? 'blue' : 'gray',
+ onClick: () => {
+ sendDetect(detectValue === 'ON' ? 'OFF' : 'ON');
+ },
+ },
+ {
+ name: `Toggle clips ${clipValue === 'ON' ? 'off' : 'on'}`,
+ icon: ClipIcon,
+ color: clipValue === 'ON' ? 'blue' : 'gray',
+ onClick: () => {
+ sendClips(clipValue === 'ON' ? 'OFF' : 'ON');
+ },
+ },
+ {
+ name: `Toggle snapshots ${snapshotValue === 'ON' ? 'off' : 'on'}`,
+ icon: SnapshotIcon,
+ color: snapshotValue === 'ON' ? 'blue' : 'gray',
+ onClick: () => {
+ sendSnapshots(snapshotValue === 'ON' ? 'OFF' : 'ON');
+ },
+ },
+ ],
+ [detectValue, sendDetect, clipValue, sendClips, snapshotValue, sendSnapshots]
+ );
- return
} />;
+ return (
+
} />
+ );
}
diff --git a/web/src/routes/Debug.jsx b/web/src/routes/Debug.jsx
index f77ddf56d..6e04509bd 100644
--- a/web/src/routes/Debug.jsx
+++ b/web/src/routes/Debug.jsx
@@ -14,7 +14,7 @@ export default function Debug() {
const { data: config } = useConfig();
const {
- value: { stats },
+ value: { payload: stats },
} = useMqtt('stats');
const { data: initialStats } = useStats();
diff --git a/web/src/routes/StyleGuide.jsx b/web/src/routes/StyleGuide.jsx
index fce992539..b0759fd63 100644
--- a/web/src/routes/StyleGuide.jsx
+++ b/web/src/routes/StyleGuide.jsx
@@ -25,6 +25,7 @@ export default function StyleGuide() {
+
@@ -35,6 +36,9 @@ export default function StyleGuide() {
+
@@ -47,6 +51,9 @@ export default function StyleGuide() {
+
diff --git a/web/src/routes/__tests__/Cameras.test.jsx b/web/src/routes/__tests__/Cameras.test.jsx
index 770ecd387..6fee585f4 100644
--- a/web/src/routes/__tests__/Cameras.test.jsx
+++ b/web/src/routes/__tests__/Cameras.test.jsx
@@ -1,8 +1,9 @@
import { h } from 'preact';
import * as Api from '../../api';
-import Cameras from '../Cameras';
import * as CameraImage from '../../components/CameraImage';
-import { render, screen } from '@testing-library/preact';
+import * as Mqtt from '../../api/mqtt';
+import Cameras from '../Cameras';
+import { fireEvent, render, screen } from '@testing-library/preact';
describe('Cameras Route', () => {
let useConfigMock;
@@ -19,6 +20,7 @@ describe('Cameras Route', () => {
}));
jest.spyOn(Api, 'useApiHost').mockImplementation(() => 'http://base-url.local:5000');
jest.spyOn(CameraImage, 'default').mockImplementation(() =>
);
+ jest.spyOn(Mqtt, 'useMqtt').mockImplementation(() => ({ value: { payload: 'OFF' }, send: jest.fn() }));
});
test('shows an ActivityIndicator if not yet loaded', async () => {
@@ -38,4 +40,35 @@ describe('Cameras Route', () => {
expect(screen.queryByText('side')).toBeInTheDocument();
expect(screen.queryByText('side').closest('a')).toHaveAttribute('href', '/cameras/side');
});
+
+ test('buttons toggle detect, clips, and snapshots', async () => {
+ const sendDetect = jest.fn();
+ const sendClips = 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, 'useSnapshotsState').mockImplementation(() => {
+ return { payload: 'ON', send: sendSnapshots };
+ });
+
+ render(
);
+
+ fireEvent.click(screen.getAllByLabelText('Toggle detect off')[0]);
+ expect(sendDetect).toHaveBeenCalledWith('OFF');
+ expect(sendDetect).toHaveBeenCalledTimes(1);
+
+ fireEvent.click(screen.getAllByLabelText('Toggle snapshots off')[0]);
+ expect(sendSnapshots).toHaveBeenCalledWith('OFF');
+
+ fireEvent.click(screen.getAllByLabelText('Toggle clips on')[0]);
+ expect(sendClips).toHaveBeenCalledWith('ON');
+
+ expect(sendDetect).toHaveBeenCalledTimes(1);
+ expect(sendSnapshots).toHaveBeenCalledTimes(1);
+ expect(sendClips).toHaveBeenCalledTimes(1);
+ });
});
diff --git a/web/src/routes/__tests__/Debug.test.jsx b/web/src/routes/__tests__/Debug.test.jsx
index 4a14835ef..e28f033c1 100644
--- a/web/src/routes/__tests__/Debug.test.jsx
+++ b/web/src/routes/__tests__/Debug.test.jsx
@@ -1,10 +1,11 @@
import { h } from 'preact';
import * as Api from '../../api';
+import * as Mqtt from '../../api/mqtt';
import Debug from '../Debug';
import { render, screen } from '@testing-library/preact';
describe('Debug Route', () => {
- let useStatsMock;
+ let useStatsMock, useMqttMock;
beforeEach(() => {
jest.spyOn(Api, 'useConfig').mockImplementation(() => ({
@@ -16,10 +17,14 @@ describe('Debug Route', () => {
front: { name: 'front', objects: { track: ['taco', 'cat', 'dog'] } },
side: { name: 'side', objects: { track: ['taco', 'cat', 'dog'] } },
},
+ mqtt: {
+ stats_interva: 60,
+ },
},
status: 'loaded',
}));
- useStatsMock = jest.spyOn(Api, 'useStats').mockImplementation(() => statsMock);
+ useStatsMock = jest.spyOn(Api, 'useStats').mockImplementation(() => ({ data: statsMock }));
+ useMqttMock = jest.spyOn(Mqtt, 'useMqtt').mockImplementation(() => ({ value: { payload: null } }));
});
test('shows an ActivityIndicator if stats are null', async () => {
@@ -43,29 +48,31 @@ describe('Debug Route', () => {
expect(screen.queryByRole('button', { name: 'Copy to Clipboard' })).toBeInTheDocument();
});
- test('updates the stats on a timeout', async () => {
- jest.spyOn(window, 'setTimeout').mockReturnValue(123);
- render(
);
- expect(useStatsMock).toHaveBeenCalledWith(null, null);
- jest.advanceTimersByTime(1001);
- expect(useStatsMock).toHaveBeenCalledWith(null, 123);
- expect(useStatsMock).toHaveBeenCalledTimes(2);
+ test('updates the stats from mqtt', async () => {
+ const { rerender } = render(
);
+ expect(useMqttMock).toHaveBeenCalledWith('stats');
+ useMqttMock.mockReturnValue({
+ value: {
+ payload: { ...statsMock, detectors: { coral: { ...statsMock.detectors.coral, inference_speed: 42.4242 } } },
+ },
+ });
+ rerender(
);
+
+ expect(screen.queryByText('42.4242')).toBeInTheDocument();
});
});
const statsMock = {
- data: {
+ detection_fps: 0.0,
+ detectors: { coral: { detection_start: 0.0, inference_speed: 8.94, pid: 52 } },
+ front: { camera_fps: 5.0, capture_pid: 64, detection_fps: 0.0, pid: 54, process_fps: 0.0, skipped_fps: 0.0 },
+ side: {
+ camera_fps: 6.9,
+ capture_pid: 71,
detection_fps: 0.0,
- detectors: { coral: { detection_start: 0.0, inference_speed: 8.94, pid: 52 } },
- front: { camera_fps: 5.0, capture_pid: 64, detection_fps: 0.0, pid: 54, process_fps: 0.0, skipped_fps: 0.0 },
- side: {
- camera_fps: 6.9,
- capture_pid: 71,
- detection_fps: 0.0,
- pid: 60,
- process_fps: 0.0,
- skipped_fps: 0.0,
- },
- service: { uptime: 34812, version: '0.8.1-d376f6b' },
+ pid: 60,
+ process_fps: 0.0,
+ skipped_fps: 0.0,
},
+ service: { uptime: 34812, version: '0.8.1-d376f6b' },
};