From cb305c1eb0ae17ddaf1822afb8f6840cecefd6a8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=9E=D0=BB=D0=B8=D0=BC=D0=BE=D0=B2=20=D0=A4=D0=B0=D1=80?= =?UTF-8?q?=D0=B8=D0=B4=D1=83=D0=BD?= Date: Tue, 10 Nov 2026 04:37:46 +0300 Subject: [PATCH 1/2] dashboard --- frontend/src/autoRefresh.ts | 39 +++++++ frontend/src/components/LiveIndicator.css | 42 ++++++++ frontend/src/components/LiveIndicator.tsx | 36 +++++++ frontend/src/liveFetchLogic.ts | 13 +++ frontend/src/pages/EquipmentCard.tsx | 16 ++- frontend/src/pages/EquipmentList.tsx | 9 +- frontend/src/useLiveFetch.ts | 121 ++++++++++++++++++++++ 7 files changed, 268 insertions(+), 8 deletions(-) create mode 100644 frontend/src/autoRefresh.ts create mode 100644 frontend/src/components/LiveIndicator.css create mode 100644 frontend/src/components/LiveIndicator.tsx create mode 100644 frontend/src/liveFetchLogic.ts create mode 100644 frontend/src/useLiveFetch.ts diff --git a/frontend/src/autoRefresh.ts b/frontend/src/autoRefresh.ts new file mode 100644 index 0000000..4244f95 --- /dev/null +++ b/frontend/src/autoRefresh.ts @@ -0,0 +1,39 @@ +import { useSyncExternalStore } from "react"; + +/** Интервал опроса, мс. Датчики пишут раз в ~30 с, поэтому 10 с — достаточно оперативно. 0 — выключить. */ +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 { + /* localStorage недоступен — работаем без сохранения */ + } + 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..408b5af --- /dev/null +++ b/frontend/src/components/LiveIndicator.tsx @@ -0,0 +1,36 @@ +import { setAutoRefresh, useAutoRefreshEnabled } from "../autoRefresh"; +import { formatTime } from "../liveFetchLogic"; +import "./LiveIndicator.css"; + +interface Props { + updatedAt: Date | null; + refreshing: boolean; + refreshError: string | null; +} + +/** «Обновлено 12:03:05» + переключатель автообновления. Ставится рядом с кнопкой «Обновить». */ +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..6ddf700 --- /dev/null +++ b/frontend/src/liveFetchLogic.ts @@ -0,0 +1,13 @@ +// Чистая логика автообновления (без React) — удобно проверять отдельно. + +/** Пауза до следующего опроса: при ошибках растёт вдвое, но не больше maxMs. */ +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..b1b4cdc --- /dev/null +++ b/frontend/src/useLiveFetch.ts @@ -0,0 +1,121 @@ +import { useCallback, useEffect, useRef, useState } from "react"; + +import { REFRESH_INTERVAL_MS, useAutoRefreshEnabled } from "./autoRefresh"; +import { isSame, nextDelay } from "./liveFetchLogic"; + +interface LiveOptions { + /** Интервал опроса, мс (по умолчанию VITE_REFRESH_INTERVAL_MS или 10 000). 0 — без автообновления. */ + intervalMs?: number; +} + +/** + * Замена useFetch с автообновлением: данные подтягиваются в фоне, пока открыта страница. + * Возвращает то же, что useFetch ({ data, error, loading, reload, setData }), + * плюс { refreshing, updatedAt, refreshError }. + * + * - `loading` — только первая загрузка (в фоне экран не мигает «Загрузка…»); + * - `reload` (кнопка «Обновить») обновляет в фоне: данные остаются на экране, `refreshing = true`; + * - если данные не изменились, объект `data` остаётся прежним — графики не перерисовываются; + * - вкладка скрыта — опрос на паузе, при возвращении обновляем сразу; + * - при сбое остаются последние данные (`refreshError`), опрос замедляется до 60 с; + * - `error` — только если данных ещё нет (первая загрузка не удалась); опрос при этом продолжается + * и сам восстановит экран, как только сервер ответит. + */ +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, + }; +} From 65dd2eb649e0151d76dda07e2babc870364af17a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=9E=D0=BB=D0=B8=D0=BC=D0=BE=D0=B2=20=D0=A4=D0=B0=D1=80?= =?UTF-8?q?=D0=B8=D0=B4=D1=83=D0=BD?= Date: Tue, 10 Nov 2026 04:44:52 +0300 Subject: [PATCH 2/2] update --- frontend/src/autoRefresh.ts | 3 --- frontend/src/components/LiveIndicator.tsx | 1 - frontend/src/liveFetchLogic.ts | 4 ---- frontend/src/useLiveFetch.ts | 18 +----------------- 4 files changed, 1 insertion(+), 25 deletions(-) diff --git a/frontend/src/autoRefresh.ts b/frontend/src/autoRefresh.ts index 4244f95..c9430b5 100644 --- a/frontend/src/autoRefresh.ts +++ b/frontend/src/autoRefresh.ts @@ -1,6 +1,5 @@ import { useSyncExternalStore } from "react"; -/** Интервал опроса, мс. Датчики пишут раз в ~30 с, поэтому 10 с — достаточно оперативно. 0 — выключить. */ export const REFRESH_INTERVAL_MS = Number(import.meta.env.VITE_REFRESH_INTERVAL_MS ?? 10_000); const KEY = "offtech.autoRefresh"; @@ -16,13 +15,11 @@ function read(): boolean { let enabled = read(); -/** Пользовательский переключатель «Автообновление» (общий для всех страниц, помнится между визитами). */ export function setAutoRefresh(value: boolean): void { enabled = value; try { localStorage.setItem(KEY, value ? "on" : "off"); } catch { - /* localStorage недоступен — работаем без сохранения */ } listeners.forEach((l) => l()); } diff --git a/frontend/src/components/LiveIndicator.tsx b/frontend/src/components/LiveIndicator.tsx index 408b5af..694c375 100644 --- a/frontend/src/components/LiveIndicator.tsx +++ b/frontend/src/components/LiveIndicator.tsx @@ -8,7 +8,6 @@ interface Props { refreshError: string | null; } -/** «Обновлено 12:03:05» + переключатель автообновления. Ставится рядом с кнопкой «Обновить». */ export function LiveIndicator({ updatedAt, refreshing, refreshError }: Props) { const auto = useAutoRefreshEnabled(); const time = updatedAt ? formatTime(updatedAt) : "—"; diff --git a/frontend/src/liveFetchLogic.ts b/frontend/src/liveFetchLogic.ts index 6ddf700..28a2bad 100644 --- a/frontend/src/liveFetchLogic.ts +++ b/frontend/src/liveFetchLogic.ts @@ -1,11 +1,7 @@ -// Чистая логика автообновления (без React) — удобно проверять отдельно. - -/** Пауза до следующего опроса: при ошибках растёт вдвое, но не больше maxMs. */ 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); } diff --git a/frontend/src/useLiveFetch.ts b/frontend/src/useLiveFetch.ts index b1b4cdc..460037b 100644 --- a/frontend/src/useLiveFetch.ts +++ b/frontend/src/useLiveFetch.ts @@ -4,23 +4,9 @@ import { REFRESH_INTERVAL_MS, useAutoRefreshEnabled } from "./autoRefresh"; import { isSame, nextDelay } from "./liveFetchLogic"; interface LiveOptions { - /** Интервал опроса, мс (по умолчанию VITE_REFRESH_INTERVAL_MS или 10 000). 0 — без автообновления. */ intervalMs?: number; } -/** - * Замена useFetch с автообновлением: данные подтягиваются в фоне, пока открыта страница. - * Возвращает то же, что useFetch ({ data, error, loading, reload, setData }), - * плюс { refreshing, updatedAt, refreshError }. - * - * - `loading` — только первая загрузка (в фоне экран не мигает «Загрузка…»); - * - `reload` (кнопка «Обновить») обновляет в фоне: данные остаются на экране, `refreshing = true`; - * - если данные не изменились, объект `data` остаётся прежним — графики не перерисовываются; - * - вкладка скрыта — опрос на паузе, при возвращении обновляем сразу; - * - при сбое остаются последние данные (`refreshError`), опрос замедляется до 60 с; - * - `error` — только если данных ещё нет (первая загрузка не удалась); опрос при этом продолжается - * и сам восстановит экран, как только сервер ответит. - */ export function useLiveFetch(fn: () => Promise, deps: unknown[], options: LiveOptions = {}) { const intervalMs = options.intervalMs ?? REFRESH_INTERVAL_MS; const auto = useAutoRefreshEnabled(); @@ -34,7 +20,7 @@ export function useLiveFetch(fn: () => Promise, deps: unknown[], options: const run = useCallback(fn, deps); const runRef = useRef(run); runRef.current = run; - const requestId = useRef(0); // номер последнего запроса: ответы от устаревших игнорируются + const requestId = useRef(0); const failures = useRef(0); const timer = useRef(undefined); const hasData = useRef(false); @@ -63,7 +49,6 @@ export function useLiveFetch(fn: () => Promise, deps: unknown[], options: } }, []); - // Первая загрузка и перезагрузка при смене зависимостей (например, другой двигатель) useEffect(() => { failures.current = 0; hasData.current = false; @@ -75,7 +60,6 @@ export function useLiveFetch(fn: () => Promise, deps: unknown[], options: }; }, [load, run]); - // Периодический опрос useEffect(() => { if (!auto || intervalMs <= 0) return; let stopped = false;