Merge branch 'staging_environment' of gitlab.com:brandx/bxt-app into staging_environment
This commit is contained in:
commit
217aa78243
3 changed files with 189 additions and 2 deletions
123
src/hooks/useWebSocket.ts
Normal file
123
src/hooks/useWebSocket.ts
Normal file
|
|
@ -0,0 +1,123 @@
|
||||||
|
import { useEffect, useRef, useCallback } from "react";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Derives the WebSocket base URL from VITE_APP_API_URL.
|
||||||
|
* e.g. "https://api.brandxtech.ca" -> "wss://api.brandxtech.ca"
|
||||||
|
* Falls back to current page host for local dev.
|
||||||
|
*/
|
||||||
|
function getWsBaseUrl(): string {
|
||||||
|
const apiUrl = import.meta.env.VITE_APP_API_URL;
|
||||||
|
if (apiUrl) {
|
||||||
|
return apiUrl.replace(/^http/, "ws");
|
||||||
|
}
|
||||||
|
const protocol = window.location.protocol === "https:" ? "wss:" : "ws:";
|
||||||
|
return `${protocol}//${window.location.host}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface UseWebSocketOptions<T> {
|
||||||
|
/** The API path, e.g. "/v1/live/devices/123/components" */
|
||||||
|
path: string;
|
||||||
|
/** Transform the raw MessageEvent into your domain type */
|
||||||
|
parse: (event: MessageEvent) => T;
|
||||||
|
/** Called whenever a new parsed message arrives */
|
||||||
|
onMessage: (data: T) => void;
|
||||||
|
/** Auth token passed as ?token= query param */
|
||||||
|
token?: string;
|
||||||
|
/** Minimum seconds between updates, passed as ?rate= to the backend */
|
||||||
|
rate?: number;
|
||||||
|
/** Whether the connection should be active. Default true. */
|
||||||
|
enabled?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* useWebSocket manages a WebSocket connection with automatic reconnection.
|
||||||
|
*
|
||||||
|
* Usage:
|
||||||
|
* useWebSocket({
|
||||||
|
* path: `/v1/live/devices/${deviceID}/components`,
|
||||||
|
* parse: (e) => Component.any(JSON.parse(e.data)),
|
||||||
|
* onMessage: (component) => handleUpdate(component),
|
||||||
|
* token: authToken,
|
||||||
|
* });
|
||||||
|
*/
|
||||||
|
export function useWebSocket<T>(options: UseWebSocketOptions<T>) {
|
||||||
|
const { path, parse, onMessage, token, rate = 0, enabled = true } = options;
|
||||||
|
|
||||||
|
// Keep latest callbacks in refs so reconnects don't use stale closures
|
||||||
|
const onMessageRef = useRef(onMessage);
|
||||||
|
onMessageRef.current = onMessage;
|
||||||
|
const parseRef = useRef(parse);
|
||||||
|
parseRef.current = parse;
|
||||||
|
|
||||||
|
const wsRef = useRef<WebSocket | null>(null);
|
||||||
|
const reconnectTimeoutRef = useRef<ReturnType<typeof setTimeout>>();
|
||||||
|
const retriesRef = useRef(0);
|
||||||
|
|
||||||
|
const connect = useCallback(() => {
|
||||||
|
if (!token) return;
|
||||||
|
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
params.set("token", token);
|
||||||
|
if (rate > 0) params.set("rate", rate.toString());
|
||||||
|
|
||||||
|
const url = `${getWsBaseUrl()}${path}?${params.toString()}`;
|
||||||
|
const ws = new WebSocket(url);
|
||||||
|
wsRef.current = ws;
|
||||||
|
|
||||||
|
ws.onopen = () => {
|
||||||
|
console.debug(`[ws] connected: ${path}`);
|
||||||
|
retriesRef.current = 0;
|
||||||
|
};
|
||||||
|
|
||||||
|
ws.onmessage = (event) => {
|
||||||
|
try {
|
||||||
|
const parsed = parseRef.current(event);
|
||||||
|
onMessageRef.current(parsed);
|
||||||
|
} catch (err) {
|
||||||
|
console.warn(`[ws] parse error on ${path}:`, err);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
ws.onerror = (err) => {
|
||||||
|
console.warn(`[ws] error on ${path}:`, err);
|
||||||
|
};
|
||||||
|
|
||||||
|
ws.onclose = (event) => {
|
||||||
|
console.debug(`[ws] closed: ${path} (code: ${event.code})`);
|
||||||
|
wsRef.current = null;
|
||||||
|
|
||||||
|
// Don't reconnect on clean close or auth rejection
|
||||||
|
if (event.code === 1000 || event.code === 4001 || event.code === 4003) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Exponential backoff: 1s, 2s, 4s, 8s, ... capped at 30s
|
||||||
|
const delay = Math.min(1000 * Math.pow(2, retriesRef.current), 30000);
|
||||||
|
retriesRef.current += 1;
|
||||||
|
console.debug(`[ws] reconnecting in ${delay}ms...`);
|
||||||
|
reconnectTimeoutRef.current = setTimeout(connect, delay);
|
||||||
|
};
|
||||||
|
}, [path, token, rate]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!enabled || !token) {
|
||||||
|
if (wsRef.current) {
|
||||||
|
wsRef.current.close(1000, "disabled");
|
||||||
|
wsRef.current = null;
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
connect();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
if (reconnectTimeoutRef.current) {
|
||||||
|
clearTimeout(reconnectTimeoutRef.current);
|
||||||
|
}
|
||||||
|
if (wsRef.current) {
|
||||||
|
wsRef.current.close(1000, "cleanup");
|
||||||
|
wsRef.current = null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, [connect, enabled, token]);
|
||||||
|
}
|
||||||
|
|
@ -6,7 +6,7 @@ import { useDeviceAPI, useGlobalState, useSnackbar } from "providers";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { useLocation, useParams } from "react-router-dom";
|
import { useLocation, useParams } from "react-router-dom";
|
||||||
import PageContainer from "./PageContainer";
|
import PageContainer from "./PageContainer";
|
||||||
import { useMobile } from "hooks";
|
import { useHTTP, useMobile } from "hooks";
|
||||||
import LoadingScreen from "app/LoadingScreen";
|
import LoadingScreen from "app/LoadingScreen";
|
||||||
import SmartBreadcrumb from "common/SmartBreadcrumb";
|
import SmartBreadcrumb from "common/SmartBreadcrumb";
|
||||||
import DeviceActions from "device/DeviceActions";
|
import DeviceActions from "device/DeviceActions";
|
||||||
|
|
@ -21,6 +21,7 @@ import { or } from "utils";
|
||||||
import ComponentDiagnostics from "component/ComponentDiagnostics";
|
import ComponentDiagnostics from "component/ComponentDiagnostics";
|
||||||
import DeviceWizard from "device/DeviceWizard";
|
import DeviceWizard from "device/DeviceWizard";
|
||||||
import DeviceScannedComponents from "device/autoDetect/DeviceScannedComponents";
|
import DeviceScannedComponents from "device/autoDetect/DeviceScannedComponents";
|
||||||
|
import { useWebSocket } from "hooks/useWebSocket";
|
||||||
|
|
||||||
export interface DevicePageData {
|
export interface DevicePageData {
|
||||||
device: Device;
|
device: Device;
|
||||||
|
|
@ -65,6 +66,8 @@ export default function DevicePage() {
|
||||||
|
|
||||||
// const [devicePageData, setDevicePageData] = useState(pond.GetDevicePageDataResponse.create())
|
// const [devicePageData, setDevicePageData] = useState(pond.GetDevicePageDataResponse.create())
|
||||||
|
|
||||||
|
const { token } = useHTTP();
|
||||||
|
|
||||||
const loadDevice = () => {
|
const loadDevice = () => {
|
||||||
if (loading) return
|
if (loading) return
|
||||||
if (state?.devicePageData) {
|
if (state?.devicePageData) {
|
||||||
|
|
@ -165,6 +168,65 @@ export default function DevicePage() {
|
||||||
.finally(() => setLoading(false));
|
.finally(() => setLoading(false));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- Real-time component updates ---
|
||||||
|
// Streams all component changes for this device.
|
||||||
|
// When the backend receives a new reading and updates a component,
|
||||||
|
// this fires and updates the component in state without a page refresh.
|
||||||
|
useWebSocket<{ key: string; component: Component } | null>({
|
||||||
|
path: `/live/devices/${deviceID}/components`,
|
||||||
|
parse: (e) => {
|
||||||
|
try {
|
||||||
|
const raw = JSON.parse(e.data);
|
||||||
|
const comp = Component.any(raw);
|
||||||
|
if (!comp?.settings) {
|
||||||
|
console.warn("[ws] received component without settings:", raw);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return { key: comp.key(), component: comp };
|
||||||
|
} catch (err) {
|
||||||
|
console.warn("[ws] failed to parse component:", err);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onMessage: (data) => {
|
||||||
|
if (!data) return;
|
||||||
|
const { key, component } = data;
|
||||||
|
setComponents((prev) => {
|
||||||
|
const updated = new Map(prev);
|
||||||
|
updated.set(key, component);
|
||||||
|
return updated;
|
||||||
|
});
|
||||||
|
},
|
||||||
|
token,
|
||||||
|
enabled: !!deviceID,
|
||||||
|
});
|
||||||
|
|
||||||
|
// --- Real-time device updates (optional) ---
|
||||||
|
// Updates device-level info (name, status, lastActive, etc.)
|
||||||
|
useWebSocket<Device | null>({
|
||||||
|
path: `/live/devices/${deviceID}`,
|
||||||
|
parse: (e) => {
|
||||||
|
try {
|
||||||
|
const raw = JSON.parse(e.data);
|
||||||
|
const dev = Device.any(raw);
|
||||||
|
if (!dev?.settings) {
|
||||||
|
console.warn("[ws] received device without settings:", raw);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return dev;
|
||||||
|
} catch (err) {
|
||||||
|
console.warn("[ws] failed to parse device:", err);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onMessage: (updatedDevice) => {
|
||||||
|
if (!updatedDevice) return;
|
||||||
|
setDevice(updatedDevice);
|
||||||
|
},
|
||||||
|
token,
|
||||||
|
enabled: !!deviceID,
|
||||||
|
});
|
||||||
|
|
||||||
const loadPortScan = () => {
|
const loadPortScan = () => {
|
||||||
deviceAPI.listFoundComponents(parseInt(deviceID))
|
deviceAPI.listFoundComponents(parseInt(deviceID))
|
||||||
.then(resp => {
|
.then(resp => {
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,7 @@ interface IHTTPContext {
|
||||||
) => Promise<AxiosResponse<T>>;
|
) => Promise<AxiosResponse<T>>;
|
||||||
del: <T>(url: string, spreadOptions?: AxiosRequestConfig) => Promise<AxiosResponse<T>>;
|
del: <T>(url: string, spreadOptions?: AxiosRequestConfig) => Promise<AxiosResponse<T>>;
|
||||||
options: (demo?: boolean) => AxiosRequestConfig;
|
options: (demo?: boolean) => AxiosRequestConfig;
|
||||||
|
token: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface Props extends PropsWithChildren<any>{
|
interface Props extends PropsWithChildren<any>{
|
||||||
|
|
@ -104,7 +105,8 @@ export default function HTTPProvider(props: Props) {
|
||||||
put,
|
put,
|
||||||
post,
|
post,
|
||||||
del,
|
del,
|
||||||
options: defaultOptions
|
options: defaultOptions,
|
||||||
|
token
|
||||||
}}>
|
}}>
|
||||||
{/* <BillingProvider> */}
|
{/* <BillingProvider> */}
|
||||||
<PondProvider>
|
<PondProvider>
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue