fix: added energy-management
This commit is contained in:
23
energy-management/frontend/src/App.tsx
Normal file
23
energy-management/frontend/src/App.tsx
Normal file
@@ -0,0 +1,23 @@
|
||||
import { useState } from "react";
|
||||
import { Dashboard } from "./Dashboard";
|
||||
import { Devices } from "./Devices";
|
||||
|
||||
type Tab = "dashboard" | "devices";
|
||||
|
||||
export function App() {
|
||||
const [tab, setTab] = useState<Tab>("dashboard");
|
||||
|
||||
return (
|
||||
<div className="app">
|
||||
<nav className="tabs">
|
||||
<button className={tab === "dashboard" ? "active" : ""} onClick={() => setTab("dashboard")}>
|
||||
Dashboard
|
||||
</button>
|
||||
<button className={tab === "devices" ? "active" : ""} onClick={() => setTab("devices")}>
|
||||
Devices
|
||||
</button>
|
||||
</nav>
|
||||
{tab === "dashboard" ? <Dashboard /> : <Devices />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
136
energy-management/frontend/src/Dashboard.tsx
Normal file
136
energy-management/frontend/src/Dashboard.tsx
Normal file
@@ -0,0 +1,136 @@
|
||||
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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
247
energy-management/frontend/src/Devices.tsx
Normal file
247
energy-management/frontend/src/Devices.tsx
Normal file
@@ -0,0 +1,247 @@
|
||||
import { useEffect, useState, type FormEvent } from "react";
|
||||
import { api, type ControlKind, type DeviceConfig } from "./api";
|
||||
|
||||
const emptyForm = {
|
||||
entityId: "",
|
||||
name: "",
|
||||
kind: "switch" as ControlKind,
|
||||
priority: 1,
|
||||
minValue: 0,
|
||||
maxValue: 1,
|
||||
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,
|
||||
};
|
||||
|
||||
export function Devices() {
|
||||
const [devices, setDevices] = useState<DeviceConfig[]>([]);
|
||||
const [form, setForm] = useState(emptyForm);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
async function refresh() {
|
||||
setDevices(await api.getDevices());
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
refresh();
|
||||
}, []);
|
||||
|
||||
async function handleCreate(e: FormEvent) {
|
||||
e.preventDefault();
|
||||
try {
|
||||
await api.createDevice(form);
|
||||
setForm(emptyForm);
|
||||
await refresh();
|
||||
setError(null);
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : "Failed to create device");
|
||||
}
|
||||
}
|
||||
|
||||
async function toggleOverride(d: DeviceConfig) {
|
||||
await api.updateDevice(d.id, { manualOverride: !d.manualOverride });
|
||||
await refresh();
|
||||
}
|
||||
|
||||
async function remove(d: DeviceConfig) {
|
||||
if (!confirm(`Remove ${d.name}?`)) return;
|
||||
await api.deleteDevice(d.id);
|
||||
await refresh();
|
||||
}
|
||||
|
||||
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>
|
||||
<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
|
||||
as the device's unique key.
|
||||
</p>
|
||||
{error && (
|
||||
<p style={{ color: "var(--status-critical)", fontSize: 13 }}>
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
<button className="primary" type="submit">
|
||||
Add device
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div className="card">
|
||||
<h2>Configured devices</h2>
|
||||
{devices.length === 0 ? (
|
||||
<p style={{ color: "var(--text-muted)", fontSize: 13 }}>
|
||||
No devices configured yet.
|
||||
</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} · {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={() => remove(d)}>
|
||||
Remove
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
114
energy-management/frontend/src/PeakHistoryChart.tsx
Normal file
114
energy-management/frontend/src/PeakHistoryChart.tsx
Normal file
@@ -0,0 +1,114 @@
|
||||
import { useMemo, useState } from "react";
|
||||
import type { BlockHistoryPoint } from "./api";
|
||||
|
||||
interface DailyPeak {
|
||||
dayStartMs: number;
|
||||
peakW: number;
|
||||
}
|
||||
|
||||
function toDailyPeaks(points: BlockHistoryPoint[]): DailyPeak[] {
|
||||
const byDay = new Map<number, number>();
|
||||
for (const p of points) {
|
||||
const dayStart = new Date(p.blockStartMs);
|
||||
dayStart.setHours(0, 0, 0, 0);
|
||||
const key = dayStart.getTime();
|
||||
byDay.set(key, Math.max(byDay.get(key) ?? 0, p.averageW));
|
||||
}
|
||||
return [...byDay.entries()]
|
||||
.map(([dayStartMs, peakW]) => ({ dayStartMs, peakW }))
|
||||
.sort((a, b) => a.dayStartMs - b.dayStartMs);
|
||||
}
|
||||
|
||||
const WIDTH = 880;
|
||||
const HEIGHT = 220;
|
||||
const PAD = { top: 16, right: 16, bottom: 28, left: 48 };
|
||||
|
||||
export function PeakHistoryChart({ points, targetW }: { points: BlockHistoryPoint[]; targetW: number }) {
|
||||
const daily = useMemo(() => toDailyPeaks(points), [points]);
|
||||
const [hoverIdx, setHoverIdx] = useState<number | null>(null);
|
||||
|
||||
if (daily.length === 0) {
|
||||
return <p style={{ color: "var(--text-muted)", fontSize: 13 }}>No history yet — check back after the first day of data.</p>;
|
||||
}
|
||||
|
||||
const maxW = Math.max(targetW, ...daily.map((d) => d.peakW)) * 1.1;
|
||||
const innerW = WIDTH - PAD.left - PAD.right;
|
||||
const innerH = HEIGHT - PAD.top - PAD.bottom;
|
||||
|
||||
const x = (i: number) => PAD.left + (daily.length === 1 ? innerW / 2 : (i / (daily.length - 1)) * innerW);
|
||||
const y = (w: number) => PAD.top + innerH - (w / maxW) * innerH;
|
||||
|
||||
const linePath = daily.map((d, i) => `${i === 0 ? "M" : "L"}${x(i)},${y(d.peakW)}`).join(" ");
|
||||
const targetY = y(targetW);
|
||||
const hovered = hoverIdx !== null ? daily[hoverIdx] : null;
|
||||
|
||||
return (
|
||||
<div style={{ position: "relative" }}>
|
||||
<svg viewBox={`0 0 ${WIDTH} ${HEIGHT}`} style={{ width: "100%", height: "auto", display: "block" }}>
|
||||
{[0, 0.25, 0.5, 0.75, 1].map((f) => (
|
||||
<line
|
||||
key={f}
|
||||
x1={PAD.left}
|
||||
x2={WIDTH - PAD.right}
|
||||
y1={PAD.top + innerH * (1 - f)}
|
||||
y2={PAD.top + innerH * (1 - f)}
|
||||
stroke="var(--gridline)"
|
||||
strokeWidth={1}
|
||||
/>
|
||||
))}
|
||||
|
||||
<line x1={PAD.left} x2={WIDTH - PAD.right} y1={targetY} y2={targetY} stroke="var(--status-critical)" strokeWidth={1.5} strokeDasharray="4 4" />
|
||||
<text x={WIDTH - PAD.right} y={targetY - 6} textAnchor="end" fontSize={11} fill="var(--status-critical)">
|
||||
target {Math.round(targetW / 1000)} kW
|
||||
</text>
|
||||
|
||||
<path d={linePath} fill="none" stroke="var(--series-1)" strokeWidth={2} strokeLinecap="round" strokeLinejoin="round" />
|
||||
|
||||
{daily.map((d, i) => (
|
||||
<circle
|
||||
key={d.dayStartMs}
|
||||
cx={x(i)}
|
||||
cy={y(d.peakW)}
|
||||
r={hoverIdx === i ? 6 : 4}
|
||||
fill={d.peakW > targetW ? "var(--status-critical)" : "var(--series-1)"}
|
||||
stroke="var(--surface-1)"
|
||||
strokeWidth={2}
|
||||
onMouseEnter={() => setHoverIdx(i)}
|
||||
onMouseLeave={() => setHoverIdx(null)}
|
||||
style={{ cursor: "pointer" }}
|
||||
/>
|
||||
))}
|
||||
|
||||
{daily.map((d, i) =>
|
||||
i % Math.ceil(daily.length / 8 || 1) === 0 ? (
|
||||
<text key={d.dayStartMs} x={x(i)} y={HEIGHT - 8} textAnchor="middle" fontSize={11} fill="var(--text-muted)">
|
||||
{new Date(d.dayStartMs).toLocaleDateString(undefined, { month: "short", day: "numeric" })}
|
||||
</text>
|
||||
) : null,
|
||||
)}
|
||||
</svg>
|
||||
|
||||
{hovered && (
|
||||
<div
|
||||
style={{
|
||||
position: "absolute",
|
||||
left: `${(x(hoverIdx!) / WIDTH) * 100}%`,
|
||||
top: 0,
|
||||
transform: "translate(-50%, -100%)",
|
||||
background: "var(--surface-1)",
|
||||
border: "1px solid var(--border)",
|
||||
borderRadius: 8,
|
||||
padding: "6px 10px",
|
||||
fontSize: 12,
|
||||
whiteSpace: "nowrap",
|
||||
pointerEvents: "none",
|
||||
boxShadow: "0 2px 8px rgba(0,0,0,0.15)",
|
||||
}}
|
||||
>
|
||||
<div style={{ fontWeight: 600 }}>{new Date(hovered.dayStartMs).toLocaleDateString()}</div>
|
||||
<div style={{ color: "var(--text-secondary)" }}>peak {(hovered.peakW / 1000).toFixed(2)} kW</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
71
energy-management/frontend/src/api.ts
Normal file
71
energy-management/frontend/src/api.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
export type ControlKind = "switch" | "number";
|
||||
|
||||
export interface DeviceConfig {
|
||||
id: number;
|
||||
entityId: string;
|
||||
name: string;
|
||||
kind: ControlKind;
|
||||
priority: number;
|
||||
minValue: number;
|
||||
maxValue: number;
|
||||
dwellSeconds: number;
|
||||
manualOverride: boolean;
|
||||
onlyWhenSun: boolean;
|
||||
fromTime: string | null;
|
||||
toTime: string | null;
|
||||
enableUrl: string | null;
|
||||
disableUrl: string | null;
|
||||
}
|
||||
|
||||
export interface DeviceStatus extends DeviceConfig {
|
||||
currentValue: number | null;
|
||||
shed: boolean;
|
||||
lastChangedAt: string | null;
|
||||
available: boolean;
|
||||
}
|
||||
|
||||
export interface QuarterHourStatus {
|
||||
blockStartMs: number;
|
||||
blockEndMs: number;
|
||||
elapsedFractionOfBlock: number;
|
||||
runningAverageW: number;
|
||||
projectedAverageW: number;
|
||||
targetW: number;
|
||||
overTarget: boolean;
|
||||
}
|
||||
|
||||
export interface ControlAction {
|
||||
timestampMs: number;
|
||||
entityId: string;
|
||||
action: "shed" | "restore";
|
||||
reason: string;
|
||||
}
|
||||
|
||||
export interface BlockHistoryPoint {
|
||||
blockStartMs: number;
|
||||
averageW: number;
|
||||
}
|
||||
|
||||
async function request<T>(path: string, init?: RequestInit): Promise<T> {
|
||||
const res = await fetch(path, {
|
||||
...init,
|
||||
headers: { ...(init?.body ? { "Content-Type": "application/json" } : {}), ...init?.headers },
|
||||
});
|
||||
if (!res.ok) throw new Error(`${init?.method ?? "GET"} ${path} failed: ${res.status}`);
|
||||
return res.json() as Promise<T>;
|
||||
}
|
||||
|
||||
export const api = {
|
||||
getStatus: () => request<{ quarterHour: QuarterHourStatus; devices: DeviceStatus[] }>("/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"),
|
||||
createDevice: (device: Omit<DeviceConfig, "id">) =>
|
||||
request<DeviceConfig>("/api/devices", { method: "POST", body: JSON.stringify(device) }),
|
||||
updateDevice: (id: number, patch: Partial<Omit<DeviceConfig, "id">>) =>
|
||||
request<{ ok: true }>(`/api/devices/${id}`, { method: "PATCH", body: JSON.stringify(patch) }),
|
||||
deleteDevice: (id: number) => request<{ ok: true }>(`/api/devices/${id}`, { method: "DELETE" }),
|
||||
getTarget: () => request<{ targetW: number }>("/api/settings/target"),
|
||||
setTargetKw: (targetKw: number) =>
|
||||
request<{ ok: true }>("/api/settings/target", { method: "PUT", body: JSON.stringify({ targetKw }) }),
|
||||
};
|
||||
10
energy-management/frontend/src/main.tsx
Normal file
10
energy-management/frontend/src/main.tsx
Normal file
@@ -0,0 +1,10 @@
|
||||
import { StrictMode } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { App } from "./App";
|
||||
import "./theme.css";
|
||||
|
||||
createRoot(document.getElementById("root")!).render(
|
||||
<StrictMode>
|
||||
<App />
|
||||
</StrictMode>,
|
||||
);
|
||||
260
energy-management/frontend/src/theme.css
Normal file
260
energy-management/frontend/src/theme.css
Normal file
@@ -0,0 +1,260 @@
|
||||
:root {
|
||||
color-scheme: light;
|
||||
--surface-1: #fcfcfb;
|
||||
--page-plane: #f9f9f7;
|
||||
--text-primary: #0b0b0b;
|
||||
--text-secondary: #52514e;
|
||||
--text-muted: #898781;
|
||||
--gridline: #e1e0d9;
|
||||
--baseline: #c3c2b7;
|
||||
--border: rgba(11, 11, 11, 0.1);
|
||||
--series-1: #2a78d6;
|
||||
--seq-300: #6da7ec;
|
||||
--seq-500: #256abf;
|
||||
--status-good: #0ca30c;
|
||||
--status-warning: #fab219;
|
||||
--status-critical: #d03b3b;
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root:where(:not([data-theme="light"])) {
|
||||
color-scheme: dark;
|
||||
--surface-1: #1a1a19;
|
||||
--page-plane: #0d0d0d;
|
||||
--text-primary: #ffffff;
|
||||
--text-secondary: #c3c2b7;
|
||||
--text-muted: #898781;
|
||||
--gridline: #2c2c2a;
|
||||
--baseline: #383835;
|
||||
--border: rgba(255, 255, 255, 0.1);
|
||||
--series-1: #3987e5;
|
||||
--seq-300: #5598e7;
|
||||
--seq-500: #1c5cab;
|
||||
--status-good: #0ca30c;
|
||||
--status-warning: #fab219;
|
||||
--status-critical: #d03b3b;
|
||||
}
|
||||
}
|
||||
|
||||
:root[data-theme="dark"] {
|
||||
color-scheme: dark;
|
||||
--surface-1: #1a1a19;
|
||||
--page-plane: #0d0d0d;
|
||||
--text-primary: #ffffff;
|
||||
--text-secondary: #c3c2b7;
|
||||
--text-muted: #898781;
|
||||
--gridline: #2c2c2a;
|
||||
--baseline: #383835;
|
||||
--border: rgba(255, 255, 255, 0.1);
|
||||
--series-1: #3987e5;
|
||||
--seq-300: #5598e7;
|
||||
--seq-500: #1c5cab;
|
||||
--status-good: #0ca30c;
|
||||
--status-warning: #fab219;
|
||||
--status-critical: #d03b3b;
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
background: var(--page-plane);
|
||||
color: var(--text-primary);
|
||||
font-family: system-ui, -apple-system, "Segoe UI", sans-serif;
|
||||
}
|
||||
|
||||
.app {
|
||||
max-width: 960px;
|
||||
margin: 0 auto;
|
||||
padding: 24px 20px 64px;
|
||||
}
|
||||
|
||||
nav.tabs {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
margin-bottom: 20px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
nav.tabs button {
|
||||
background: none;
|
||||
border: none;
|
||||
font: inherit;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
color: var(--text-secondary);
|
||||
padding: 10px 14px;
|
||||
cursor: pointer;
|
||||
border-bottom: 2px solid transparent;
|
||||
}
|
||||
|
||||
nav.tabs button.active {
|
||||
color: var(--text-primary);
|
||||
border-bottom-color: var(--series-1);
|
||||
}
|
||||
|
||||
.card {
|
||||
background: var(--surface-1);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 12px;
|
||||
padding: 20px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.card h2 {
|
||||
margin: 0 0 12px;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.03em;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.hero-figure {
|
||||
font-size: 40px;
|
||||
font-weight: 600;
|
||||
line-height: 1.1;
|
||||
}
|
||||
|
||||
.hero-sub {
|
||||
font-size: 13px;
|
||||
color: var(--text-muted);
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.status-pill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
padding: 4px 10px;
|
||||
border-radius: 999px;
|
||||
}
|
||||
|
||||
.status-pill.good {
|
||||
color: var(--status-good);
|
||||
background: color-mix(in srgb, var(--status-good) 14%, transparent);
|
||||
}
|
||||
|
||||
.status-pill.critical {
|
||||
color: var(--status-critical);
|
||||
background: color-mix(in srgb, var(--status-critical) 14%, transparent);
|
||||
}
|
||||
|
||||
.device-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 12px 0;
|
||||
border-bottom: 1px solid var(--gridline);
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.device-row:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.device-name {
|
||||
font-weight: 600;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.device-meta {
|
||||
font-size: 12px;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.badge {
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.03em;
|
||||
padding: 3px 8px;
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.badge.shed {
|
||||
color: var(--status-critical);
|
||||
background: color-mix(in srgb, var(--status-critical) 14%, transparent);
|
||||
}
|
||||
|
||||
.badge.active {
|
||||
color: var(--status-good);
|
||||
background: color-mix(in srgb, var(--status-good) 14%, transparent);
|
||||
}
|
||||
|
||||
.badge.override {
|
||||
color: var(--status-warning);
|
||||
background: color-mix(in srgb, var(--status-warning) 20%, transparent);
|
||||
}
|
||||
|
||||
input,
|
||||
select {
|
||||
font: inherit;
|
||||
padding: 6px 8px;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
background: var(--surface-1);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
button.primary {
|
||||
font: inherit;
|
||||
font-weight: 600;
|
||||
padding: 8px 14px;
|
||||
border-radius: 8px;
|
||||
border: none;
|
||||
background: var(--series-1);
|
||||
color: white;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
button.secondary {
|
||||
font: inherit;
|
||||
padding: 6px 10px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--border);
|
||||
background: transparent;
|
||||
color: var(--text-primary);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
th {
|
||||
text-align: left;
|
||||
color: var(--text-muted);
|
||||
font-weight: 600;
|
||||
font-size: 11px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.03em;
|
||||
padding: 6px 8px;
|
||||
border-bottom: 1px solid var(--gridline);
|
||||
}
|
||||
|
||||
td {
|
||||
padding: 8px;
|
||||
border-bottom: 1px solid var(--gridline);
|
||||
}
|
||||
|
||||
.form-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(140px, 1fr));
|
||||
gap: 10px;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.form-grid label {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
font-size: 12px;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
Reference in New Issue
Block a user