diff --git a/frontend/src/autoRefresh.ts b/frontend/src/autoRefresh.ts new file mode 100644 index 0000000..c9430b5 --- /dev/null +++ b/frontend/src/autoRefresh.ts @@ -0,0 +1,36 @@ +import { useSyncExternalStore } from "react"; + +export const REFRESH_INTERVAL_MS = Number(import.meta.env.VITE_REFRESH_INTERVAL_MS ?? 10_000); + +const KEY = "offtech.autoRefresh"; +const listeners = new Set<() => void>(); + +function read(): boolean { + try { + return localStorage.getItem(KEY) !== "off"; + } catch { + return true; + } +} + +let enabled = read(); + +export function setAutoRefresh(value: boolean): void { + enabled = value; + try { + localStorage.setItem(KEY, value ? "on" : "off"); + } catch { + } + listeners.forEach((l) => l()); +} + +export function useAutoRefreshEnabled(): boolean { + return useSyncExternalStore( + (cb) => { + listeners.add(cb); + return () => listeners.delete(cb); + }, + () => enabled, + () => true, + ); +} diff --git a/frontend/src/components/LiveIndicator.css b/frontend/src/components/LiveIndicator.css new file mode 100644 index 0000000..0890aca --- /dev/null +++ b/frontend/src/components/LiveIndicator.css @@ -0,0 +1,42 @@ +.live { + margin: 0 0 12px; + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 8px 16px; + font-size: 14px; + color: #6b7280; +} +.live-dot { + width: 9px; + height: 9px; + border-radius: 50%; + background: #9ca3af; + flex: none; +} +.live-dot.on { + background: #16a34a; +} +.live-dot.stale { + background: #d97706; +} +.live-dot.busy { + animation: live-pulse 0.9s ease-in-out infinite; +} +.live-toggle { + display: inline-flex; + align-items: center; + gap: 6px; + cursor: pointer; + user-select: none; +} +@keyframes live-pulse { + 50% { + opacity: 0.3; + } +} +@media (prefers-reduced-motion: reduce) { + .live-dot.busy { + animation: none; + } +} diff --git a/frontend/src/components/LiveIndicator.tsx b/frontend/src/components/LiveIndicator.tsx new file mode 100644 index 0000000..694c375 --- /dev/null +++ b/frontend/src/components/LiveIndicator.tsx @@ -0,0 +1,35 @@ +import { setAutoRefresh, useAutoRefreshEnabled } from "../autoRefresh"; +import { formatTime } from "../liveFetchLogic"; +import "./LiveIndicator.css"; + +interface Props { + updatedAt: Date | null; + refreshing: boolean; + refreshError: string | null; +} + +export function LiveIndicator({ updatedAt, refreshing, refreshError }: Props) { + const auto = useAutoRefreshEnabled(); + const time = updatedAt ? formatTime(updatedAt) : "—"; + + let text = `Обновлено ${time}`; + let dot = "on"; + if (refreshError) { + text = `Нет связи с сервером, показаны данные от ${time}`; + dot = "stale"; + } else if (!auto) { + text = `Автообновление выключено, данные от ${time}`; + dot = "off"; + } + + return ( +
+
+ ); +} diff --git a/frontend/src/liveFetchLogic.ts b/frontend/src/liveFetchLogic.ts new file mode 100644 index 0000000..28a2bad --- /dev/null +++ b/frontend/src/liveFetchLogic.ts @@ -0,0 +1,9 @@ +export function nextDelay(baseMs: number, failures: number, maxMs = 60_000): number { + return Math.min(baseMs * 2 ** failures, Math.max(maxMs, baseMs)); +} + +export function isSame(a: unknown, b: unknown): boolean { + return JSON.stringify(a) === JSON.stringify(b); +} + +export const formatTime = (d: Date): string => d.toLocaleTimeString("ru-RU"); diff --git a/frontend/src/pages/EquipmentCard.tsx b/frontend/src/pages/EquipmentCard.tsx index 508a419..ef5eaa3 100644 --- a/frontend/src/pages/EquipmentCard.tsx +++ b/frontend/src/pages/EquipmentCard.tsx @@ -15,17 +15,18 @@ import { import { api } from "../api"; import { AlertsTable } from "../components/AlertsTable"; +import { LiveIndicator } from "../components/LiveIndicator"; import { Empty, ErrorMessage, Loading } from "../components/State"; import { StatusBadge } from "../components/StatusBadge"; import { SENSORS, formatDate, formatRul } from "../format"; -import { useFetch } from "../useFetch"; +import { useLiveFetch } from "../useLiveFetch"; const RUL_WARNING = 75; const RUL_CRITICAL = 30; export function EquipmentCard() { const id = Number(useParams().id); - const equipment = useFetch(() => api.getEquipment(id), [id]); + const equipment = useLiveFetch(() => api.getEquipment(id), [id]); const [recalculating, setRecalculating] = useState(false); const [recalcError, setRecalcError] = useState(null); @@ -73,6 +74,11 @@ export function EquipmentCard() { + {recalcError && }
@@ -87,7 +93,7 @@ export function EquipmentCard() { function HistoryChart({ id }: { id: number }) { const [sensor, setSensor] = useState(SENSORS[0].code); - const { data, error } = useFetch(() => api.getHistory(id), [id]); + const { data, error } = useLiveFetch(() => api.getHistory(id), [id]); const points = (data ?? []).map((r) => ({ cycle: r.cycle, value: r.sensors[sensor] })); @@ -122,7 +128,7 @@ function HistoryChart({ id }: { id: number }) { } function ForecastChart({ id }: { id: number }) { - const { data, error } = useFetch(() => api.getForecast(id), [id]); + const { data, error } = useLiveFetch(() => api.getForecast(id), [id]); const points = (data?.points ?? []).map((p) => ({ ...p, range: [p.rul_low, p.rul_high] })); @@ -166,7 +172,7 @@ function PhysicsChart() { } function EquipmentAlerts({ id }: { id: number }) { - const { data, error } = useFetch(() => api.alertsByEquipment(id), [id]); + const { data, error } = useLiveFetch(() => api.alertsByEquipment(id), [id]); return (
diff --git a/frontend/src/pages/EquipmentList.tsx b/frontend/src/pages/EquipmentList.tsx index 82901a1..0cd97d5 100644 --- a/frontend/src/pages/EquipmentList.tsx +++ b/frontend/src/pages/EquipmentList.tsx @@ -1,24 +1,27 @@ import { useNavigate } from "react-router-dom"; import { api } from "../api"; +import { LiveIndicator } from "../components/LiveIndicator"; import { Empty, ErrorMessage, Loading } from "../components/State"; import { StatusBadge } from "../components/StatusBadge"; import { formatDate, formatRul } from "../format"; -import { useFetch } from "../useFetch"; +import { useLiveFetch } from "../useLiveFetch"; export function EquipmentList() { - const { data, error, loading, reload } = useFetch(api.listEquipment, []); + const { data, error, loading, reload, refreshing, updatedAt, refreshError } = useLiveFetch(api.listEquipment, []); const navigate = useNavigate(); return (

Оборудование

-
+ {data && } + {error && } {loading && !data && } {data && data.length === 0 && } diff --git a/frontend/src/useLiveFetch.ts b/frontend/src/useLiveFetch.ts new file mode 100644 index 0000000..460037b --- /dev/null +++ b/frontend/src/useLiveFetch.ts @@ -0,0 +1,105 @@ +import { useCallback, useEffect, useRef, useState } from "react"; + +import { REFRESH_INTERVAL_MS, useAutoRefreshEnabled } from "./autoRefresh"; +import { isSame, nextDelay } from "./liveFetchLogic"; + +interface LiveOptions { + intervalMs?: number; +} + +export function useLiveFetch(fn: () => Promise, deps: unknown[], options: LiveOptions = {}) { + const intervalMs = options.intervalMs ?? REFRESH_INTERVAL_MS; + const auto = useAutoRefreshEnabled(); + + const [data, setData] = useState(null); + const [error, setError] = useState(null); + const [loading, setLoading] = useState(true); + const [refreshing, setRefreshing] = useState(false); + const [updatedAt, setUpdatedAt] = useState(null); + + const run = useCallback(fn, deps); + const runRef = useRef(run); + runRef.current = run; + const requestId = useRef(0); + const failures = useRef(0); + const timer = useRef(undefined); + const hasData = useRef(false); + + const load = useCallback(async (background: boolean) => { + const id = ++requestId.current; + if (background) setRefreshing(true); + else setLoading(true); + try { + const next = await runRef.current(); + if (id !== requestId.current) return; + failures.current = 0; + hasData.current = true; + setData((prev) => (prev !== null && isSame(prev, next) ? prev : next)); + setError(null); + setUpdatedAt(new Date()); + } catch (e) { + if (id !== requestId.current) return; + failures.current += 1; + setError(e instanceof Error ? e.message : String(e)); + } finally { + if (id === requestId.current) { + setLoading(false); + setRefreshing(false); + } + } + }, []); + + useEffect(() => { + failures.current = 0; + hasData.current = false; + setData(null); + setError(null); + void load(false); + return () => { + requestId.current += 1; + }; + }, [load, run]); + + useEffect(() => { + if (!auto || intervalMs <= 0) return; + let stopped = false; + + const schedule = () => { + timer.current = window.setTimeout(async () => { + if (stopped) return; + if (!document.hidden) await load(true); + if (!stopped) schedule(); + }, nextDelay(intervalMs, failures.current)); + }; + const onVisible = () => { + if (document.hidden) return; + window.clearTimeout(timer.current); + void load(true).then(() => { + if (!stopped) schedule(); + }); + }; + + schedule(); + document.addEventListener("visibilitychange", onVisible); + return () => { + stopped = true; + window.clearTimeout(timer.current); + document.removeEventListener("visibilitychange", onVisible); + }; + }, [auto, intervalMs, load]); + + const reload = useCallback(() => { + void load(hasData.current); + }, [load]); + + return { + data, + error: data === null ? error : null, + loading, + reload, + setData, + refreshing, + updatedAt, + refreshError: data !== null ? error : null, + }; +}