Use new UI (#8983)

* fixup build

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

View File

@@ -1,23 +0,0 @@
import { h } from 'preact';
import * as WS from '../ws';
import { ApiProvider, useApiHost } from '..';
import { render, screen } from 'testing-library';
describe('useApiHost', () => {
beforeEach(() => {
vi.spyOn(WS, 'WsProvider').mockImplementation(({ children }) => children);
});
test('is set from the baseUrl', async () => {
function Test() {
const apiHost = useApiHost();
return <div>{apiHost}</div>;
}
render(
<ApiProvider>
<Test />
</ApiProvider>
);
expect(screen.queryByText('http://localhost:3000/')).toBeInTheDocument();
});
});

View File

@@ -1,154 +0,0 @@
/* eslint-disable jest/no-disabled-tests */
import { h } from 'preact';
import { WS as frigateWS, WsProvider, useWs } from '../ws';
import { useCallback, useContext } from 'preact/hooks';
import { fireEvent, render, screen } from 'testing-library';
import { WS } from 'jest-websocket-mock';
function Test() {
const { state } = useContext(frigateWS);
return state.__connected ? (
<div data-testid="data">
{Object.keys(state).map((key) => (
<div key={key} data-testid={key}>
{JSON.stringify(state[key])}
</div>
))}
</div>
) : null;
}
const TEST_URL = 'ws://test-foo:1234/ws';
describe('WsProvider', () => {
let wsClient, wsServer;
beforeEach(async () => {
wsClient = {
close: vi.fn(),
send: vi.fn(),
};
wsServer = new WS(TEST_URL);
});
afterEach(() => {
WS.clean();
});
test.skip('connects to the ws server', async () => {
render(
<WsProvider config={mockConfig} wsUrl={TEST_URL}>
<Test />
</WsProvider>
);
await wsServer.connected;
await screen.findByTestId('data');
expect(wsClient.args).toEqual([TEST_URL]);
expect(screen.getByTestId('__connected')).toHaveTextContent('true');
});
test.skip('receives data through useWs', async () => {
function Test() {
const {
value: { payload, retain },
connected,
} = useWs('tacos');
return connected ? (
<div>
<div data-testid="payload">{JSON.stringify(payload)}</div>
<div data-testid="retain">{JSON.stringify(retain)}</div>
</div>
) : null;
}
const { rerender } = render(
<WsProvider config={mockConfig} wsUrl={TEST_URL}>
<Test />
</WsProvider>
);
await wsServer.connected;
await screen.findByTestId('payload');
wsClient.onmessage({
data: JSON.stringify({ topic: 'tacos', payload: JSON.stringify({ yes: true }), retain: false }),
});
rerender(
<WsProvider config={mockConfig} wsUrl={TEST_URL}>
<Test />
</WsProvider>
);
expect(screen.getByTestId('payload')).toHaveTextContent('{"yes":true}');
expect(screen.getByTestId('retain')).toHaveTextContent('false');
});
test.skip('can send values through useWs', async () => {
function Test() {
const { send, connected } = useWs('tacos');
const handleClick = useCallback(() => {
send({ yes: true });
}, [send]);
return connected ? <button onClick={handleClick}>click me</button> : null;
}
render(
<WsProvider config={mockConfig} wsUrl={TEST_URL}>
<Test />
</WsProvider>
);
await wsServer.connected;
await screen.findByRole('button');
fireEvent.click(screen.getByRole('button'));
await expect(wsClient.send).toHaveBeenCalledWith(
JSON.stringify({ topic: 'tacos', payload: JSON.stringify({ yes: true }), retain: false })
);
});
test.skip('prefills the recordings/detect/snapshots state from config', async () => {
vi.spyOn(Date, 'now').mockReturnValue(123456);
const config = {
cameras: {
front: {
name: 'front',
detect: { enabled: true },
record: { enabled: false },
snapshots: { enabled: true },
audio: { enabled: false },
},
side: {
name: 'side',
detect: { enabled: false },
record: { enabled: false },
snapshots: { enabled: false },
audio: { enabled: false },
},
},
};
render(
<WsProvider config={config} wsUrl={TEST_URL}>
<Test />
</WsProvider>
);
await wsServer.connected;
await screen.findByTestId('data');
expect(screen.getByTestId('front/detect/state')).toHaveTextContent(
'{"lastUpdate":123456,"payload":"ON","retain":false}'
);
expect(screen.getByTestId('front/recordings/state')).toHaveTextContent(
'{"lastUpdate":123456,"payload":"OFF","retain":false}'
);
expect(screen.getByTestId('front/snapshots/state')).toHaveTextContent(
'{"lastUpdate":123456,"payload":"ON","retain":false}'
);
expect(screen.getByTestId('side/detect/state')).toHaveTextContent(
'{"lastUpdate":123456,"payload":"OFF","retain":false}'
);
expect(screen.getByTestId('side/recordings/state')).toHaveTextContent(
'{"lastUpdate":123456,"payload":"OFF","retain":false}'
);
expect(screen.getByTestId('side/snapshots/state')).toHaveTextContent(
'{"lastUpdate":123456,"payload":"OFF","retain":false}'
);
});
});
const mockConfig = {
cameras: {},
};

View File

@@ -1 +0,0 @@
export const baseUrl = `${window.location.protocol}//${window.location.host}${window.baseUrl || '/'}`;

7
web/src/api/baseUrl.ts Normal file
View File

@@ -0,0 +1,7 @@
declare global {
interface Window {
baseUrl?: any;
}
}
export const baseUrl = `${window.location.protocol}//${window.location.host}${window.baseUrl || '/'}`;

View File

@@ -1,33 +0,0 @@
import { h } from 'preact';
import { baseUrl } from './baseUrl';
import useSWR, { SWRConfig } from 'swr';
import { WsProvider } from './ws';
import axios from 'axios';
axios.defaults.baseURL = `${baseUrl}api/`;
axios.defaults.headers.common = {
'X-CSRF-TOKEN': 1,
'X-CACHE-BYPASS': 1,
};
export function ApiProvider({ children, options }) {
return (
<SWRConfig
value={{
fetcher: (path, params) => axios.get(path, { params }).then((res) => res.data),
...options,
}}
>
<WsWithConfig>{children}</WsWithConfig>
</SWRConfig>
);
}
function WsWithConfig({ children }) {
const { data } = useSWR('config');
return data ? <WsProvider config={data}>{children}</WsProvider> : children;
}
export function useApiHost() {
return baseUrl;
}

48
web/src/api/index.tsx Normal file
View File

@@ -0,0 +1,48 @@
import { baseUrl } from "./baseUrl";
import useSWR, { SWRConfig } from "swr";
import { WsProvider } from "./ws";
import axios from "axios";
import { ReactNode } from "react";
import { FrigateConfig } from "@/types/frigateConfig";
axios.defaults.baseURL = `${baseUrl}api/`;
type ApiProviderType = {
children?: ReactNode;
options?: Record<string, unknown>;
};
export function ApiProvider({ children, options }: ApiProviderType) {
axios.defaults.headers.common = {
"X-CSRF-TOKEN": 1,
"X-CACHE-BYPASS": 1,
};
return (
<SWRConfig
value={{
fetcher: (key) => {
const [path, params] = Array.isArray(key) ? key : [key, undefined];
return axios.get(path, { params }).then((res) => res.data);
},
...options,
}}
>
<WsWithConfig>{children}</WsWithConfig>
</SWRConfig>
);
}
type WsWithConfigType = {
children: ReactNode;
};
function WsWithConfig({ children }: WsWithConfigType) {
const { data } = useSWR<FrigateConfig>("config");
return data ? <WsProvider config={data}>{children}</WsProvider> : children;
}
export function useApiHost() {
return baseUrl;
}

View File

@@ -1,134 +0,0 @@
import { h, createContext } from 'preact';
import { baseUrl } from './baseUrl';
import { produce } from 'immer';
import { useCallback, useContext, useEffect, useReducer } from 'preact/hooks';
import useWebSocket, { ReadyState } from 'react-use-websocket';
const initialState = Object.freeze({ __connected: false });
export const WS = createContext({ state: initialState, readyState: null, sendJsonMessage: () => {} });
function reducer(state, { topic, payload, retain }) {
switch (topic) {
case '__CLIENT_CONNECTED':
return produce(state, (draftState) => {
draftState.__connected = true;
});
default:
return produce(state, (draftState) => {
let parsedPayload = payload;
try {
parsedPayload = payload && JSON.parse(payload);
} catch (e) {}
draftState[topic] = {
lastUpdate: Date.now(),
payload: parsedPayload,
retain,
};
});
}
}
export function WsProvider({
config,
children,
wsUrl = `${baseUrl.replace(/^http/, 'ws')}ws`,
}) {
const [state, dispatch] = useReducer(reducer, initialState);
const { sendJsonMessage, readyState } = useWebSocket(wsUrl, {
onMessage: (event) => {
dispatch(JSON.parse(event.data));
},
onOpen: () => dispatch({ topic: '__CLIENT_CONNECTED' }),
shouldReconnect: () => true,
});
useEffect(() => {
Object.keys(config.cameras).forEach((camera) => {
const { name, record, detect, snapshots, audio } = config.cameras[camera];
dispatch({ topic: `${name}/recordings/state`, payload: record.enabled ? 'ON' : 'OFF', retain: false });
dispatch({ topic: `${name}/detect/state`, payload: detect.enabled ? 'ON' : 'OFF', retain: false });
dispatch({ topic: `${name}/snapshots/state`, payload: snapshots.enabled ? 'ON' : 'OFF', retain: false });
dispatch({ topic: `${name}/audio/state`, payload: audio.enabled ? 'ON' : 'OFF', retain: false });
});
}, [config]);
return <WS.Provider value={{ state, readyState, sendJsonMessage }}>{children}</WS.Provider>;
}
export function useWs(watchTopic, publishTopic) {
const { state, readyState, sendJsonMessage } = useContext(WS);
const value = state[watchTopic] || { payload: null };
const send = useCallback(
(payload, retain = false) => {
if (readyState === ReadyState.OPEN) {
sendJsonMessage({
topic: publishTopic || watchTopic,
payload,
retain,
});
}
},
[sendJsonMessage, readyState, watchTopic, publishTopic]
);
return { value, send, connected: state.__connected };
}
export function useDetectState(camera) {
const {
value: { payload },
send,
connected,
} = useWs(`${camera}/detect/state`, `${camera}/detect/set`);
return { payload, send, connected };
}
export function useRecordingsState(camera) {
const {
value: { payload },
send,
connected,
} = useWs(`${camera}/recordings/state`, `${camera}/recordings/set`);
return { payload, send, connected };
}
export function useSnapshotsState(camera) {
const {
value: { payload },
send,
connected,
} = useWs(`${camera}/snapshots/state`, `${camera}/snapshots/set`);
return { payload, send, connected };
}
export function useAudioState(camera) {
const {
value: { payload },
send,
connected,
} = useWs(`${camera}/audio/state`, `${camera}/audio/set`);
return { payload, send, connected };
}
export function usePtzCommand(camera) {
const {
value: { payload },
send,
connected,
} = useWs(`${camera}/ptz`, `${camera}/ptz`);
return { payload, send, connected };
}
export function useRestart() {
const {
value: { payload },
send,
connected,
} = useWs('restart', 'restart');
return { payload, send, connected };
}

196
web/src/api/ws.tsx Normal file
View File

@@ -0,0 +1,196 @@
import { baseUrl } from "./baseUrl";
import {
ReactNode,
createContext,
useCallback,
useContext,
useEffect,
useReducer,
} from "react";
import { produce, Draft } from "immer";
import useWebSocket, { ReadyState } from "react-use-websocket";
import { FrigateConfig } from "@/types/frigateConfig";
type ReducerState = {
[topic: string]: {
lastUpdate: number;
payload: string;
retain: boolean;
};
};
type ReducerAction = {
topic: string;
payload: string;
retain: boolean;
};
const initialState: ReducerState = {
_initial_state: {
lastUpdate: 0,
payload: "",
retain: false,
},
};
type WebSocketContextProps = {
state: ReducerState;
readyState: ReadyState;
sendJsonMessage: (message: any) => void;
};
export const WS = createContext<WebSocketContextProps>({
state: initialState,
readyState: ReadyState.CLOSED,
sendJsonMessage: () => {},
});
export const useWebSocketContext = (): WebSocketContextProps => {
const context = useContext(WS);
if (!context) {
throw new Error(
"useWebSocketContext must be used within a WebSocketProvider"
);
}
return context;
};
function reducer(state: ReducerState, action: ReducerAction): ReducerState {
switch (action.topic) {
default:
return produce(state, (draftState: Draft<ReducerState>) => {
let parsedPayload = action.payload;
try {
parsedPayload = action.payload && JSON.parse(action.payload);
} catch (e) {}
draftState[action.topic] = {
lastUpdate: Date.now(),
payload: parsedPayload,
retain: action.retain,
};
});
}
}
type WsProviderType = {
config: FrigateConfig;
children: ReactNode;
wsUrl?: string;
};
export function WsProvider({
config,
children,
wsUrl = `${baseUrl.replace(/^http/, "ws")}ws`,
}: WsProviderType) {
const [state, dispatch] = useReducer(reducer, initialState);
const { sendJsonMessage, readyState } = useWebSocket(wsUrl, {
onMessage: (event) => {
dispatch(JSON.parse(event.data));
},
onOpen: () => dispatch({ topic: "", payload: "", retain: false }),
shouldReconnect: () => true,
});
useEffect(() => {
Object.keys(config.cameras).forEach((camera) => {
const { name, record, detect, snapshots, audio } = config.cameras[camera];
dispatch({
topic: `${name}/recordings/state`,
payload: record.enabled ? "ON" : "OFF",
retain: false,
});
dispatch({
topic: `${name}/detect/state`,
payload: detect.enabled ? "ON" : "OFF",
retain: false,
});
dispatch({
topic: `${name}/snapshots/state`,
payload: snapshots.enabled ? "ON" : "OFF",
retain: false,
});
dispatch({
topic: `${name}/audio/state`,
payload: audio.enabled ? "ON" : "OFF",
retain: false,
});
});
}, [config]);
return (
<WS.Provider value={{ state, readyState, sendJsonMessage }}>
{children}
</WS.Provider>
);
}
export function useWs(watchTopic: string, publishTopic: string) {
const { state, readyState, sendJsonMessage } = useWebSocketContext();
const value = state[watchTopic] || { payload: null };
const send = useCallback(
(payload: string, retain = false) => {
if (readyState === ReadyState.OPEN) {
sendJsonMessage({
topic: publishTopic || watchTopic,
payload,
retain,
});
}
},
[sendJsonMessage, readyState, watchTopic, publishTopic]
);
return { value, send };
}
export function useDetectState(camera: string) {
const {
value: { payload },
send,
} = useWs(`${camera}/detect/state`, `${camera}/detect/set`);
return { payload, send };
}
export function useRecordingsState(camera: string) {
const {
value: { payload },
send,
} = useWs(`${camera}/recordings/state`, `${camera}/recordings/set`);
return { payload, send };
}
export function useSnapshotsState(camera: string) {
const {
value: { payload },
send,
} = useWs(`${camera}/snapshots/state`, `${camera}/snapshots/set`);
return { payload, send };
}
export function useAudioState(camera: string) {
const {
value: { payload },
send,
} = useWs(`${camera}/audio/state`, `${camera}/audio/set`);
return { payload, send };
}
export function usePtzCommand(camera: string) {
const {
value: { payload },
send,
} = useWs(`${camera}/ptz`, `${camera}/ptz`);
return { payload, send };
}
export function useRestart() {
const {
value: { payload },
send,
} = useWs("restart", "restart");
return { payload, send };
}