second version
This commit is contained in:
307
frontend/src/components/ControlPanel.tsx
Normal file
307
frontend/src/components/ControlPanel.tsx
Normal file
@@ -0,0 +1,307 @@
|
||||
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="Bit4–5: 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="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?.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)
|
||||
Reference in New Issue
Block a user