second version

This commit is contained in:
Dusan Vojacek
2026-04-03 14:23:16 +02:00
parent 897b95f728
commit 9f4126946d
105 changed files with 9738 additions and 1470 deletions

View 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,
}
}

View File

@@ -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)

View 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
}

View 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),
}
}

View 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 }
}

View File

@@ -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)

View 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
}