second version
This commit is contained in:
520
frontend/src/hooks/useDashboardData.ts
Normal file
520
frontend/src/hooks/useDashboardData.ts
Normal file
@@ -0,0 +1,520 @@
|
||||
import axios from 'axios'
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
|
||||
import {
|
||||
getCurrentPlan,
|
||||
getSiteForecastPv,
|
||||
getSitePrices,
|
||||
type SiteEffectivePriceRowDto,
|
||||
} from '../api/backend'
|
||||
import { getJson } from '../api/postgrest'
|
||||
import {
|
||||
currentSlotIndexInWindow,
|
||||
SLOT_COUNT_BACK,
|
||||
SLOT_MS,
|
||||
TOTAL_SLOTS,
|
||||
floorSlotUtcMs,
|
||||
} from '../components/charts/chartConstants'
|
||||
import { pragueAddCalendarDays, pragueCalendarDay } from '../lib/pragueDate'
|
||||
import type { ForecastDayTotal, LiveMetrics, NegPriceItem, SlotData } from '../types/dashboard'
|
||||
import type {
|
||||
AuditTodayHourlyRow,
|
||||
HeatPumpLatestRow,
|
||||
ModeLogRecentRow,
|
||||
SiteStatusRow,
|
||||
TelemetryHourly7dRow,
|
||||
} from '../types/ems'
|
||||
import type { PlanningIntervalDto } from '../types/plan'
|
||||
|
||||
const POLL_FULL_MS = 30_000
|
||||
const POLL_LIVE_MS = 5_000
|
||||
|
||||
function parseNum(v: string | number | null | undefined): number | null {
|
||||
if (v == null) return null
|
||||
if (typeof v === 'number' && !Number.isNaN(v)) return v
|
||||
const n = Number(v)
|
||||
return Number.isFinite(n) ? n : null
|
||||
}
|
||||
|
||||
function numFromWs(v: unknown): number | null {
|
||||
if (v == null) return null
|
||||
const n = typeof v === 'number' ? v : Number(v)
|
||||
return Number.isFinite(n) ? n : null
|
||||
}
|
||||
|
||||
function buildLiveMetrics(
|
||||
status: SiteStatusRow | null,
|
||||
_hp: HeatPumpLatestRow | null,
|
||||
): LiveMetrics | null {
|
||||
if (status == null) return null
|
||||
return {
|
||||
pv_w: parseNum(status.pv_power_w),
|
||||
load_w: parseNum(status.load_power_w),
|
||||
grid_w: parseNum(status.grid_power_w),
|
||||
bat_soc: parseNum(status.battery_soc_percent),
|
||||
bat_w: parseNum(status.battery_power_w),
|
||||
}
|
||||
}
|
||||
|
||||
function hourFloorUtcMs(ms: number): number {
|
||||
const d = new Date(ms)
|
||||
d.setUTCMinutes(0, 0, 0)
|
||||
d.setUTCSeconds(0, 0)
|
||||
return d.getTime()
|
||||
}
|
||||
|
||||
/** Klíč hodiny v Europe/Prague (pro shodu s vw_audit_today_hourly.hour_local). */
|
||||
function pragueHourKey(ms: number): string {
|
||||
return new Intl.DateTimeFormat('sv-SE', {
|
||||
timeZone: 'Europe/Prague',
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
hour12: false,
|
||||
}).format(new Date(ms))
|
||||
}
|
||||
|
||||
function slotTimeKey(ms: number): string {
|
||||
return String(floorSlotUtcMs(ms))
|
||||
}
|
||||
|
||||
function modeAt(logs: ModeLogRecentRow[], tMs: number): string | null {
|
||||
let best: ModeLogRecentRow | null = null
|
||||
let bestA = -Infinity
|
||||
for (const l of logs) {
|
||||
const a = new Date(l.activated_at).getTime()
|
||||
const d = l.deactivated_at ? new Date(l.deactivated_at).getTime() : Number.POSITIVE_INFINITY
|
||||
if (a <= tMs && tMs < d && a >= bestA) {
|
||||
bestA = a
|
||||
best = l
|
||||
}
|
||||
}
|
||||
return best?.mode_code ?? null
|
||||
}
|
||||
|
||||
function emptySlot(iso: string): SlotData {
|
||||
return {
|
||||
interval_start: iso,
|
||||
pv_power_w: null,
|
||||
battery_power_w: null,
|
||||
battery_setpoint_w: null,
|
||||
grid_power_w: null,
|
||||
grid_setpoint_w: null,
|
||||
load_power_w: null,
|
||||
gen_port_power_w: null,
|
||||
pv_a_forecast_w: null,
|
||||
pv_b_forecast_w: null,
|
||||
load_baseline_w: null,
|
||||
ev1_setpoint_w: null,
|
||||
ev2_setpoint_w: null,
|
||||
heat_pump_setpoint_w: null,
|
||||
heat_pump_enabled: null,
|
||||
battery_soc_target_pct: null,
|
||||
buy_price: null,
|
||||
sell_price: null,
|
||||
regime_code: null,
|
||||
regime_is_planned: false,
|
||||
soc_actual_pct: null,
|
||||
soc_plan_pct: null,
|
||||
tuv_actual_c: null,
|
||||
tuv_plan_c: null,
|
||||
}
|
||||
}
|
||||
|
||||
function mergeInterval(s: SlotData, p: PlanningIntervalDto): void {
|
||||
s.battery_setpoint_w = p.battery_setpoint_w ?? s.battery_setpoint_w
|
||||
s.grid_setpoint_w = p.grid_setpoint_w ?? s.grid_setpoint_w
|
||||
s.ev1_setpoint_w = p.ev1_setpoint_w ?? s.ev1_setpoint_w
|
||||
s.ev2_setpoint_w = p.ev2_setpoint_w ?? s.ev2_setpoint_w
|
||||
if (s.ev1_setpoint_w == null && s.ev2_setpoint_w == null && p.ev_charge_power_w != null) {
|
||||
s.ev1_setpoint_w = p.ev_charge_power_w
|
||||
}
|
||||
if (p.heat_pump_enabled === true) {
|
||||
s.heat_pump_enabled = true
|
||||
} else if (p.heat_pump_enabled === false) {
|
||||
s.heat_pump_enabled = false
|
||||
}
|
||||
if (p.heat_pump_setpoint_w != null) {
|
||||
s.heat_pump_setpoint_w = p.heat_pump_setpoint_w
|
||||
if (s.heat_pump_enabled == null) {
|
||||
s.heat_pump_enabled = p.heat_pump_setpoint_w > 0
|
||||
}
|
||||
} else if (p.heat_pump_enabled === false) {
|
||||
s.heat_pump_setpoint_w = 0
|
||||
s.heat_pump_enabled = false
|
||||
}
|
||||
s.load_baseline_w = p.load_baseline_w ?? s.load_baseline_w
|
||||
s.buy_price = parseNum(p.effective_buy_price) ?? s.buy_price
|
||||
s.sell_price = parseNum(p.effective_sell_price) ?? s.sell_price
|
||||
const tgtSoc = parseNum(p.battery_soc_target_pct)
|
||||
if (tgtSoc != null) {
|
||||
s.battery_soc_target_pct = tgtSoc
|
||||
s.soc_plan_pct = tgtSoc
|
||||
}
|
||||
const pva = p.pv_forecast_total_w != null ? Math.round(Number(p.pv_forecast_total_w) * 0.6) : null
|
||||
const pvb = p.pv_forecast_total_w != null ? Math.round(Number(p.pv_forecast_total_w) * 0.4) : null
|
||||
if (s.pv_a_forecast_w == null && pva != null) s.pv_a_forecast_w = pva
|
||||
if (s.pv_b_forecast_w == null && pvb != null) s.pv_b_forecast_w = pvb
|
||||
if (p.heat_pump_setpoint_w != null && p.heat_pump_setpoint_w > 0) {
|
||||
s.tuv_plan_c = 52
|
||||
}
|
||||
}
|
||||
|
||||
export function useDashboardData(siteId: number | null) {
|
||||
const [slots, setSlots] = useState<SlotData[]>([])
|
||||
const [liveMetrics, setLiveMetrics] = useState<LiveMetrics | null>(null)
|
||||
const [forecastWeek, setForecastWeek] = useState<ForecastDayTotal[]>([])
|
||||
const [negPrices, setNegPrices] = useState<NegPriceItem[]>([])
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [ready, setReady] = useState(false)
|
||||
|
||||
const wsRef = useRef<WebSocket | null>(null)
|
||||
const siteIdRef = useRef(siteId)
|
||||
siteIdRef.current = siteId
|
||||
|
||||
const load = useCallback(async () => {
|
||||
if (siteId == null) {
|
||||
setSlots([])
|
||||
setForecastWeek([])
|
||||
setNegPrices([])
|
||||
setLiveMetrics(null)
|
||||
setError(null)
|
||||
setReady(true)
|
||||
return
|
||||
}
|
||||
|
||||
const windowStart = floorSlotUtcMs(Date.now()) - SLOT_COUNT_BACK * SLOT_MS
|
||||
const nIdx = currentSlotIndexInWindow(windowStart)
|
||||
|
||||
try {
|
||||
const todayPrague = pragueCalendarDay()
|
||||
const dates = new Set<string>()
|
||||
for (let i = 0; i < TOTAL_SLOTS; i++) {
|
||||
const ms = windowStart + i * SLOT_MS
|
||||
dates.add(pragueCalendarDay(new Date(ms)))
|
||||
}
|
||||
|
||||
const [
|
||||
planMaybe,
|
||||
statusArr,
|
||||
hourly7d,
|
||||
auditHourly,
|
||||
modeLog,
|
||||
hpArr,
|
||||
...priceLists
|
||||
] = await Promise.all([
|
||||
getCurrentPlan(siteId).catch((e: unknown) => {
|
||||
if (axios.isAxiosError(e) && e.response?.status === 404) {
|
||||
return { run: null, intervals: [] as PlanningIntervalDto[], summary: null }
|
||||
}
|
||||
throw e
|
||||
}),
|
||||
getJson<SiteStatusRow[]>('/vw_site_status', { site_id: `eq.${siteId}` }),
|
||||
getJson<TelemetryHourly7dRow[]>('/vw_telemetry_hourly_7d', {
|
||||
site_id: `eq.${siteId}`,
|
||||
order: 'hour.asc',
|
||||
limit: '500',
|
||||
}),
|
||||
getJson<AuditTodayHourlyRow[]>('/vw_audit_today_hourly', {
|
||||
site_id: `eq.${siteId}`,
|
||||
order: 'hour_local.asc',
|
||||
}),
|
||||
getJson<ModeLogRecentRow[]>('/vw_mode_log_recent', {
|
||||
site_id: `eq.${siteId}`,
|
||||
order: 'activated_at.asc',
|
||||
limit: '200',
|
||||
}),
|
||||
getJson<HeatPumpLatestRow[]>('/vw_latest_heat_pump', { site_id: `eq.${siteId}` }),
|
||||
...[...dates].map((d) => getSitePrices(siteId, d)),
|
||||
])
|
||||
|
||||
const status = Array.isArray(statusArr) && statusArr[0] ? statusArr[0]! : null
|
||||
const hp = Array.isArray(hpArr) && hpArr[0] ? hpArr[0]! : null
|
||||
setLiveMetrics(buildLiveMetrics(status, hp))
|
||||
|
||||
const plan = planMaybe as { intervals: PlanningIntervalDto[] }
|
||||
const planBySlot = new Map<string, PlanningIntervalDto>()
|
||||
for (const iv of plan.intervals) {
|
||||
planBySlot.set(slotTimeKey(new Date(iv.interval_start).getTime()), iv)
|
||||
}
|
||||
|
||||
const priceBySlot = new Map<string, { buy: number | null; sell: number | null }>()
|
||||
const flatPrices: SiteEffectivePriceRowDto[] = priceLists.flat() as SiteEffectivePriceRowDto[]
|
||||
for (const r of flatPrices) {
|
||||
const k = slotTimeKey(new Date(r.interval_start).getTime())
|
||||
priceBySlot.set(k, {
|
||||
buy: parseNum(r.effective_buy_price_czk_kwh),
|
||||
sell: parseNum(r.effective_sell_price_czk_kwh),
|
||||
})
|
||||
}
|
||||
|
||||
const forecastBySlot = new Map<string, { a: number; b: number }>()
|
||||
const forecastDays: ForecastDayTotal[] = []
|
||||
const today = todayPrague
|
||||
const forecastResults = await Promise.all(
|
||||
Array.from({ length: 7 }, (_, d) => {
|
||||
const ymd = pragueAddCalendarDays(today, d)
|
||||
return getSiteForecastPv(siteId, ymd)
|
||||
.then((fc) => ({ ymd, fc }))
|
||||
.catch(() => ({ ymd, fc: null as Awaited<ReturnType<typeof getSiteForecastPv>> | null }))
|
||||
}),
|
||||
)
|
||||
for (const { ymd, fc } of forecastResults) {
|
||||
if (!fc) {
|
||||
forecastDays.push({ date: ymd, label: ymd, kwh: 0 })
|
||||
continue
|
||||
}
|
||||
let kwh = 0
|
||||
const byStart = new Map<string, { a: number; b: number }>()
|
||||
for (const x of fc.pv_a ?? []) {
|
||||
const t = new Date(x.interval_start).getTime()
|
||||
const p = Number(x.power_w ?? 0)
|
||||
const cur = byStart.get(slotTimeKey(t)) ?? { a: 0, b: 0 }
|
||||
cur.a += p
|
||||
byStart.set(slotTimeKey(t), cur)
|
||||
}
|
||||
for (const x of fc.pv_b ?? []) {
|
||||
const t = new Date(x.interval_start).getTime()
|
||||
const p = Number(x.power_w ?? 0)
|
||||
const cur = byStart.get(slotTimeKey(t)) ?? { a: 0, b: 0 }
|
||||
cur.b += p
|
||||
byStart.set(slotTimeKey(t), cur)
|
||||
}
|
||||
for (const [, v] of byStart) {
|
||||
kwh += ((v.a + v.b) * 0.25) / 1000
|
||||
}
|
||||
for (const [k, v] of byStart) {
|
||||
forecastBySlot.set(k, v)
|
||||
}
|
||||
const label = new Date(ymd + 'T12:00:00Z').toLocaleDateString('cs-CZ', {
|
||||
weekday: 'short',
|
||||
day: 'numeric',
|
||||
month: 'numeric',
|
||||
timeZone: 'Europe/Prague',
|
||||
})
|
||||
forecastDays.push({ date: ymd, label, kwh: Math.round(kwh * 10) / 10 })
|
||||
}
|
||||
setForecastWeek(forecastDays)
|
||||
|
||||
const hourlyMap = new Map<number, TelemetryHourly7dRow>()
|
||||
if (Array.isArray(hourly7d)) {
|
||||
for (const r of hourly7d) {
|
||||
hourlyMap.set(new Date(r.hour).getTime(), r)
|
||||
}
|
||||
}
|
||||
|
||||
const auditMap = new Map<string, AuditTodayHourlyRow>()
|
||||
if (Array.isArray(auditHourly)) {
|
||||
for (const r of auditHourly) {
|
||||
auditMap.set(pragueHourKey(new Date(r.hour_local).getTime()), r)
|
||||
}
|
||||
}
|
||||
|
||||
const logs = Array.isArray(modeLog) ? modeLog : []
|
||||
const activeMode = status?.active_mode ?? 'AUTO'
|
||||
|
||||
const built: SlotData[] = []
|
||||
for (let i = 0; i < TOTAL_SLOTS; i++) {
|
||||
const startMs = windowStart + i * SLOT_MS
|
||||
const iso = new Date(startMs).toISOString()
|
||||
const base = emptySlot(iso)
|
||||
const k = slotTimeKey(startMs)
|
||||
|
||||
const tel = hourlyMap.get(hourFloorUtcMs(startMs))
|
||||
if (tel) {
|
||||
base.pv_power_w = tel.avg_pv_w ?? base.pv_power_w
|
||||
base.battery_power_w = tel.avg_battery_w ?? base.battery_power_w
|
||||
base.grid_power_w = tel.avg_grid_w ?? base.grid_power_w
|
||||
base.load_power_w = tel.avg_load_w ?? base.load_power_w
|
||||
base.soc_actual_pct = parseNum(tel.last_soc_pct) ?? base.soc_actual_pct
|
||||
}
|
||||
|
||||
const aud = auditMap.get(pragueHourKey(startMs))
|
||||
if (aud) {
|
||||
if (base.pv_power_w == null && aud.avg_pv_kw != null) {
|
||||
base.pv_power_w = Math.round((parseNum(aud.avg_pv_kw) ?? 0) * 1000)
|
||||
}
|
||||
if (base.load_power_w == null && aud.avg_load_kw != null) {
|
||||
base.load_power_w = Math.round((parseNum(aud.avg_load_kw) ?? 0) * 1000)
|
||||
}
|
||||
if (base.battery_power_w == null && aud.avg_battery_kw != null) {
|
||||
base.battery_power_w = Math.round((parseNum(aud.avg_battery_kw) ?? 0) * 1000)
|
||||
}
|
||||
if (base.grid_power_w == null && aud.avg_grid_kw != null) {
|
||||
base.grid_power_w = Math.round((parseNum(aud.avg_grid_kw) ?? 0) * 1000)
|
||||
}
|
||||
if (base.soc_actual_pct == null) {
|
||||
base.soc_actual_pct = parseNum(aud.avg_soc_pct) ?? base.soc_actual_pct
|
||||
}
|
||||
}
|
||||
|
||||
const pr = priceBySlot.get(k)
|
||||
if (pr) {
|
||||
base.buy_price = pr.buy
|
||||
base.sell_price = pr.sell
|
||||
}
|
||||
|
||||
const fc = forecastBySlot.get(k)
|
||||
if (fc) {
|
||||
base.pv_a_forecast_w = fc.a
|
||||
base.pv_b_forecast_w = fc.b
|
||||
}
|
||||
|
||||
const pi = planBySlot.get(k)
|
||||
if (pi) mergeInterval(base, pi)
|
||||
|
||||
const past = i <= nIdx
|
||||
const regimePast = modeAt(logs, startMs) ?? activeMode
|
||||
built.push({
|
||||
...base,
|
||||
regime_code: past ? regimePast : activeMode,
|
||||
regime_is_planned: !past,
|
||||
})
|
||||
}
|
||||
|
||||
const neg: NegPriceItem[] = []
|
||||
const nowMs = Date.now()
|
||||
for (const r of flatPrices) {
|
||||
const t = new Date(r.interval_start).getTime()
|
||||
if (t < nowMs) continue
|
||||
const buy = parseNum(r.effective_buy_price_czk_kwh)
|
||||
const sell = parseNum(r.effective_sell_price_czk_kwh)
|
||||
if ((buy != null && buy < 0) || (sell != null && sell < 0)) {
|
||||
neg.push({
|
||||
interval_start: r.interval_start,
|
||||
buy,
|
||||
sell,
|
||||
})
|
||||
}
|
||||
}
|
||||
neg.sort((a, b) => new Date(a.interval_start).getTime() - new Date(b.interval_start).getTime())
|
||||
setNegPrices(neg.slice(0, 32))
|
||||
|
||||
setSlots(built)
|
||||
setError(null)
|
||||
} catch (e) {
|
||||
setError(e instanceof Error ? e.message : 'Chyba načítání dashboardu')
|
||||
setSlots([])
|
||||
} finally {
|
||||
setReady(true)
|
||||
}
|
||||
}, [siteId])
|
||||
|
||||
useEffect(() => {
|
||||
void load()
|
||||
const id = window.setInterval(() => void load(), POLL_FULL_MS)
|
||||
return () => window.clearInterval(id)
|
||||
}, [load])
|
||||
|
||||
useEffect(() => {
|
||||
if (siteId == null) {
|
||||
setLiveMetrics(null)
|
||||
return
|
||||
}
|
||||
const fetchLive = async () => {
|
||||
try {
|
||||
const [statusArr, hpArr] = await Promise.all([
|
||||
getJson<SiteStatusRow[]>('/vw_site_status', { site_id: `eq.${siteId}` }),
|
||||
getJson<HeatPumpLatestRow[]>('/vw_latest_heat_pump', { site_id: `eq.${siteId}` }),
|
||||
])
|
||||
const status = Array.isArray(statusArr) && statusArr[0] ? statusArr[0]! : null
|
||||
const hp = Array.isArray(hpArr) && hpArr[0] ? hpArr[0]! : null
|
||||
setLiveMetrics(buildLiveMetrics(status, hp))
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
void fetchLive()
|
||||
const id = window.setInterval(() => void fetchLive(), POLL_LIVE_MS)
|
||||
return () => window.clearInterval(id)
|
||||
}, [siteId])
|
||||
|
||||
useEffect(() => {
|
||||
if (siteId == null) {
|
||||
wsRef.current?.close()
|
||||
wsRef.current = null
|
||||
return
|
||||
}
|
||||
const proto = window.location.protocol === 'https:' ? 'wss' : 'ws'
|
||||
const ws = new WebSocket(`${proto}://${window.location.host}/ws/telemetry`)
|
||||
wsRef.current = ws
|
||||
ws.onmessage = (ev) => {
|
||||
try {
|
||||
const msg = JSON.parse(ev.data as string) as Record<string, unknown>
|
||||
if (msg.type !== 'telemetry' || Number(msg.site_id) !== siteIdRef.current) return
|
||||
|
||||
const pv = numFromWs(msg.pv_power_w)
|
||||
const batW = numFromWs(msg.battery_power_w)
|
||||
const gridW = numFromWs(msg.grid_power_w)
|
||||
const loadW = numFromWs(msg.load_power_w)
|
||||
const genW = numFromWs(msg.gen_port_power_w)
|
||||
const soc = numFromWs(msg.battery_soc_pct)
|
||||
|
||||
setLiveMetrics((prev) => ({
|
||||
pv_w: pv ?? prev?.pv_w ?? null,
|
||||
load_w: loadW ?? prev?.load_w ?? null,
|
||||
grid_w: gridW ?? prev?.grid_w ?? null,
|
||||
bat_soc: soc ?? prev?.bat_soc ?? null,
|
||||
bat_w: batW ?? prev?.bat_w ?? null,
|
||||
}))
|
||||
|
||||
const tsStr = typeof msg.ts === 'string' ? msg.ts : null
|
||||
if (!tsStr) return
|
||||
const tsMs = new Date(tsStr).getTime()
|
||||
if (!Number.isFinite(tsMs)) return
|
||||
|
||||
setSlots((prev) => {
|
||||
const idx = prev.findIndex((s) => {
|
||||
const start = new Date(s.interval_start).getTime()
|
||||
return start <= tsMs && tsMs < start + SLOT_MS
|
||||
})
|
||||
if (idx === -1) return prev
|
||||
const cur = prev[idx]!
|
||||
const updated = [...prev]
|
||||
updated[idx] = {
|
||||
...cur,
|
||||
pv_power_w: pv ?? cur.pv_power_w,
|
||||
battery_power_w: batW ?? cur.battery_power_w,
|
||||
grid_power_w: gridW ?? cur.grid_power_w,
|
||||
load_power_w: loadW ?? cur.load_power_w,
|
||||
gen_port_power_w: genW ?? cur.gen_port_power_w,
|
||||
soc_actual_pct: soc ?? cur.soc_actual_pct,
|
||||
}
|
||||
return updated
|
||||
})
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
ws.onclose = () => {
|
||||
if (wsRef.current === ws) wsRef.current = null
|
||||
}
|
||||
return () => {
|
||||
ws.close()
|
||||
if (wsRef.current === ws) wsRef.current = null
|
||||
}
|
||||
}, [siteId])
|
||||
|
||||
const liveNowIndex = useMemo(
|
||||
() => currentSlotIndexInWindow(floorSlotUtcMs(Date.now()) - SLOT_COUNT_BACK * SLOT_MS),
|
||||
[slots],
|
||||
)
|
||||
|
||||
const buyNow =
|
||||
slots.length && liveNowIndex >= 0 && liveNowIndex < slots.length
|
||||
? slots[liveNowIndex]!.buy_price
|
||||
: null
|
||||
|
||||
return {
|
||||
slots,
|
||||
nowIndex: liveNowIndex,
|
||||
forecastWeek,
|
||||
negPrices,
|
||||
error,
|
||||
ready,
|
||||
reload: load,
|
||||
liveMetrics,
|
||||
buyNow,
|
||||
}
|
||||
}
|
||||
@@ -3,7 +3,7 @@ import { useCallback, useEffect, useState } from 'react'
|
||||
import { getSiteStatusFull } from '../api/backend'
|
||||
import type { FullStatusResponse } from '../types/fullStatus'
|
||||
|
||||
const POLL_MS = 30_000
|
||||
const POLL_MS = 60_000
|
||||
|
||||
export function useFullStatus(siteId: number | null) {
|
||||
const [data, setData] = useState<FullStatusResponse | null>(null)
|
||||
|
||||
51
frontend/src/hooks/useLogSeverityBadge.ts
Normal file
51
frontend/src/hooks/useLogSeverityBadge.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
|
||||
const SEVERE = new Set(['ERROR', 'CRITICAL'])
|
||||
|
||||
function wsLogsUrl(): string {
|
||||
const proto = window.location.protocol === 'https:' ? 'wss:' : 'ws:'
|
||||
return `${proto}//${window.location.host}/ws/logs`
|
||||
}
|
||||
|
||||
/** Počítá ERROR/CRITICAL z /ws/logs jen když stránka Logy není aktivní (aby nebyly 2 sockety). */
|
||||
export function useLogSeverityBadge(logsPageActive: boolean): number {
|
||||
const [count, setCount] = useState(0)
|
||||
|
||||
useEffect(() => {
|
||||
if (logsPageActive) {
|
||||
setCount(0)
|
||||
return
|
||||
}
|
||||
|
||||
let ws: WebSocket | null = null
|
||||
let reconnectTimer: ReturnType<typeof setTimeout> | null = null
|
||||
let disposed = false
|
||||
|
||||
const connect = () => {
|
||||
if (disposed) return
|
||||
ws = new WebSocket(wsLogsUrl())
|
||||
ws.onmessage = (e) => {
|
||||
try {
|
||||
const o = JSON.parse(e.data) as { level?: string }
|
||||
if (o.level && SEVERE.has(o.level)) setCount((c) => c + 1)
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
ws.onclose = () => {
|
||||
if (disposed) return
|
||||
reconnectTimer = setTimeout(connect, 3000)
|
||||
}
|
||||
}
|
||||
|
||||
connect()
|
||||
|
||||
return () => {
|
||||
disposed = true
|
||||
if (reconnectTimer != null) clearTimeout(reconnectTimer)
|
||||
ws?.close()
|
||||
}
|
||||
}, [logsPageActive])
|
||||
|
||||
return count
|
||||
}
|
||||
45
frontend/src/hooks/useNotifications.ts
Normal file
45
frontend/src/hooks/useNotifications.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
|
||||
import { getSiteNotifications } from '../api/backend'
|
||||
import type { Notification } from '../types/dashboard'
|
||||
|
||||
const POLL_MS = 60_000
|
||||
|
||||
export function useNotifications(siteId: number | null) {
|
||||
const [notifications, setNotifications] = useState<Notification[]>([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
const load = useCallback(async (silent?: boolean) => {
|
||||
if (siteId == null) {
|
||||
setNotifications([])
|
||||
setError(null)
|
||||
setLoading(false)
|
||||
return
|
||||
}
|
||||
if (!silent) setLoading(true)
|
||||
try {
|
||||
const res = await getSiteNotifications(siteId)
|
||||
setNotifications(res.notifications)
|
||||
setError(null)
|
||||
} catch {
|
||||
setNotifications([])
|
||||
setError('Notifikace se nepodařilo načíst')
|
||||
} finally {
|
||||
if (!silent) setLoading(false)
|
||||
}
|
||||
}, [siteId])
|
||||
|
||||
useEffect(() => {
|
||||
void load(false)
|
||||
const id = window.setInterval(() => void load(true), POLL_MS)
|
||||
return () => window.clearInterval(id)
|
||||
}, [load])
|
||||
|
||||
return {
|
||||
notifications,
|
||||
loading,
|
||||
error,
|
||||
reload: () => void load(false),
|
||||
}
|
||||
}
|
||||
31
frontend/src/hooks/useRollingReplanMinutes.ts
Normal file
31
frontend/src/hooks/useRollingReplanMinutes.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
import { useCallback, useEffect, useState } from 'react'
|
||||
|
||||
import { getBackendHealthDetailed } from '../api/backend'
|
||||
|
||||
/** Minuty do dalšího naplánovaného `rolling_replan` jobu (globální scheduler). */
|
||||
export function useRollingReplanMinutes() {
|
||||
const [nextReplanIn, setNextReplanIn] = useState<number | null>(null)
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
try {
|
||||
const h = await getBackendHealthDetailed()
|
||||
const job = h.active_jobs.find((j) => j.id === 'rolling_replan')
|
||||
if (job?.next_run_time == null) {
|
||||
setNextReplanIn(null)
|
||||
return
|
||||
}
|
||||
const diffMin = (new Date(job.next_run_time).getTime() - Date.now()) / 60_000
|
||||
setNextReplanIn(Math.max(0, Math.round(diffMin)))
|
||||
} catch {
|
||||
setNextReplanIn(null)
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
void refresh()
|
||||
const id = window.setInterval(() => void refresh(), 60_000)
|
||||
return () => window.clearInterval(id)
|
||||
}, [refresh])
|
||||
|
||||
return { nextReplanIn, refreshRollingEta: refresh }
|
||||
}
|
||||
@@ -2,7 +2,7 @@ import { useCallback, useEffect, useState } from 'react'
|
||||
import { getJson } from '../api/postgrest'
|
||||
import type { SiteStatusRow } from '../types/ems'
|
||||
|
||||
const POLL_MS = 5_000
|
||||
const POLL_MS = 30_000
|
||||
|
||||
export function useSiteStatus() {
|
||||
const [row, setRow] = useState<SiteStatusRow | null>(null)
|
||||
|
||||
49
frontend/src/hooks/useWsLogErrorCount.ts
Normal file
49
frontend/src/hooks/useWsLogErrorCount.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import { useEffect, useState } from 'react'
|
||||
|
||||
const WINDOW_MS = 5 * 60_000
|
||||
const PRUNE_MS = 10_000
|
||||
|
||||
/** Počet ERROR logů z /ws/logs za posledních 5 minut (podle času přijetí zprávy). */
|
||||
export function useWsLogErrorCount(enabled: boolean): number {
|
||||
const [count, setCount] = useState(0)
|
||||
|
||||
useEffect(() => {
|
||||
if (!enabled) {
|
||||
setCount(0)
|
||||
return
|
||||
}
|
||||
|
||||
const timestamps: number[] = []
|
||||
const prune = () => {
|
||||
const cutoff = Date.now() - WINDOW_MS
|
||||
while (timestamps.length > 0 && timestamps[0]! < cutoff) {
|
||||
timestamps.shift()
|
||||
}
|
||||
setCount(timestamps.length)
|
||||
}
|
||||
|
||||
const proto = window.location.protocol === 'https:' ? 'wss:' : 'ws:'
|
||||
const ws = new WebSocket(`${proto}//${window.location.host}/ws/logs`)
|
||||
|
||||
ws.onmessage = (ev) => {
|
||||
let rec: { level?: string }
|
||||
try {
|
||||
rec = JSON.parse(ev.data as string) as { level?: string }
|
||||
} catch {
|
||||
return
|
||||
}
|
||||
if (rec.level === 'ERROR') {
|
||||
timestamps.push(Date.now())
|
||||
prune()
|
||||
}
|
||||
}
|
||||
|
||||
const id = window.setInterval(prune, PRUNE_MS)
|
||||
return () => {
|
||||
window.clearInterval(id)
|
||||
ws.close()
|
||||
}
|
||||
}, [enabled])
|
||||
|
||||
return count
|
||||
}
|
||||
Reference in New Issue
Block a user