fix: various fixes
This commit is contained in:
@@ -1,11 +1,24 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { api, type BlockHistoryPoint, type ControlAction, type DeviceStatus, type QuarterHourStatus } from "./api";
|
||||
import {
|
||||
api,
|
||||
type BlockHistoryPoint,
|
||||
type ControlAction,
|
||||
type CurrentReadings,
|
||||
type DeviceStatus,
|
||||
type QuarterHourStatus,
|
||||
} from "./api";
|
||||
import { PeakHistoryChart } from "./PeakHistoryChart";
|
||||
|
||||
const THIRTY_DAYS_MS = 30 * 24 * 60 * 60 * 1000;
|
||||
|
||||
function formatW(watts: number | null): string {
|
||||
if (watts == null) return "—";
|
||||
return Math.abs(watts) >= 1000 ? `${(watts / 1000).toFixed(2)} kW` : `${Math.round(watts)} W`;
|
||||
}
|
||||
|
||||
export function Dashboard() {
|
||||
const [quarterHour, setQuarterHour] = useState<QuarterHourStatus | null>(null);
|
||||
const [current, setCurrent] = useState<CurrentReadings | null>(null);
|
||||
const [devices, setDevices] = useState<DeviceStatus[]>([]);
|
||||
const [history, setHistory] = useState<BlockHistoryPoint[]>([]);
|
||||
const [actions, setActions] = useState<ControlAction[]>([]);
|
||||
@@ -23,6 +36,7 @@ export function Dashboard() {
|
||||
]);
|
||||
if (cancelled) return;
|
||||
setQuarterHour(status.quarterHour);
|
||||
setCurrent(status.current);
|
||||
setDevices(status.devices);
|
||||
setHistory(hist);
|
||||
setActions(acts);
|
||||
@@ -58,6 +72,20 @@ export function Dashboard() {
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="card">
|
||||
<h2>Live readings</h2>
|
||||
<div className="stat-grid">
|
||||
<div className="stat-tile">
|
||||
<div className="stat-label">Power usage</div>
|
||||
<div className="stat-value">{formatW(current?.powerW ?? null)}</div>
|
||||
</div>
|
||||
<div className="stat-tile">
|
||||
<div className="stat-label">Solar generation</div>
|
||||
<div className="stat-value">{formatW(current?.solarW ?? null)}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="card">
|
||||
<h2>Current quarter-hour block</h2>
|
||||
<div className="hero-figure">{(quarterHour.projectedAverageW / 1000).toFixed(2)} kW</div>
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { useEffect, useState, type FormEvent } from "react";
|
||||
import { api, type ControlKind, type DeviceConfig } from "./api";
|
||||
|
||||
const emptyForm = {
|
||||
type DeviceFormValues = Omit<DeviceConfig, "id">;
|
||||
|
||||
const emptyForm: DeviceFormValues = {
|
||||
entityId: "",
|
||||
name: "",
|
||||
kind: "switch" as ControlKind,
|
||||
@@ -11,16 +13,158 @@ const emptyForm = {
|
||||
dwellSeconds: 300,
|
||||
manualOverride: false,
|
||||
onlyWhenSun: false,
|
||||
fromTime: null as string | null,
|
||||
toTime: null as string | null,
|
||||
enableUrl: null as string | null,
|
||||
disableUrl: null as string | null,
|
||||
fromTime: null,
|
||||
toTime: null,
|
||||
enableUrl: null,
|
||||
disableUrl: null,
|
||||
};
|
||||
|
||||
function DeviceFields({
|
||||
values,
|
||||
onChange,
|
||||
}: {
|
||||
values: DeviceFormValues;
|
||||
onChange: (next: DeviceFormValues) => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="form-grid">
|
||||
<label>
|
||||
Name
|
||||
<input
|
||||
value={values.name}
|
||||
onChange={(e) => onChange({ ...values, name: e.target.value })}
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
Home Assistant entity ID
|
||||
<input
|
||||
value={values.entityId}
|
||||
onChange={(e) => onChange({ ...values, entityId: e.target.value })}
|
||||
placeholder="switch.ev_charger"
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
Control type
|
||||
<select
|
||||
value={values.kind}
|
||||
onChange={(e) =>
|
||||
onChange({ ...values, kind: e.target.value as ControlKind })
|
||||
}
|
||||
>
|
||||
<option value="switch">Switch (on/off)</option>
|
||||
<option value="number">Number (set-point)</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
Priority (lower = shed first)
|
||||
<input
|
||||
type="number"
|
||||
value={values.priority}
|
||||
onChange={(e) =>
|
||||
onChange({ ...values, priority: Number(e.target.value) })
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
{values.kind === "number" && (
|
||||
<>
|
||||
<label>
|
||||
Min value (shed target)
|
||||
<input
|
||||
type="number"
|
||||
value={values.minValue}
|
||||
onChange={(e) =>
|
||||
onChange({ ...values, minValue: Number(e.target.value) })
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
Max value (normal)
|
||||
<input
|
||||
type="number"
|
||||
value={values.maxValue}
|
||||
onChange={(e) =>
|
||||
onChange({ ...values, maxValue: Number(e.target.value) })
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
</>
|
||||
)}
|
||||
<label>
|
||||
Min dwell time (seconds)
|
||||
<input
|
||||
type="number"
|
||||
value={values.dwellSeconds}
|
||||
onChange={(e) =>
|
||||
onChange({ ...values, dwellSeconds: Number(e.target.value) })
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
Only enable if sun
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={values.onlyWhenSun}
|
||||
onChange={(e) =>
|
||||
onChange({ ...values, onlyWhenSun: e.target.checked })
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
From (allowed window start)
|
||||
<input
|
||||
type="time"
|
||||
value={values.fromTime ?? ""}
|
||||
onChange={(e) =>
|
||||
onChange({ ...values, fromTime: e.target.value || null })
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
To (allowed window end)
|
||||
<input
|
||||
type="time"
|
||||
value={values.toTime ?? ""}
|
||||
onChange={(e) =>
|
||||
onChange({ ...values, toTime: e.target.value || null })
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
Custom enable endpoint (optional)
|
||||
<input
|
||||
type="url"
|
||||
value={values.enableUrl ?? ""}
|
||||
onChange={(e) =>
|
||||
onChange({ ...values, enableUrl: e.target.value || null })
|
||||
}
|
||||
placeholder="https://example.local/device/on"
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
Custom disable endpoint (optional)
|
||||
<input
|
||||
type="url"
|
||||
value={values.disableUrl ?? ""}
|
||||
onChange={(e) =>
|
||||
onChange({ ...values, disableUrl: e.target.value || null })
|
||||
}
|
||||
placeholder="https://example.local/device/off"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function Devices() {
|
||||
const [devices, setDevices] = useState<DeviceConfig[]>([]);
|
||||
const [form, setForm] = useState(emptyForm);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [editingId, setEditingId] = useState<number | null>(null);
|
||||
const [editForm, setEditForm] = useState<DeviceFormValues>(emptyForm);
|
||||
const [editError, setEditError] = useState<string | null>(null);
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
async function refresh() {
|
||||
setDevices(await api.getDevices());
|
||||
@@ -53,138 +197,40 @@ export function Devices() {
|
||||
await refresh();
|
||||
}
|
||||
|
||||
function startEdit(d: DeviceConfig) {
|
||||
const { id: _id, ...rest } = d;
|
||||
setEditForm(rest);
|
||||
setEditingId(d.id);
|
||||
setEditError(null);
|
||||
}
|
||||
|
||||
function cancelEdit() {
|
||||
setEditingId(null);
|
||||
setEditError(null);
|
||||
}
|
||||
|
||||
async function saveEdit(e: FormEvent) {
|
||||
e.preventDefault();
|
||||
if (editingId == null) return;
|
||||
setSaving(true);
|
||||
try {
|
||||
await api.updateDevice(editingId, editForm);
|
||||
await refresh();
|
||||
setEditingId(null);
|
||||
setEditError(null);
|
||||
} catch (err) {
|
||||
setEditError(err instanceof Error ? err.message : "Failed to save device");
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="card">
|
||||
<h2>Add device</h2>
|
||||
<form onSubmit={handleCreate}>
|
||||
<div className="form-grid">
|
||||
<label>
|
||||
Name
|
||||
<input
|
||||
value={form.name}
|
||||
onChange={(e) => setForm({ ...form, name: e.target.value })}
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
Home Assistant entity ID
|
||||
<input
|
||||
value={form.entityId}
|
||||
onChange={(e) => setForm({ ...form, entityId: e.target.value })}
|
||||
placeholder="switch.ev_charger"
|
||||
required
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
Control type
|
||||
<select
|
||||
value={form.kind}
|
||||
onChange={(e) =>
|
||||
setForm({ ...form, kind: e.target.value as ControlKind })
|
||||
}
|
||||
>
|
||||
<option value="switch">Switch (on/off)</option>
|
||||
<option value="number">Number (set-point)</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
Priority (lower = shed first)
|
||||
<input
|
||||
type="number"
|
||||
value={form.priority}
|
||||
onChange={(e) =>
|
||||
setForm({ ...form, priority: Number(e.target.value) })
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
{form.kind === "number" && (
|
||||
<>
|
||||
<label>
|
||||
Min value (shed target)
|
||||
<input
|
||||
type="number"
|
||||
value={form.minValue}
|
||||
onChange={(e) =>
|
||||
setForm({ ...form, minValue: Number(e.target.value) })
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
Max value (normal)
|
||||
<input
|
||||
type="number"
|
||||
value={form.maxValue}
|
||||
onChange={(e) =>
|
||||
setForm({ ...form, maxValue: Number(e.target.value) })
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
</>
|
||||
)}
|
||||
<label>
|
||||
Min dwell time (seconds)
|
||||
<input
|
||||
type="number"
|
||||
value={form.dwellSeconds}
|
||||
onChange={(e) =>
|
||||
setForm({ ...form, dwellSeconds: Number(e.target.value) })
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
Only enable if sun
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={form.onlyWhenSun}
|
||||
onChange={(e) =>
|
||||
setForm({ ...form, onlyWhenSun: e.target.checked })
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
From (allowed window start)
|
||||
<input
|
||||
type="time"
|
||||
value={form.fromTime ?? ""}
|
||||
onChange={(e) =>
|
||||
setForm({ ...form, fromTime: e.target.value || null })
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
To (allowed window end)
|
||||
<input
|
||||
type="time"
|
||||
value={form.toTime ?? ""}
|
||||
onChange={(e) =>
|
||||
setForm({ ...form, toTime: e.target.value || null })
|
||||
}
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
Custom enable endpoint (optional)
|
||||
<input
|
||||
type="url"
|
||||
value={form.enableUrl ?? ""}
|
||||
onChange={(e) =>
|
||||
setForm({ ...form, enableUrl: e.target.value || null })
|
||||
}
|
||||
placeholder="https://example.local/device/on"
|
||||
/>
|
||||
</label>
|
||||
<label>
|
||||
Custom disable endpoint (optional)
|
||||
<input
|
||||
type="url"
|
||||
value={form.disableUrl ?? ""}
|
||||
onChange={(e) =>
|
||||
setForm({ ...form, disableUrl: e.target.value || null })
|
||||
}
|
||||
placeholder="https://example.local/device/off"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<DeviceFields values={form} onChange={setForm} />
|
||||
<p style={{ color: "var(--text-muted)", fontSize: 13 }}>
|
||||
Set both custom endpoints to control this device over plain HTTP
|
||||
(POST) instead of Home Assistant. The entity ID above is still used
|
||||
@@ -211,35 +257,66 @@ export function Devices() {
|
||||
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} · {d.kind} · priority {d.priority} · dwell{" "}
|
||||
{d.dwellSeconds}s{d.onlyWhenSun && " · sun only"}
|
||||
{d.fromTime && d.toTime && ` · ${d.fromTime}–${d.toTime}`}
|
||||
{d.enableUrl && d.disableUrl && " · custom endpoints"}
|
||||
<div>{d.enableUrl}</div>
|
||||
<div>{d.disableUrl}</div>
|
||||
.map((d) =>
|
||||
editingId === d.id ? (
|
||||
<form
|
||||
className="device-row device-row-editing"
|
||||
key={d.id}
|
||||
onSubmit={saveEdit}
|
||||
>
|
||||
<DeviceFields values={editForm} onChange={setEditForm} />
|
||||
{editError && (
|
||||
<p style={{ color: "var(--status-critical)", fontSize: 13 }}>
|
||||
{editError}
|
||||
</p>
|
||||
)}
|
||||
<div style={{ display: "flex", gap: 8 }}>
|
||||
<button className="primary" type="submit" disabled={saving}>
|
||||
{saving ? "Saving…" : "Save"}
|
||||
</button>
|
||||
<button
|
||||
className="secondary"
|
||||
type="button"
|
||||
onClick={cancelEdit}
|
||||
disabled={saving}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
) : (
|
||||
<div className="device-row" key={d.id}>
|
||||
<div>
|
||||
<div className="device-name">{d.name}</div>
|
||||
<div className="device-meta">
|
||||
{d.entityId} · {d.kind} · priority {d.priority} · dwell{" "}
|
||||
{d.dwellSeconds}s{d.onlyWhenSun && " · sun only"}
|
||||
{d.fromTime && d.toTime && ` · ${d.fromTime}–${d.toTime}`}
|
||||
{d.enableUrl && d.disableUrl && " · custom endpoints"}
|
||||
<div>{d.enableUrl}</div>
|
||||
<div>{d.disableUrl}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ display: "flex", gap: 8, alignItems: "center" }}>
|
||||
{d.manualOverride && (
|
||||
<span className="badge override">Manual</span>
|
||||
)}
|
||||
<button
|
||||
className="secondary"
|
||||
onClick={() => toggleOverride(d)}
|
||||
>
|
||||
{d.manualOverride ? "Resume auto" : "Manual override"}
|
||||
</button>
|
||||
<button className="secondary" onClick={() => startEdit(d)}>
|
||||
Edit
|
||||
</button>
|
||||
<button className="secondary" onClick={() => remove(d)}>
|
||||
Remove
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ display: "flex", gap: 8, alignItems: "center" }}>
|
||||
{d.manualOverride && (
|
||||
<span className="badge override">Manual</span>
|
||||
)}
|
||||
<button
|
||||
className="secondary"
|
||||
onClick={() => toggleOverride(d)}
|
||||
>
|
||||
{d.manualOverride ? "Resume auto" : "Manual override"}
|
||||
</button>
|
||||
<button className="secondary" onClick={() => remove(d)}>
|
||||
Remove
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
),
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
|
||||
@@ -34,6 +34,11 @@ export interface QuarterHourStatus {
|
||||
overTarget: boolean;
|
||||
}
|
||||
|
||||
export interface CurrentReadings {
|
||||
powerW: number | null;
|
||||
solarW: number | null;
|
||||
}
|
||||
|
||||
export interface ControlAction {
|
||||
timestampMs: number;
|
||||
entityId: string;
|
||||
@@ -56,7 +61,10 @@ async function request<T>(path: string, init?: RequestInit): Promise<T> {
|
||||
}
|
||||
|
||||
export const api = {
|
||||
getStatus: () => request<{ quarterHour: QuarterHourStatus; devices: DeviceStatus[] }>("/api/status"),
|
||||
getStatus: () =>
|
||||
request<{ quarterHour: QuarterHourStatus; devices: DeviceStatus[]; current: CurrentReadings }>(
|
||||
"/api/status",
|
||||
),
|
||||
getHistory: (sinceMs: number) => request<BlockHistoryPoint[]>(`/api/history?sinceMs=${sinceMs}`),
|
||||
getActions: (limit = 50) => request<ControlAction[]>(`/api/actions?limit=${limit}`),
|
||||
getDevices: () => request<DeviceConfig[]>("/api/devices"),
|
||||
|
||||
@@ -124,6 +124,31 @@ nav.tabs button.active {
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.stat-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.stat-tile {
|
||||
background: var(--surface-2, transparent);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 10px;
|
||||
padding: 14px 16px;
|
||||
}
|
||||
|
||||
.stat-label {
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.stat-value {
|
||||
font-size: 28px;
|
||||
font-weight: 600;
|
||||
line-height: 1.1;
|
||||
}
|
||||
|
||||
.status-pill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
@@ -162,6 +187,11 @@ nav.tabs button.active {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.device-row-editing {
|
||||
display: block;
|
||||
padding: 16px 0;
|
||||
}
|
||||
|
||||
.device-meta {
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
|
||||
Reference in New Issue
Block a user