137 lines
4.4 KiB
TypeScript
137 lines
4.4 KiB
TypeScript
import { useEffect, useState } from "react";
|
|
import { api, type BlockHistoryPoint, type ControlAction, type DeviceStatus, type QuarterHourStatus } from "./api";
|
|
import { PeakHistoryChart } from "./PeakHistoryChart";
|
|
|
|
const THIRTY_DAYS_MS = 30 * 24 * 60 * 60 * 1000;
|
|
|
|
export function Dashboard() {
|
|
const [quarterHour, setQuarterHour] = useState<QuarterHourStatus | null>(null);
|
|
const [devices, setDevices] = useState<DeviceStatus[]>([]);
|
|
const [history, setHistory] = useState<BlockHistoryPoint[]>([]);
|
|
const [actions, setActions] = useState<ControlAction[]>([]);
|
|
const [error, setError] = useState<string | null>(null);
|
|
|
|
useEffect(() => {
|
|
let cancelled = false;
|
|
|
|
async function refresh() {
|
|
try {
|
|
const [status, hist, acts] = await Promise.all([
|
|
api.getStatus(),
|
|
api.getHistory(Date.now() - THIRTY_DAYS_MS),
|
|
api.getActions(10),
|
|
]);
|
|
if (cancelled) return;
|
|
setQuarterHour(status.quarterHour);
|
|
setDevices(status.devices);
|
|
setHistory(hist);
|
|
setActions(acts);
|
|
setError(null);
|
|
} catch (err) {
|
|
if (!cancelled) setError(err instanceof Error ? err.message : "Failed to load status");
|
|
}
|
|
}
|
|
|
|
refresh();
|
|
const interval = setInterval(refresh, 10_000);
|
|
return () => {
|
|
cancelled = true;
|
|
clearInterval(interval);
|
|
};
|
|
}, []);
|
|
|
|
if (error) {
|
|
return (
|
|
<div className="card">
|
|
<h2>Error</h2>
|
|
<p>{error}</p>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
if (!quarterHour) {
|
|
return <div className="card">Loading…</div>;
|
|
}
|
|
|
|
const overTarget = quarterHour.overTarget;
|
|
const shedCount = devices.filter((d) => d.shed).length;
|
|
|
|
return (
|
|
<>
|
|
<div className="card">
|
|
<h2>Current quarter-hour block</h2>
|
|
<div className="hero-figure">{(quarterHour.projectedAverageW / 1000).toFixed(2)} kW</div>
|
|
<div className="hero-sub">
|
|
projected average · target {(quarterHour.targetW / 1000).toFixed(2)} kW · running average{" "}
|
|
{(quarterHour.runningAverageW / 1000).toFixed(2)} kW ·{" "}
|
|
{Math.round(quarterHour.elapsedFractionOfBlock * 100)}% through block
|
|
</div>
|
|
<div style={{ marginTop: 10 }}>
|
|
<span className={`status-pill ${overTarget ? "critical" : "good"}`}>
|
|
{overTarget ? `Over target · ${shedCount} device(s) shed` : "Under target"}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="card">
|
|
<h2>Devices</h2>
|
|
{devices.length === 0 ? (
|
|
<p style={{ color: "var(--text-muted)", fontSize: 13 }}>No devices configured yet — add them in the Devices tab.</p>
|
|
) : (
|
|
devices
|
|
.slice()
|
|
.sort((a, b) => a.priority - b.priority)
|
|
.map((d) => (
|
|
<div className="device-row" key={d.id}>
|
|
<div>
|
|
<div className="device-name">{d.name}</div>
|
|
<div className="device-meta">
|
|
{d.entityId} · priority {d.priority}
|
|
{!d.available ? " · unavailable" : ""}
|
|
</div>
|
|
</div>
|
|
<div style={{ display: "flex", gap: 6 }}>
|
|
{d.manualOverride && <span className="badge override">Manual</span>}
|
|
<span className={`badge ${d.shed ? "shed" : "active"}`}>{d.shed ? "Shed" : "Active"}</span>
|
|
</div>
|
|
</div>
|
|
))
|
|
)}
|
|
</div>
|
|
|
|
<div className="card">
|
|
<h2>Daily peak (last 30 days)</h2>
|
|
<PeakHistoryChart points={history} targetW={quarterHour.targetW} />
|
|
</div>
|
|
|
|
<div className="card">
|
|
<h2>Recent control actions</h2>
|
|
{actions.length === 0 ? (
|
|
<p style={{ color: "var(--text-muted)", fontSize: 13 }}>No actions yet.</p>
|
|
) : (
|
|
<table>
|
|
<thead>
|
|
<tr>
|
|
<th>Time</th>
|
|
<th>Device</th>
|
|
<th>Action</th>
|
|
<th>Reason</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{actions.map((a, i) => (
|
|
<tr key={i}>
|
|
<td>{new Date(a.timestampMs).toLocaleTimeString()}</td>
|
|
<td>{a.entityId}</td>
|
|
<td>{a.action}</td>
|
|
<td style={{ color: "var(--text-secondary)" }}>{a.reason}</td>
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
)}
|
|
</div>
|
|
</>
|
|
);
|
|
}
|