Files
ems/frontend/src/components/ControlPanel.tsx
Dusan Vojacek f3a7b0c64f
Some checks failed
CI and deploy / deploy (push) Has been skipped
CI and deploy / migration-check (push) Failing after 11s
FE cutoff vizsualize
2026-04-29 13:47:57 +02:00

321 lines
12 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import axios from 'axios'
import { RefreshCw } from 'lucide-react'
import { memo, useCallback, useEffect, useState } from 'react'
import { getCommandJournal, getDeyeRegisters, type DeyeRegistersLive, type ModbusJournalCommandDto } from '../api/backend'
const BATT_VOLTAGE_V = 51.2
const POLL_REGISTERS_MS = 30_000
const POLL_JOURNAL_MS = 60_000
const TZ = 'Europe/Prague'
function fmtTime(iso: string): string {
return new Date(iso).toLocaleString('cs-CZ', {
timeZone: TZ,
day: '2-digit',
month: '2-digit',
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
})
}
function ampsToKw(a: number | null | undefined): string {
if (a == null || Number.isNaN(a)) return '—'
return `${((a * BATT_VOLTAGE_V) / 1000).toFixed(2)} kW`
}
function fmtW(w: number | null | undefined): string {
if (w == null || Number.isNaN(w)) return '—'
return `${w} W`
}
function journalSignature(cmds: ModbusJournalCommandDto[]): string {
return cmds
.map(
(c) =>
`${c.id}:${c.status}:${c.attempt_count}:${c.value_written ?? ''}:${c.value_verified ?? ''}`,
)
.join('|')
}
function statusBadgeClass(status: string): string {
const u = status.toLowerCase()
if (u === 'verified') return 'bg-emerald-600/25 text-emerald-200 ring-1 ring-emerald-500/40'
if (u === 'written') return 'bg-sky-600/25 text-sky-200 ring-1 ring-sky-500/40'
if (u === 'pending' || u === 'retrying') return 'bg-slate-600/30 text-slate-300 ring-1 ring-slate-500/35'
if (u === 'failed' || u === 'mismatch')
return u === 'mismatch'
? 'bg-red-600/30 text-red-100 font-bold ring-1 ring-red-500/50'
: 'bg-red-600/25 text-red-200 ring-1 ring-red-500/40'
return 'bg-slate-600/30 text-slate-300 ring-1 ring-slate-500/35'
}
type LiveSectionProps = {
live: DeyeRegistersLive | null
liveLoading: boolean
onRefresh: () => void
}
const LiveRegistersSection = memo(
function LiveRegistersSection({ live, liveLoading, onRefresh }: LiveSectionProps) {
return (
<div className="rounded-xl border border-slate-800 bg-slate-900/50 p-4">
<div className="mb-3 flex flex-wrap items-center justify-between gap-2">
<h3 className="text-sm font-semibold text-slate-200">Živé registry</h3>
<button
type="button"
onClick={() => onRefresh()}
disabled={liveLoading}
className="inline-flex items-center gap-1.5 rounded-lg border border-slate-600 bg-slate-800/80 px-3 py-1.5 text-xs font-medium text-slate-100 hover:bg-slate-700 disabled:opacity-50"
>
<RefreshCw className={`h-3.5 w-3.5 ${liveLoading ? 'animate-spin' : ''}`} aria-hidden />
Refresh
</button>
</div>
<div className="mt-2 grid grid-cols-1 gap-3 sm:grid-cols-2">
<Metric label="Max nabíjení" reg={108} unitA={live?.reg108_charge_a} kwHint />
<Metric label="Max vybíjení" reg={109} unitA={live?.reg109_discharge_a} kwHint />
<Metric
label="Limit control"
reg={142}
sub="0 = selling first, 1 = zero export"
valueText={live?.reg142_limit_control != null ? String(live.reg142_limit_control) : undefined}
/>
<Metric label="Energy mode" reg={141} valueText={live?.reg141_energy_mode != null ? String(live.reg141_energy_mode) : undefined} />
<Metric
label="Peak shaving switch"
reg={178}
sub="Bit45: 10 = disable při exportu, 11 = enable při IDLE/CHARGE"
valueText={live?.reg178_peak_shaving_switch != null ? String(live.reg178_peak_shaving_switch) : undefined}
/>
<Metric
label="MI export cutoff (GEN)"
reg={178}
sub="Bits01: 2 = disable (cutoff OFF), 3 = enable (cutoff ON)"
valueText={
live
? `${live.reg178_mi_export_cutoff_is_on ? 'ON (enable=3)' : 'OFF (disable=2)'} · bits01=${live.reg178_mi_export_cutoff_bits} · reg178=${live.reg178_control_board_special_1}`
: undefined
}
/>
<Metric
label="Grid peak shaving W"
reg={191}
sub="EMS nezapisuje nastavit v SolarmanApp (výkon peak shavingu v W)"
valueText={fmtW(live?.reg191_peak_shaving_w)}
/>
</div>
{live?.read_at ? (
<p className="mt-3 text-[10px] text-slate-500">Načteno: {fmtTime(live.read_at)}</p>
) : null}
</div>
)
},
(a, b) =>
a.liveLoading === b.liveLoading &&
a.live?.read_at === b.live?.read_at &&
a.live?.reg108_charge_a === b.live?.reg108_charge_a &&
a.live?.reg109_discharge_a === b.live?.reg109_discharge_a &&
a.live?.reg141_energy_mode === b.live?.reg141_energy_mode &&
a.live?.reg142_limit_control === b.live?.reg142_limit_control &&
a.live?.reg143_export_limit_w === b.live?.reg143_export_limit_w &&
a.live?.reg178_peak_shaving_switch === b.live?.reg178_peak_shaving_switch &&
a.live?.reg178_control_board_special_1 === b.live?.reg178_control_board_special_1 &&
a.live?.reg178_mi_export_cutoff_bits === b.live?.reg178_mi_export_cutoff_bits &&
a.live?.reg178_mi_export_cutoff_is_on === b.live?.reg178_mi_export_cutoff_is_on &&
a.live?.reg191_peak_shaving_w === b.live?.reg191_peak_shaving_w,
)
type MetricProps = {
label: string
reg: number
unitA?: number | null
kwHint?: boolean
valueText?: string
sub?: string
}
function Metric({ label, reg, unitA, kwHint, valueText, sub }: MetricProps) {
const main =
valueText ??
(unitA != null && !Number.isNaN(unitA) ? `${unitA} A` : '—')
const extra = kwHint ? ampsToKw(unitA ?? null) : null
return (
<div className="rounded-lg border border-slate-800/80 bg-slate-950/40 px-3 py-2">
<p className="text-[10px] font-semibold uppercase tracking-wide text-slate-500">{label}</p>
<p className="mt-0.5 font-mono text-sm text-slate-100">
reg {reg}: {main}
{extra && extra !== '—' ? <span className="text-slate-400"> · {extra}</span> : null}
</p>
{sub ? <p className="mt-0.5 text-[10px] text-slate-500">{sub}</p> : null}
</div>
)
}
type JournalSectionProps = {
commands: ModbusJournalCommandDto[]
}
const JournalSection = memo(
function JournalSection({ commands }: JournalSectionProps) {
return (
<div className="rounded-xl border border-slate-800 bg-slate-900/50 p-4">
<h3 className="mb-3 text-sm font-semibold text-slate-200">Posledních 50 zápisů</h3>
<div
className="overflow-x-auto"
style={{
maxHeight: '400px',
overflowY: 'auto',
borderRadius: 'var(--border-radius-md)',
border: '0.5px solid var(--color-border-tertiary)',
}}
>
<table className="w-full border-collapse text-left text-xs">
<thead>
<tr className="text-slate-500">
<th className="py-2 pr-2 font-medium">Čas</th>
<th className="py-2 pr-2 font-medium">Reg</th>
<th className="py-2 pr-2 font-medium">Popis</th>
<th className="py-2 pr-2 font-medium">Hodnota</th>
<th className="py-2 pr-2 font-medium">Pokus</th>
<th className="py-2 font-medium">Status</th>
</tr>
</thead>
<tbody>
{commands.length === 0 ? (
<tr>
<td colSpan={6} className="py-4 text-slate-500">
Žádné záznamy v journalu.
</td>
</tr>
) : (
commands.map((c) => (
<tr key={c.id} className="border-t border-slate-800/80">
<td className="whitespace-nowrap py-1.5 pr-2 font-mono text-slate-400">
{fmtTime(c.created_at)}
</td>
<td className="pr-2 font-mono text-slate-300">{c.register}</td>
<td className="max-w-[140px] truncate pr-2 text-slate-400" title={c.register_name ?? ''}>
{c.register_name ?? '—'}
</td>
<td className="pr-2 font-mono tabular-nums text-slate-200">
{c.value_to_write}
{c.value_verified != null ? (
<span className="text-slate-500"> {c.value_verified}</span>
) : null}
</td>
<td className="pr-2 font-mono tabular-nums text-slate-300">{c.attempt_count}</td>
<td className="py-1.5">
<span
className={`inline-block rounded-md px-2 py-0.5 text-[10px] font-semibold uppercase ${statusBadgeClass(c.status)}`}
>
{c.status}
</span>
</td>
</tr>
))
)}
</tbody>
</table>
</div>
</div>
)
},
(a, b) => journalSignature(a.commands) === journalSignature(b.commands),
)
function ControlPanelImpl({ siteId }: { siteId: number }) {
const [live, setLive] = useState<DeyeRegistersLive | null>(null)
const [liveError, setLiveError] = useState<string | null>(null)
const [liveLoading, setLiveLoading] = useState(false)
const [commands, setCommands] = useState<ModbusJournalCommandDto[]>([])
const [journalError, setJournalError] = useState<string | null>(null)
const fetchRegisters = useCallback(async () => {
setLiveLoading(true)
setLiveError(null)
try {
const data = await getDeyeRegisters(siteId)
setLive(data)
} catch (e: unknown) {
let msg = 'Chyba čtení registrů'
if (axios.isAxiosError(e)) {
const d = e.response?.data as { detail?: string } | undefined
if (typeof d?.detail === 'string') msg = d.detail
} else if (e instanceof Error) {
msg = e.message
}
setLiveError(msg)
setLive(null)
} finally {
setLiveLoading(false)
}
}, [siteId])
const fetchJournal = useCallback(async () => {
setJournalError(null)
try {
const res = await getCommandJournal(siteId, 50)
setCommands(res.commands)
} catch (e: unknown) {
const msg = e instanceof Error ? e.message : 'Chyba načtení journalu'
setJournalError(msg)
setCommands([])
}
}, [siteId])
useEffect(() => {
void fetchRegisters()
}, [fetchRegisters])
useEffect(() => {
void fetchJournal()
}, [fetchJournal])
useEffect(() => {
const t = window.setInterval(() => void fetchRegisters(), POLL_REGISTERS_MS)
return () => window.clearInterval(t)
}, [fetchRegisters])
useEffect(() => {
const t = window.setInterval(() => void fetchJournal(), POLL_JOURNAL_MS)
return () => window.clearInterval(t)
}, [fetchJournal])
const apiError = liveError ?? journalError
return (
<div className="space-y-4">
{apiError ? (
<div
role="alert"
className="rounded-lg border border-red-500/45 bg-red-950/50 px-4 py-3 text-sm text-red-100"
>
<p className="font-semibold text-red-50">Chyba API řízení / Modbus</p>
{liveError ? (
<p className="mt-1.5">
<span className="text-red-200/90">GET /control/registers: </span>
{liveError}
</p>
) : null}
{journalError ? (
<p className={liveError ? 'mt-2' : 'mt-1.5'}>
<span className="text-red-200/90">Journal: </span>
{journalError}
</p>
) : null}
</div>
) : null}
<div className="grid grid-cols-1 gap-4 lg:grid-cols-2 lg:gap-6">
<LiveRegistersSection live={live} liveLoading={liveLoading} onRefresh={fetchRegisters} />
<JournalSection commands={commands} />
</div>
</div>
)
}
export const ControlPanel = memo(ControlPanelImpl)