192 lines
5.3 KiB
TypeScript
192 lines
5.3 KiB
TypeScript
import { useCallback, useEffect, useRef, useState } from "react";
|
|
|
|
import {
|
|
ApiError,
|
|
api,
|
|
openEventSocket,
|
|
type ConnectRequest,
|
|
type ConsoleState,
|
|
type EventSocketStatus,
|
|
type LiveRequest,
|
|
type ReplayRequest,
|
|
} from "./api";
|
|
|
|
export type BackendStatus = "checking" | "online" | "degraded" | "offline";
|
|
export type PendingAction = "scan" | "connect" | "live" | "replay" | "stop";
|
|
|
|
function messageFor(error: unknown): string {
|
|
if (error instanceof ApiError) {
|
|
return error.status
|
|
? `${error.message} (HTTP ${error.status})`
|
|
: error.message;
|
|
}
|
|
return "Запрос к локальному API K1 завершился ошибкой.";
|
|
}
|
|
|
|
function measuredLatency(state: ConsoleState | null): number | null {
|
|
const metrics = state?.metrics;
|
|
if (!metrics) return null;
|
|
|
|
const reported = metrics.pipeline_ms ?? metrics.end_to_end_ms;
|
|
if (typeof reported === "number" && Number.isFinite(reported)) return reported;
|
|
|
|
const segments = [
|
|
metrics.mqtt_to_decode_ms,
|
|
metrics.decode_ms,
|
|
metrics.publish_ms,
|
|
].filter((value): value is number => typeof value === "number" && Number.isFinite(value));
|
|
|
|
return segments.length ? segments.reduce((total, value) => total + value, 0) : null;
|
|
}
|
|
|
|
export function useK1Console() {
|
|
const [state, setState] = useState<ConsoleState | null>(null);
|
|
const [backendStatus, setBackendStatus] = useState<BackendStatus>("checking");
|
|
const [eventStatus, setEventStatus] = useState<EventSocketStatus>("connecting");
|
|
const [pendingAction, setPendingAction] = useState<PendingAction | null>(null);
|
|
const [error, setError] = useState<string | null>(null);
|
|
const [latencyHistory, setLatencyHistory] = useState<number[]>([]);
|
|
const mounted = useRef(true);
|
|
|
|
const acceptState = useCallback((nextState: ConsoleState) => {
|
|
setState(nextState);
|
|
setBackendStatus("online");
|
|
}, []);
|
|
|
|
const refresh = useCallback(async (reportErrors = true) => {
|
|
const [healthResult, stateResult] = await Promise.allSettled([
|
|
api.getHealth(),
|
|
api.getState(),
|
|
]);
|
|
|
|
if (!mounted.current) return;
|
|
|
|
if (stateResult.status === "fulfilled") {
|
|
acceptState(stateResult.value);
|
|
if (reportErrors) setError(null);
|
|
}
|
|
|
|
if (healthResult.status === "fulfilled") {
|
|
const health = healthResult.value;
|
|
const healthy = health.ok !== false && health.status !== "error";
|
|
setBackendStatus(healthy && stateResult.status === "fulfilled" ? "online" : "degraded");
|
|
} else if (stateResult.status === "rejected") {
|
|
setBackendStatus("offline");
|
|
}
|
|
|
|
if (stateResult.status === "rejected" && reportErrors) {
|
|
setError(messageFor(stateResult.reason));
|
|
}
|
|
}, [acceptState]);
|
|
|
|
const run = useCallback(
|
|
async (action: PendingAction, operation: () => Promise<ConsoleState>) => {
|
|
setPendingAction(action);
|
|
setError(null);
|
|
|
|
try {
|
|
const nextState = await operation();
|
|
if (mounted.current) acceptState(nextState);
|
|
return true;
|
|
} catch (operationError) {
|
|
if (mounted.current) {
|
|
setError(messageFor(operationError));
|
|
if (operationError instanceof ApiError && operationError.status === 0) {
|
|
setBackendStatus("offline");
|
|
}
|
|
}
|
|
return false;
|
|
} finally {
|
|
if (mounted.current) setPendingAction(null);
|
|
}
|
|
},
|
|
[acceptState],
|
|
);
|
|
|
|
const scan = useCallback(
|
|
() => run("scan", () => api.scanBle({ duration_seconds: 6 })),
|
|
[run],
|
|
);
|
|
|
|
const connect = useCallback(
|
|
(request: ConnectRequest) => run("connect", () => api.connect(request)),
|
|
[run],
|
|
);
|
|
|
|
const startLive = useCallback(
|
|
(request: LiveRequest = {}) => run("live", () => api.startLive(request)),
|
|
[run],
|
|
);
|
|
|
|
const startReplay = useCallback(
|
|
(request: ReplayRequest) => run("replay", () => api.startReplay(request)),
|
|
[run],
|
|
);
|
|
|
|
const stop = useCallback(
|
|
() => run("stop", () => api.stopSession()),
|
|
[run],
|
|
);
|
|
|
|
useEffect(() => {
|
|
mounted.current = true;
|
|
void refresh(true);
|
|
const poll = window.setInterval(() => void refresh(false), 4_000);
|
|
|
|
return () => {
|
|
mounted.current = false;
|
|
window.clearInterval(poll);
|
|
};
|
|
}, [refresh]);
|
|
|
|
useEffect(() => {
|
|
let dispose: (() => void) | undefined;
|
|
let retry: number | undefined;
|
|
let cancelled = false;
|
|
|
|
const connectEvents = () => {
|
|
if (cancelled) return;
|
|
dispose = openEventSocket(acceptState, (status) => {
|
|
if (cancelled) return;
|
|
setEventStatus(status);
|
|
if ((status === "closed" || status === "error") && retry === undefined) {
|
|
retry = window.setTimeout(() => {
|
|
retry = undefined;
|
|
connectEvents();
|
|
}, 3_000);
|
|
}
|
|
});
|
|
};
|
|
|
|
connectEvents();
|
|
|
|
return () => {
|
|
cancelled = true;
|
|
if (retry !== undefined) window.clearTimeout(retry);
|
|
dispose?.();
|
|
};
|
|
}, [acceptState]);
|
|
|
|
useEffect(() => {
|
|
const latency = measuredLatency(state);
|
|
if (latency === null) return;
|
|
setLatencyHistory((values) => [...values.slice(-23), latency]);
|
|
}, [state]);
|
|
|
|
return {
|
|
state,
|
|
backendStatus,
|
|
eventStatus,
|
|
pendingAction,
|
|
error,
|
|
latencyHistory,
|
|
refresh: () => refresh(true),
|
|
clearError: () => setError(null),
|
|
scan,
|
|
connect,
|
|
startLive,
|
|
startReplay,
|
|
stop,
|
|
};
|
|
}
|