Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 36 additions & 0 deletions frontend/src/autoRefresh.ts
Original file line number Diff line number Diff line change
@@ -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,
);
}
42 changes: 42 additions & 0 deletions frontend/src/components/LiveIndicator.css
Original file line number Diff line number Diff line change
@@ -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;
}
}
35 changes: 35 additions & 0 deletions frontend/src/components/LiveIndicator.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div className="live" role="status" aria-live="polite">
<span className={`live-dot ${dot}${refreshing ? " busy" : ""}`} aria-hidden="true" />
<span>{text}</span>
<label className="live-toggle">
<input type="checkbox" checked={auto} onChange={(e) => setAutoRefresh(e.target.checked)} />
Автообновление
</label>
</div>
);
}
9 changes: 9 additions & 0 deletions frontend/src/liveFetchLogic.ts
Original file line number Diff line number Diff line change
@@ -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");
16 changes: 11 additions & 5 deletions frontend/src/pages/EquipmentCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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<string | null>(null);
Expand Down Expand Up @@ -73,6 +74,11 @@ export function EquipmentCard() {
</button>
</div>
</div>
<LiveIndicator
updatedAt={equipment.updatedAt}
refreshing={equipment.refreshing}
refreshError={equipment.refreshError}
/>
{recalcError && <ErrorMessage message={recalcError} />}

<div className="grid">
Expand All @@ -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] }));

Expand Down Expand Up @@ -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] }));

Expand Down Expand Up @@ -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 (
<div className="card">
Expand Down
9 changes: 6 additions & 3 deletions frontend/src/pages/EquipmentList.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<section>
<div className="page-head">
<h1>Оборудование</h1>
<button className="btn btn-secondary" onClick={reload} disabled={loading}>
<button className="btn btn-secondary" onClick={reload} disabled={loading || refreshing}>
Обновить
</button>
</div>

{data && <LiveIndicator updatedAt={updatedAt} refreshing={refreshing} refreshError={refreshError} />}

{error && <ErrorMessage message={error} />}
{loading && !data && <Loading />}
{data && data.length === 0 && <Empty text="Оборудования пока нет — запустите симулятор." />}
Expand Down
105 changes: 105 additions & 0 deletions frontend/src/useLiveFetch.ts
Original file line number Diff line number Diff line change
@@ -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<T>(fn: () => Promise<T>, deps: unknown[], options: LiveOptions = {}) {
const intervalMs = options.intervalMs ?? REFRESH_INTERVAL_MS;
const auto = useAutoRefreshEnabled();

const [data, setData] = useState<T | null>(null);
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(true);
const [refreshing, setRefreshing] = useState(false);
const [updatedAt, setUpdatedAt] = useState<Date | null>(null);

const run = useCallback(fn, deps);
const runRef = useRef(run);
runRef.current = run;
const requestId = useRef(0);
const failures = useRef(0);
const timer = useRef<number | undefined>(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,
};
}
Loading