mirror of
https://github.com/blakeblackshear/frigate.git
synced 2024-11-21 19:07:46 +01:00
feat(web): detect, clips, snapshots toggles
This commit is contained in:
parent
e399790442
commit
b6ba6459fb
@ -15,10 +15,4 @@ Object.defineProperty(window, 'matchMedia', {
|
|||||||
|
|
||||||
window.fetch = () => Promise.resolve();
|
window.fetch = () => Promise.resolve();
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
jest.spyOn(window, 'fetch').mockImplementation(async (url, opts = {}) => {
|
|
||||||
throw new Error(`Unexpected fetch to ${url}, ${JSON.stringify(opts)}`);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
jest.mock('../src/env');
|
jest.mock('../src/env');
|
||||||
|
@ -16,6 +16,7 @@
|
|||||||
<body>
|
<body>
|
||||||
<div id="root" class="z-0"></div>
|
<div id="root" class="z-0"></div>
|
||||||
<div id="menus" class="z-0"></div>
|
<div id="menus" class="z-0"></div>
|
||||||
|
<div id="tooltips" class="z-0"></div>
|
||||||
<noscript>You need to enable JavaScript to run this app.</noscript>
|
<noscript>You need to enable JavaScript to run this app.</noscript>
|
||||||
<script type="module" src="/dist/index.js"></script>
|
<script type="module" src="/dist/index.js"></script>
|
||||||
</body>
|
</body>
|
||||||
|
@ -1,8 +1,13 @@
|
|||||||
import { h } from 'preact';
|
import { h } from 'preact';
|
||||||
|
import * as Mqtt from '../mqtt';
|
||||||
import { ApiProvider, useFetch, useApiHost } from '..';
|
import { ApiProvider, useFetch, useApiHost } from '..';
|
||||||
import { render, screen } from '@testing-library/preact';
|
import { render, screen } from '@testing-library/preact';
|
||||||
|
|
||||||
describe('useApiHost', () => {
|
describe('useApiHost', () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
jest.spyOn(Mqtt, 'MqttProvider').mockImplementation(({ children }) => children);
|
||||||
|
});
|
||||||
|
|
||||||
test('is set from the baseUrl', async () => {
|
test('is set from the baseUrl', async () => {
|
||||||
function Test() {
|
function Test() {
|
||||||
const apiHost = useApiHost();
|
const apiHost = useApiHost();
|
||||||
@ -17,8 +22,7 @@ describe('useApiHost', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('useFetch', () => {
|
function Test() {
|
||||||
function Test() {
|
|
||||||
const { data, status } = useFetch('/api/tacos');
|
const { data, status } = useFetch('/api/tacos');
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
@ -26,17 +30,26 @@ describe('useFetch', () => {
|
|||||||
<span>{status}</span>
|
<span>{status}</span>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('useFetch', () => {
|
||||||
|
let fetchSpy;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
jest.spyOn(Mqtt, 'MqttProvider').mockImplementation(({ children }) => children);
|
||||||
|
fetchSpy = jest.spyOn(window, 'fetch').mockImplementation(async (url, options) => {
|
||||||
|
if (url.endsWith('/api/config')) {
|
||||||
|
return Promise.resolve({ ok: true, json: () => Promise.resolve({}) });
|
||||||
}
|
}
|
||||||
test('loads data', async () => {
|
return new Promise((resolve) => {
|
||||||
const fetchSpy = jest.spyOn(window, 'fetch').mockImplementation(
|
|
||||||
(url) =>
|
|
||||||
new Promise((resolve) => {
|
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
resolve({ ok: true, json: () => Promise.resolve({ returnData: 'yep' }) });
|
resolve({ ok: true, json: () => Promise.resolve({ returnData: 'yep' }) });
|
||||||
}, 1);
|
}, 1);
|
||||||
})
|
});
|
||||||
);
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test('loads data', async () => {
|
||||||
render(
|
render(
|
||||||
<ApiProvider>
|
<ApiProvider>
|
||||||
<Test />
|
<Test />
|
||||||
@ -55,14 +68,16 @@ describe('useFetch', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('sets error if response is not okay', async () => {
|
test('sets error if response is not okay', async () => {
|
||||||
jest.spyOn(window, 'fetch').mockImplementation(
|
jest.spyOn(window, 'fetch').mockImplementation((url) => {
|
||||||
(url) =>
|
if (url.includes('/config')) {
|
||||||
new Promise((resolve) => {
|
return Promise.resolve({ ok: true, json: () => Promise.resolve({}) });
|
||||||
|
}
|
||||||
|
return new Promise((resolve) => {
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
resolve({ ok: false });
|
resolve({ ok: false });
|
||||||
}, 1);
|
}, 1);
|
||||||
})
|
});
|
||||||
);
|
});
|
||||||
|
|
||||||
render(
|
render(
|
||||||
<ApiProvider>
|
<ApiProvider>
|
||||||
@ -76,15 +91,6 @@ describe('useFetch', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('does not re-fetch if the query has already been made', async () => {
|
test('does not re-fetch if the query has already been made', async () => {
|
||||||
const fetchSpy = jest.spyOn(window, 'fetch').mockImplementation(
|
|
||||||
(url) =>
|
|
||||||
new Promise((resolve) => {
|
|
||||||
setTimeout(() => {
|
|
||||||
resolve({ ok: true, json: () => Promise.resolve({ returnData: 'yep' }) });
|
|
||||||
}, 1);
|
|
||||||
})
|
|
||||||
);
|
|
||||||
|
|
||||||
const { rerender } = render(
|
const { rerender } = render(
|
||||||
<ApiProvider>
|
<ApiProvider>
|
||||||
<Test key={0} />
|
<Test key={0} />
|
||||||
@ -109,6 +115,7 @@ describe('useFetch', () => {
|
|||||||
|
|
||||||
jest.runAllTimers();
|
jest.runAllTimers();
|
||||||
|
|
||||||
expect(fetchSpy).toHaveBeenCalledTimes(1);
|
// once for /api/config, once for /api/tacos
|
||||||
|
expect(fetchSpy).toHaveBeenCalledTimes(2);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
@ -45,7 +45,7 @@ describe('MqttProvider', () => {
|
|||||||
|
|
||||||
test('connects to the mqtt server', async () => {
|
test('connects to the mqtt server', async () => {
|
||||||
render(
|
render(
|
||||||
<MqttProvider createWebsocket={createWebsocket} mqttUrl={TEST_URL}>
|
<MqttProvider config={mockConfig} createWebsocket={createWebsocket} mqttUrl={TEST_URL}>
|
||||||
<Test />
|
<Test />
|
||||||
</MqttProvider>
|
</MqttProvider>
|
||||||
);
|
);
|
||||||
@ -69,7 +69,7 @@ describe('MqttProvider', () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const { rerender } = render(
|
const { rerender } = render(
|
||||||
<MqttProvider createWebsocket={createWebsocket} mqttUrl={TEST_URL}>
|
<MqttProvider config={mockConfig} createWebsocket={createWebsocket} mqttUrl={TEST_URL}>
|
||||||
<Test />
|
<Test />
|
||||||
</MqttProvider>
|
</MqttProvider>
|
||||||
);
|
);
|
||||||
@ -78,7 +78,7 @@ describe('MqttProvider', () => {
|
|||||||
data: JSON.stringify({ topic: 'tacos', payload: JSON.stringify({ yes: true }), retain: false }),
|
data: JSON.stringify({ topic: 'tacos', payload: JSON.stringify({ yes: true }), retain: false }),
|
||||||
});
|
});
|
||||||
rerender(
|
rerender(
|
||||||
<MqttProvider createWebsocket={createWebsocket} mqttUrl={TEST_URL}>
|
<MqttProvider config={mockConfig} createWebsocket={createWebsocket} mqttUrl={TEST_URL}>
|
||||||
<Test />
|
<Test />
|
||||||
</MqttProvider>
|
</MqttProvider>
|
||||||
);
|
);
|
||||||
@ -96,7 +96,7 @@ describe('MqttProvider', () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
render(
|
render(
|
||||||
<MqttProvider createWebsocket={createWebsocket} mqttUrl={TEST_URL}>
|
<MqttProvider config={mockConfig} createWebsocket={createWebsocket} mqttUrl={TEST_URL}>
|
||||||
<Test />
|
<Test />
|
||||||
</MqttProvider>
|
</MqttProvider>
|
||||||
);
|
);
|
||||||
@ -106,4 +106,30 @@ describe('MqttProvider', () => {
|
|||||||
JSON.stringify({ topic: 'tacos', payload: JSON.stringify({ yes: true }) })
|
JSON.stringify({ topic: 'tacos', payload: JSON.stringify({ yes: true }) })
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('prefills the clips/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 } },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
render(
|
||||||
|
<MqttProvider config={config} createWebsocket={createWebsocket} mqttUrl={TEST_URL}>
|
||||||
|
<Test />
|
||||||
|
</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/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/snapshots/state')).toHaveTextContent('{"lastUpdate":123456,"payload":"OFF"}');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const mockConfig = {
|
||||||
|
cameras: {},
|
||||||
|
};
|
||||||
|
@ -40,14 +40,19 @@ function reducer(state, { type, payload, meta }) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export const ApiProvider = ({ children }) => {
|
export function ApiProvider({ children }) {
|
||||||
const [state, dispatch] = useReducer(reducer, initialState);
|
const [state, dispatch] = useReducer(reducer, initialState);
|
||||||
return (
|
return (
|
||||||
<Api.Provider value={{ state, dispatch }}>
|
<Api.Provider value={{ state, dispatch }}>
|
||||||
<MqttProvider>{children}</MqttProvider>
|
<MqttWithConfig>{children}</MqttWithConfig>
|
||||||
</Api.Provider>
|
</Api.Provider>
|
||||||
);
|
);
|
||||||
};
|
}
|
||||||
|
|
||||||
|
function MqttWithConfig({ children }) {
|
||||||
|
const { data, status } = useConfig();
|
||||||
|
return status === FetchStatus.LOADED ? <MqttProvider config={data}>{children}</MqttProvider> : children;
|
||||||
|
}
|
||||||
|
|
||||||
function shouldFetch(state, url, fetchId = null) {
|
function shouldFetch(state, url, fetchId = null) {
|
||||||
if ((fetchId && url in state.queries && state.queries[url].fetchId !== fetchId) || !(url in state.queries)) {
|
if ((fetchId && url in state.queries && state.queries[url].fetchId !== fetchId) || !(url in state.queries)) {
|
||||||
|
@ -31,6 +31,7 @@ function reducer(state, { topic, payload, retain }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function MqttProvider({
|
export function MqttProvider({
|
||||||
|
config,
|
||||||
children,
|
children,
|
||||||
createWebsocket = defaultCreateWebsocket,
|
createWebsocket = defaultCreateWebsocket,
|
||||||
mqttUrl = `${baseUrl.replace(/^https?:/, 'ws:')}/ws`,
|
mqttUrl = `${baseUrl.replace(/^https?:/, 'ws:')}/ws`,
|
||||||
@ -38,6 +39,15 @@ export function MqttProvider({
|
|||||||
const [state, dispatch] = useReducer(reducer, initialState);
|
const [state, dispatch] = useReducer(reducer, initialState);
|
||||||
const wsRef = useRef();
|
const wsRef = useRef();
|
||||||
|
|
||||||
|
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' });
|
||||||
|
dispatch({ topic: `${name}/detect/state`, payload: detect.enabled ? 'ON' : 'OFF' });
|
||||||
|
dispatch({ topic: `${name}/snapshots/state`, payload: snapshots.enabled ? 'ON' : 'OFF' });
|
||||||
|
});
|
||||||
|
}, [config]);
|
||||||
|
|
||||||
useEffect(
|
useEffect(
|
||||||
() => {
|
() => {
|
||||||
const ws = createWebsocket(mqttUrl);
|
const ws = createWebsocket(mqttUrl);
|
||||||
@ -62,17 +72,49 @@ export function MqttProvider({
|
|||||||
return <Mqtt.Provider value={{ state, ws: wsRef.current }}>{children}</Mqtt.Provider>;
|
return <Mqtt.Provider value={{ state, ws: wsRef.current }}>{children}</Mqtt.Provider>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useMqtt(topic) {
|
export function useMqtt(watchTopic, publishTopic) {
|
||||||
const { state, ws } = useContext(Mqtt);
|
const { state, ws } = useContext(Mqtt);
|
||||||
|
|
||||||
const value = state[topic] || { payload: null };
|
const value = state[watchTopic] || { payload: null };
|
||||||
|
|
||||||
const send = useCallback(
|
const send = useCallback(
|
||||||
(payload) => {
|
(payload) => {
|
||||||
ws.send(JSON.stringify({ topic, payload: typeof payload !== 'string' ? JSON.stringify(payload) : payload }));
|
ws.send(
|
||||||
|
JSON.stringify({
|
||||||
|
topic: publishTopic || watchTopic,
|
||||||
|
payload: typeof payload !== 'string' ? JSON.stringify(payload) : payload,
|
||||||
|
})
|
||||||
|
);
|
||||||
},
|
},
|
||||||
[ws, topic]
|
[ws, watchTopic, publishTopic]
|
||||||
);
|
);
|
||||||
|
|
||||||
return { value, send, connected: state.__connected };
|
return { value, send, connected: state.__connected };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function useDetectState(camera) {
|
||||||
|
const {
|
||||||
|
value: { payload },
|
||||||
|
send,
|
||||||
|
connected,
|
||||||
|
} = useMqtt(`${camera}/detect/state`, `${camera}/detect/set`);
|
||||||
|
return { payload, send, connected };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useClipsState(camera) {
|
||||||
|
const {
|
||||||
|
value: { payload },
|
||||||
|
send,
|
||||||
|
connected,
|
||||||
|
} = useMqtt(`${camera}/clips/state`, `${camera}/clips/set`);
|
||||||
|
return { payload, send, connected };
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useSnapshotsState(camera) {
|
||||||
|
const {
|
||||||
|
value: { payload },
|
||||||
|
send,
|
||||||
|
connected,
|
||||||
|
} = useMqtt(`${camera}/snapshots/state`, `${camera}/snapshots/set`);
|
||||||
|
return { payload, send, connected };
|
||||||
|
}
|
||||||
|
@ -1,4 +1,6 @@
|
|||||||
import { h } from 'preact';
|
import { h, Fragment } from 'preact';
|
||||||
|
import Tooltip from './Tooltip';
|
||||||
|
import { useCallback, useRef, useState } from 'preact/hooks';
|
||||||
|
|
||||||
const ButtonColors = {
|
const ButtonColors = {
|
||||||
blue: {
|
blue: {
|
||||||
@ -22,6 +24,13 @@ const ButtonColors = {
|
|||||||
text:
|
text:
|
||||||
'text-green-500 hover:bg-green-500 hover:bg-opacity-20 focus:bg-green-500 focus:bg-opacity-40 active:bg-green-500 active:bg-opacity-40',
|
'text-green-500 hover:bg-green-500 hover:bg-opacity-20 focus:bg-green-500 focus:bg-opacity-40 active:bg-green-500 active:bg-opacity-40',
|
||||||
},
|
},
|
||||||
|
gray: {
|
||||||
|
contained: 'bg-gray-500 focus:bg-gray-400 active:bg-gray-600 ring-gray-300',
|
||||||
|
outlined:
|
||||||
|
'text-gray-500 border-2 border-gray-500 hover:bg-gray-500 hover:bg-opacity-20 focus:bg-gray-500 focus:bg-opacity-40 active:bg-gray-500 active:bg-opacity-40',
|
||||||
|
text:
|
||||||
|
'text-gray-500 hover:bg-gray-500 hover:bg-opacity-20 focus:bg-gray-500 focus:bg-opacity-40 active:bg-gray-500 active:bg-opacity-40',
|
||||||
|
},
|
||||||
disabled: {
|
disabled: {
|
||||||
contained: 'bg-gray-400',
|
contained: 'bg-gray-400',
|
||||||
outlined:
|
outlined:
|
||||||
@ -52,6 +61,9 @@ export default function Button({
|
|||||||
type = 'contained',
|
type = 'contained',
|
||||||
...attrs
|
...attrs
|
||||||
}) {
|
}) {
|
||||||
|
const [hovered, setHovered] = useState(false);
|
||||||
|
const ref = useRef();
|
||||||
|
|
||||||
let classes = `whitespace-nowrap flex items-center space-x-1 ${className} ${ButtonTypes[type]} ${
|
let classes = `whitespace-nowrap flex items-center space-x-1 ${className} ${ButtonTypes[type]} ${
|
||||||
ButtonColors[disabled ? 'disabled' : color][type]
|
ButtonColors[disabled ? 'disabled' : color][type]
|
||||||
} font-sans inline-flex font-bold uppercase text-xs px-2 py-2 rounded outline-none focus:outline-none ring-opacity-50 transition-shadow transition-colors ${
|
} font-sans inline-flex font-bold uppercase text-xs px-2 py-2 rounded outline-none focus:outline-none ring-opacity-50 transition-shadow transition-colors ${
|
||||||
@ -62,18 +74,32 @@ export default function Button({
|
|||||||
classes = classes.replace(/(?:focus|active|hover):[^ ]+/g, '');
|
classes = classes.replace(/(?:focus|active|hover):[^ ]+/g, '');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const handleMousenter = useCallback((event) => {
|
||||||
|
setHovered(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleMouseleave = useCallback((event) => {
|
||||||
|
setHovered(false);
|
||||||
|
}, []);
|
||||||
|
|
||||||
const Element = href ? 'a' : 'div';
|
const Element = href ? 'a' : 'div';
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<Fragment>
|
||||||
<Element
|
<Element
|
||||||
role="button"
|
role="button"
|
||||||
aria-disabled={disabled ? 'true' : 'false'}
|
aria-disabled={disabled ? 'true' : 'false'}
|
||||||
tabindex="0"
|
tabindex="0"
|
||||||
className={classes}
|
className={classes}
|
||||||
href={href}
|
href={href}
|
||||||
|
ref={ref}
|
||||||
|
onmouseenter={handleMousenter}
|
||||||
|
onmouseleave={handleMouseleave}
|
||||||
{...attrs}
|
{...attrs}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
</Element>
|
</Element>
|
||||||
|
{hovered && attrs['aria-label'] ? <Tooltip text={attrs['aria-label']} relativeTo={ref} /> : null}
|
||||||
|
</Fragment>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -9,6 +9,7 @@ export default function Box({
|
|||||||
elevated = true,
|
elevated = true,
|
||||||
header,
|
header,
|
||||||
href,
|
href,
|
||||||
|
icons = [],
|
||||||
media = null,
|
media = null,
|
||||||
...props
|
...props
|
||||||
}) {
|
}) {
|
||||||
@ -26,8 +27,8 @@ export default function Box({
|
|||||||
<div className="p-4 pb-2">{header ? <Heading size="base">{header}</Heading> : null}</div>
|
<div className="p-4 pb-2">{header ? <Heading size="base">{header}</Heading> : null}</div>
|
||||||
</Element>
|
</Element>
|
||||||
) : null}
|
) : null}
|
||||||
{buttons.length || content ? (
|
{buttons.length || content || icons.length ? (
|
||||||
<div className="pl-4 pb-2">
|
<div className="px-4 pb-2">
|
||||||
{content || null}
|
{content || null}
|
||||||
{buttons.length ? (
|
{buttons.length ? (
|
||||||
<div className="flex space-x-4 -ml-2">
|
<div className="flex space-x-4 -ml-2">
|
||||||
@ -36,6 +37,12 @@ export default function Box({
|
|||||||
{name}
|
{name}
|
||||||
</Button>
|
</Button>
|
||||||
))}
|
))}
|
||||||
|
<div class="flex-grow" />
|
||||||
|
{icons.map(({ name, icon: Icon, ...props }) => (
|
||||||
|
<Button aria-label={name} className="rounded-full" key={name} type="text" {...props}>
|
||||||
|
<Icon className="w-6" />
|
||||||
|
</Button>
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
|
61
web/src/components/Tooltip.jsx
Normal file
61
web/src/components/Tooltip.jsx
Normal file
@ -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 = (
|
||||||
|
<div
|
||||||
|
role="tooltip"
|
||||||
|
className={`shadow max-w-lg absolute pointer-events-none bg-gray-900 dark:bg-gray-200 bg-opacity-80 rounded px-2 py-1 transition-opacity duration-200 opacity-0 text-gray-100 dark:text-gray-900 text-sm ${
|
||||||
|
position.top >= 0 ? 'opacity-100' : ''
|
||||||
|
}`}
|
||||||
|
ref={ref}
|
||||||
|
style={position.top >= 0 ? position : null}
|
||||||
|
>
|
||||||
|
{text}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
return portalRoot ? createPortal(tooltip, portalRoot) : tooltip;
|
||||||
|
}
|
115
web/src/components/__tests__/Toolltip.test.jsx
Normal file
115
web/src/components/__tests__/Toolltip.test.jsx
Normal file
@ -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(
|
||||||
|
<div>
|
||||||
|
<div ref={ref} />
|
||||||
|
<Tooltip relativeTo={ref} text="hello" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
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(
|
||||||
|
<div>
|
||||||
|
<div ref={ref} />
|
||||||
|
<Tooltip relativeTo={ref} text="hello" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
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(
|
||||||
|
<div>
|
||||||
|
<div ref={ref} />
|
||||||
|
<Tooltip relativeTo={ref} text="hello" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
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(
|
||||||
|
<div>
|
||||||
|
<div ref={ref} />
|
||||||
|
<Tooltip relativeTo={ref} text="hello" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
const tooltip = await screen.findByRole('tooltip');
|
||||||
|
const style = window.getComputedStyle(tooltip);
|
||||||
|
expect(style.left).toEqual('87px');
|
||||||
|
expect(style.top).toEqual('160px');
|
||||||
|
});
|
||||||
|
});
|
13
web/src/icons/Clip.jsx
Normal file
13
web/src/icons/Clip.jsx
Normal file
@ -0,0 +1,13 @@
|
|||||||
|
import { h } from 'preact';
|
||||||
|
import { memo } from 'preact/compat';
|
||||||
|
|
||||||
|
export function Clip({ className = '' }) {
|
||||||
|
return (
|
||||||
|
<svg className={`fill-current ${className}`} viewBox="0 0 24 24">
|
||||||
|
<path d="M0 0h24v24H0z" fill="none" />
|
||||||
|
<path d="M18 3v2h-2V3H8v2H6V3H4v18h2v-2h2v2h8v-2h2v2h2V3h-2zM8 17H6v-2h2v2zm0-4H6v-2h2v2zm0-4H6V7h2v2zm10 8h-2v-2h2v2zm0-4h-2v-2h2v2zm0-4h-2V7h2v2z" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default memo(Clip);
|
12
web/src/icons/Motion.jsx
Normal file
12
web/src/icons/Motion.jsx
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
import { h } from 'preact';
|
||||||
|
import { memo } from 'preact/compat';
|
||||||
|
|
||||||
|
export function Motion({ className = '' }) {
|
||||||
|
return (
|
||||||
|
<svg className={`fill-current ${className}`} viewBox="0 0 24 24">
|
||||||
|
<path d="M13.5 5.5c1.1 0 2-.9 2-2s-.9-2-2-2-2 .9-2 2 .9 2 2 2zM9.8 8.9L7 23h2.1l1.8-8 2.1 2v6h2v-7.5l-2.1-2 .6-3C14.8 12 16.8 13 19 13v-2c-1.9 0-3.5-1-4.3-2.4l-1-1.6c-.4-.6-1-1-1.7-1-.3 0-.5.1-.8.1L6 8.3V13h2V9.6l1.8-.7" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default memo(Motion);
|
14
web/src/icons/Snapshot.jsx
Normal file
14
web/src/icons/Snapshot.jsx
Normal file
@ -0,0 +1,14 @@
|
|||||||
|
import { h } from 'preact';
|
||||||
|
import { memo } from 'preact/compat';
|
||||||
|
|
||||||
|
export function Snapshot({ className = '' }) {
|
||||||
|
return (
|
||||||
|
<svg className={`fill-current ${className}`} viewBox="0 0 24 24">
|
||||||
|
<path d="M0 0h24v24H0z" fill="none" />
|
||||||
|
<circle cx="12" cy="12" r="3.2" />
|
||||||
|
<path d="M9 2L7.17 4H4c-1.1 0-2 .9-2 2v12c0 1.1.9 2 2 2h16c1.1 0 2-.9 2-2V6c0-1.1-.9-2-2-2h-3.17L15 2H9zm3 15c-2.76 0-5-2.24-5-5s2.24-5 5-5 5 2.24 5 5-2.24 5-5 5z" />
|
||||||
|
</svg>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default memo(Snapshot);
|
@ -2,6 +2,10 @@ import { h } from 'preact';
|
|||||||
import ActivityIndicator from '../components/ActivityIndicator';
|
import ActivityIndicator from '../components/ActivityIndicator';
|
||||||
import Card from '../components/Card';
|
import Card from '../components/Card';
|
||||||
import CameraImage from '../components/CameraImage';
|
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 { useConfig, FetchStatus } from '../api';
|
||||||
import { useMemo } from 'preact/hooks';
|
import { useMemo } from 'preact/hooks';
|
||||||
|
|
||||||
@ -20,8 +24,42 @@ export default function Cameras() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function Camera({ name }) {
|
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 href = `/cameras/${name}`;
|
||||||
const buttons = useMemo(() => [{ name: 'Events', href: `/events?camera=${name}` }], [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 <Card buttons={buttons} href={href} header={name} media={<CameraImage camera={name} stretch />} />;
|
return (
|
||||||
|
<Card buttons={buttons} href={href} header={name} icons={icons} media={<CameraImage camera={name} stretch />} />
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
@ -14,7 +14,7 @@ export default function Debug() {
|
|||||||
const { data: config } = useConfig();
|
const { data: config } = useConfig();
|
||||||
|
|
||||||
const {
|
const {
|
||||||
value: { stats },
|
value: { payload: stats },
|
||||||
} = useMqtt('stats');
|
} = useMqtt('stats');
|
||||||
const { data: initialStats } = useStats();
|
const { data: initialStats } = useStats();
|
||||||
|
|
||||||
|
@ -25,6 +25,7 @@ export default function StyleGuide() {
|
|||||||
<Button>Default</Button>
|
<Button>Default</Button>
|
||||||
<Button color="red">Danger</Button>
|
<Button color="red">Danger</Button>
|
||||||
<Button color="green">Save</Button>
|
<Button color="green">Save</Button>
|
||||||
|
<Button color="gray">Gray</Button>
|
||||||
<Button disabled>Disabled</Button>
|
<Button disabled>Disabled</Button>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex space-x-4 mb-4">
|
<div className="flex space-x-4 mb-4">
|
||||||
@ -35,6 +36,9 @@ export default function StyleGuide() {
|
|||||||
<Button color="green" type="text">
|
<Button color="green" type="text">
|
||||||
Save
|
Save
|
||||||
</Button>
|
</Button>
|
||||||
|
<Button color="gray" type="text">
|
||||||
|
Gray
|
||||||
|
</Button>
|
||||||
<Button disabled type="text">
|
<Button disabled type="text">
|
||||||
Disabled
|
Disabled
|
||||||
</Button>
|
</Button>
|
||||||
@ -47,6 +51,9 @@ export default function StyleGuide() {
|
|||||||
<Button color="green" type="outlined">
|
<Button color="green" type="outlined">
|
||||||
Save
|
Save
|
||||||
</Button>
|
</Button>
|
||||||
|
<Button color="gray" type="outlined">
|
||||||
|
Gray
|
||||||
|
</Button>
|
||||||
<Button disabled type="outlined">
|
<Button disabled type="outlined">
|
||||||
Disabled
|
Disabled
|
||||||
</Button>
|
</Button>
|
||||||
|
@ -1,8 +1,9 @@
|
|||||||
import { h } from 'preact';
|
import { h } from 'preact';
|
||||||
import * as Api from '../../api';
|
import * as Api from '../../api';
|
||||||
import Cameras from '../Cameras';
|
|
||||||
import * as CameraImage from '../../components/CameraImage';
|
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', () => {
|
describe('Cameras Route', () => {
|
||||||
let useConfigMock;
|
let useConfigMock;
|
||||||
@ -19,6 +20,7 @@ describe('Cameras Route', () => {
|
|||||||
}));
|
}));
|
||||||
jest.spyOn(Api, 'useApiHost').mockImplementation(() => 'http://base-url.local:5000');
|
jest.spyOn(Api, 'useApiHost').mockImplementation(() => 'http://base-url.local:5000');
|
||||||
jest.spyOn(CameraImage, 'default').mockImplementation(() => <div data-testid="camera-image" />);
|
jest.spyOn(CameraImage, 'default').mockImplementation(() => <div data-testid="camera-image" />);
|
||||||
|
jest.spyOn(Mqtt, 'useMqtt').mockImplementation(() => ({ value: { payload: 'OFF' }, send: jest.fn() }));
|
||||||
});
|
});
|
||||||
|
|
||||||
test('shows an ActivityIndicator if not yet loaded', async () => {
|
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')).toBeInTheDocument();
|
||||||
expect(screen.queryByText('side').closest('a')).toHaveAttribute('href', '/cameras/side');
|
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(<Cameras />);
|
||||||
|
|
||||||
|
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);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
@ -1,10 +1,11 @@
|
|||||||
import { h } from 'preact';
|
import { h } from 'preact';
|
||||||
import * as Api from '../../api';
|
import * as Api from '../../api';
|
||||||
|
import * as Mqtt from '../../api/mqtt';
|
||||||
import Debug from '../Debug';
|
import Debug from '../Debug';
|
||||||
import { render, screen } from '@testing-library/preact';
|
import { render, screen } from '@testing-library/preact';
|
||||||
|
|
||||||
describe('Debug Route', () => {
|
describe('Debug Route', () => {
|
||||||
let useStatsMock;
|
let useStatsMock, useMqttMock;
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
jest.spyOn(Api, 'useConfig').mockImplementation(() => ({
|
jest.spyOn(Api, 'useConfig').mockImplementation(() => ({
|
||||||
@ -16,10 +17,14 @@ describe('Debug Route', () => {
|
|||||||
front: { name: 'front', objects: { track: ['taco', 'cat', 'dog'] } },
|
front: { name: 'front', objects: { track: ['taco', 'cat', 'dog'] } },
|
||||||
side: { name: 'side', objects: { track: ['taco', 'cat', 'dog'] } },
|
side: { name: 'side', objects: { track: ['taco', 'cat', 'dog'] } },
|
||||||
},
|
},
|
||||||
|
mqtt: {
|
||||||
|
stats_interva: 60,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
status: 'loaded',
|
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 () => {
|
test('shows an ActivityIndicator if stats are null', async () => {
|
||||||
@ -43,18 +48,21 @@ describe('Debug Route', () => {
|
|||||||
expect(screen.queryByRole('button', { name: 'Copy to Clipboard' })).toBeInTheDocument();
|
expect(screen.queryByRole('button', { name: 'Copy to Clipboard' })).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('updates the stats on a timeout', async () => {
|
test('updates the stats from mqtt', async () => {
|
||||||
jest.spyOn(window, 'setTimeout').mockReturnValue(123);
|
const { rerender } = render(<Debug />);
|
||||||
render(<Debug />);
|
expect(useMqttMock).toHaveBeenCalledWith('stats');
|
||||||
expect(useStatsMock).toHaveBeenCalledWith(null, null);
|
useMqttMock.mockReturnValue({
|
||||||
jest.advanceTimersByTime(1001);
|
value: {
|
||||||
expect(useStatsMock).toHaveBeenCalledWith(null, 123);
|
payload: { ...statsMock, detectors: { coral: { ...statsMock.detectors.coral, inference_speed: 42.4242 } } },
|
||||||
expect(useStatsMock).toHaveBeenCalledTimes(2);
|
},
|
||||||
|
});
|
||||||
|
rerender(<Debug />);
|
||||||
|
|
||||||
|
expect(screen.queryByText('42.4242')).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
const statsMock = {
|
const statsMock = {
|
||||||
data: {
|
|
||||||
detection_fps: 0.0,
|
detection_fps: 0.0,
|
||||||
detectors: { coral: { detection_start: 0.0, inference_speed: 8.94, pid: 52 } },
|
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 },
|
front: { camera_fps: 5.0, capture_pid: 64, detection_fps: 0.0, pid: 54, process_fps: 0.0, skipped_fps: 0.0 },
|
||||||
@ -67,5 +75,4 @@ const statsMock = {
|
|||||||
skipped_fps: 0.0,
|
skipped_fps: 0.0,
|
||||||
},
|
},
|
||||||
service: { uptime: 34812, version: '0.8.1-d376f6b' },
|
service: { uptime: 34812, version: '0.8.1-d376f6b' },
|
||||||
},
|
|
||||||
};
|
};
|
||||||
|
Loading…
Reference in New Issue
Block a user