fix: various fixes

This commit is contained in:
Willem Serruys
2026-08-21 11:49:49 +02:00
parent 161b226db9
commit 43fda71c1b
10 changed files with 441 additions and 185 deletions

View File

@@ -13,6 +13,18 @@ INFLUX_FIELD_UNIT=W
HA_URL=http://homeassistant.local:8123
HA_TOKEN=replace-with-a-long-lived-access-token
# Solar inverter production, read from InfluxDB (same bucket as the house
# power series above). Its latest sample is compared against
# SOLAR_PRODUCTION_THRESHOLD_W to gate "only when sun" devices. Leave
# SOLAR_INFLUX_ENTITY_ID unset to fall back to HA's sun.sun (astronomical
# above/below horizon).
SOLAR_INFLUX_MEASUREMENT=power
SOLAR_INFLUX_DOMAIN=sensor
SOLAR_INFLUX_ENTITY_ID=inverter_pv_power
SOLAR_INFLUX_FIELD=value
SOLAR_INFLUX_FIELD_UNIT=W
SOLAR_PRODUCTION_THRESHOLD_W=50
# Capacity tariff target: keep every 15-minute block's average under this many kW
TARGET_KW=5

View File

@@ -6,7 +6,7 @@ export interface LoopDecisionInput {
nowMs: number;
/** Only restore once projected power is at least this far below target, to avoid flapping. */
restoreMarginW: number;
/** Whether HA's sun.sun entity currently reports above_horizon. */
/** Whether the solar inverter is currently producing power (see HomeAssistantClient.isSolarProducing). */
sunUp: boolean;
}

View File

@@ -1,10 +1,14 @@
import type { QueryApi } from "@influxdata/influxdb-client";
import type { Env } from "../env.js";
import { fetchPowerSamplesSince } from "../influx/power.js";
import {
fetchLatestPowerSample,
fetchLatestSolarProductionSample,
fetchPowerSamplesSince,
} from "../influx/power.js";
import { HomeAssistantClient } from "../homeassistant/client.js";
import { getDeviceStatus, restoreDevice, shedDevice } from "../homeassistant/devices.js";
import type { Store } from "../db/store.js";
import type { DeviceStatus, QuarterHourStatus } from "../types.js";
import type { CurrentReadings, DeviceStatus, QuarterHourStatus } from "../types.js";
import { blockStartFor, computeQuarterHourStatus } from "./quarterHour.js";
import { decideControlAction } from "./loop.js";
@@ -19,17 +23,24 @@ export class ControlLoopRunner {
private readonly log: { info: (o: unknown, msg?: string) => void; error: (o: unknown, msg?: string) => void },
) {}
async getStatus(): Promise<{ quarterHour: QuarterHourStatus; devices: DeviceStatus[] }> {
async getStatus(): Promise<{ quarterHour: QuarterHourStatus; devices: DeviceStatus[]; current: CurrentReadings }> {
const nowMs = Date.now();
const blockStart = blockStartFor(nowMs);
const targetW = this.store.getTargetW();
const samples = await fetchPowerSamplesSince(this.queryApi, this.env, blockStart);
const [samples, latestPower, latestSolar, devices] = await Promise.all([
fetchPowerSamplesSince(this.queryApi, this.env, blockStart),
fetchLatestPowerSample(this.queryApi, this.env),
fetchLatestSolarProductionSample(this.queryApi, this.env),
this.loadDeviceStatuses(),
]);
const quarterHour = computeQuarterHourStatus(samples, nowMs, targetW);
const current: CurrentReadings = {
powerW: latestPower?.watts ?? null,
solarW: latestSolar?.watts ?? null,
};
const devices = await this.loadDeviceStatuses();
return { quarterHour, devices };
return { quarterHour, devices, current };
}
private async loadDeviceStatuses(): Promise<DeviceStatus[]> {
@@ -42,6 +53,26 @@ export class ControlLoopRunner {
);
}
/**
* Whether the solar inverter is currently producing power, used to gate
* `onlyWhenSun` devices. Reads the configured InfluxDB solar production
* series and compares its latest sample against SOLAR_PRODUCTION_THRESHOLD_W.
* Falls back to HA's sun.sun (astronomical above/below horizon) when no
* solar production series is configured, or if either read fails.
*/
private async isSolarProducing(): Promise<boolean> {
try {
const sample = await fetchLatestSolarProductionSample(this.queryApi, this.env);
if (sample) return sample.watts >= this.env.SOLAR_PRODUCTION_THRESHOLD_W;
} catch (err) {
this.log.error({ err }, "failed to read solar production series; falling back to sun.sun");
}
return this.ha.isSunUp().catch((err) => {
this.log.error({ err }, "failed to read sun.sun state; treating as unrestricted");
return true;
});
}
async tick(): Promise<void> {
const nowMs = Date.now();
const { quarterHour, devices } = await this.getStatus();
@@ -50,10 +81,7 @@ export class ControlLoopRunner {
// as that block's final average, with no separate "finalize" step needed.
this.store.upsertBlockHistory(quarterHour.blockStartMs, quarterHour.runningAverageW);
const sunUp = await this.ha.isSunUp().catch((err) => {
this.log.error({ err }, "failed to read sun.sun state; treating as unrestricted");
return true;
});
const sunUp = await this.isSolarProducing();
const decision = decideControlAction({ status: quarterHour, devices, nowMs, restoreMarginW: RESTORE_MARGIN_W, sunUp });
if (!decision) return;

View File

@@ -20,6 +20,15 @@ const envSchema = z.object({
INFLUX_FIELD_UNIT: z.enum(["W", "kW"]).default("W"),
HA_URL: z.string().url(),
HA_TOKEN: z.string().min(1),
// Solar inverter production, read from InfluxDB (same shape as the house
// power series above). Leave SOLAR_INFLUX_ENTITY_ID unset to fall back to
// sun.sun (astronomical day/night).
SOLAR_INFLUX_MEASUREMENT: z.string().default("power"),
SOLAR_INFLUX_DOMAIN: z.string().optional(),
SOLAR_INFLUX_ENTITY_ID: z.string().optional(),
SOLAR_INFLUX_FIELD: z.string().default("value"),
SOLAR_INFLUX_FIELD_UNIT: z.enum(["W", "kW"]).default("W"),
SOLAR_PRODUCTION_THRESHOLD_W: z.coerce.number().default(50),
TARGET_KW: z.coerce.number().positive(),
DB_PATH: z.string().default("./data/energy.db"),
CONTROL_LOOP_INTERVAL_MS: z.coerce.number().default(10_000),

View File

@@ -2,27 +2,40 @@ import type { QueryApi } from "@influxdata/influxdb-client";
import type { Env } from "../env.js";
import type { PowerSample } from "../types.js";
interface SeriesConfig {
bucket: string;
measurement: string;
field: string;
fieldUnit: "W" | "kW";
domain?: string;
entityId?: string;
}
/**
* Fetches raw power samples between `sinceMs` and now.
* Converts the configured field to watts based on INFLUX_FIELD_UNIT.
* Fetches raw samples for one series between `sinceMs` and now, converting
* the configured field to watts based on `fieldUnit`.
*/
export async function fetchPowerSamplesSince(
async function fetchSeriesSince(
queryApi: QueryApi,
env: Env,
config: SeriesConfig,
sinceMs: number,
): Promise<PowerSample[]> {
const sinceIso = new Date(sinceMs).toISOString();
const domainFilter = config.domain
? `|> filter(fn: (r) => r["domain"] == "${config.domain}")\n `
: "";
const entityFilter = config.entityId
? `|> filter(fn: (r) => r["entity_id"] == "${config.entityId}")\n `
: "";
const flux = `
from(bucket: "${env.INFLUX_BUCKET}")
from(bucket: "${config.bucket}")
|> range(start: ${sinceIso})
|> filter(fn: (r) => r["_measurement"] == "${env.INFLUX_FIELD_UNIT}")
|> filter(fn: (r) => r["_field"] == "${env.INFLUX_FIELD}")
|> filter(fn: (r) => r["domain"] == "${env.INFLUX_DOMAIN}")
|> filter(fn: (r) => r["entity_id"] == "${env.INFLUX_ENTITY_ID}")
|> sort(columns: ["_time"])
|> filter(fn: (r) => r["_measurement"] == "${config.fieldUnit}")
|> filter(fn: (r) => r["_field"] == "${config.field}")
${domainFilter}${entityFilter}|> sort(columns: ["_time"])
`;
const unitMultiplier = env.INFLUX_FIELD_UNIT === "kW" ? 1000 : 1;
const unitMultiplier = config.fieldUnit === "kW" ? 1000 : 1;
const samples: PowerSample[] = [];
for await (const { values, tableMeta } of queryApi.iterateRows(flux)) {
@@ -36,6 +49,29 @@ export async function fetchPowerSamplesSince(
return samples;
}
function housePowerConfig(env: Env): SeriesConfig {
return {
bucket: env.INFLUX_BUCKET,
measurement: env.INFLUX_MEASUREMENT,
field: env.INFLUX_FIELD,
fieldUnit: env.INFLUX_FIELD_UNIT,
domain: env.INFLUX_DOMAIN,
entityId: env.INFLUX_ENTITY_ID,
};
}
/**
* Fetches raw power samples between `sinceMs` and now.
* Converts the configured field to watts based on INFLUX_FIELD_UNIT.
*/
export async function fetchPowerSamplesSince(
queryApi: QueryApi,
env: Env,
sinceMs: number,
): Promise<PowerSample[]> {
return fetchSeriesSince(queryApi, housePowerConfig(env), sinceMs);
}
/** Fetches the single most recent power sample, if any exists within the lookback window. */
export async function fetchLatestPowerSample(
queryApi: QueryApi,
@@ -49,3 +85,26 @@ export async function fetchLatestPowerSample(
);
return samples.length > 0 ? samples[samples.length - 1] : null;
}
/**
* Fetches the most recent solar production sample from InfluxDB, if any
* exists within the lookback window. Returns null when no solar production
* series is configured (SOLAR_INFLUX_ENTITY_ID unset).
*/
export async function fetchLatestSolarProductionSample(
queryApi: QueryApi,
env: Env,
lookbackMs = 60_000,
): Promise<PowerSample | null> {
if (!env.SOLAR_INFLUX_ENTITY_ID) return null;
const config: SeriesConfig = {
bucket: env.INFLUX_BUCKET,
measurement: env.SOLAR_INFLUX_MEASUREMENT,
field: env.SOLAR_INFLUX_FIELD,
fieldUnit: env.SOLAR_INFLUX_FIELD_UNIT,
domain: env.SOLAR_INFLUX_DOMAIN,
entityId: env.SOLAR_INFLUX_ENTITY_ID,
};
const samples = await fetchSeriesSince(queryApi, config, Date.now() - lookbackMs);
return samples.length > 0 ? samples[samples.length - 1] : null;
}

View File

@@ -10,7 +10,7 @@ export interface DeviceConfig {
maxValue: number; // for "number" kind: normal operating value; for "switch": unused (1)
dwellSeconds: number; // minimum time between state changes for this device
manualOverride: boolean; // if true, control loop leaves this device alone
onlyWhenSun: boolean; // if true, device may only be enabled while sun.sun reports above_horizon
onlyWhenSun: boolean; // if true, device may only be enabled while the solar inverter is producing power
fromTime: string | null; // "HH:MM", start of allowed enable window (local time), null = no restriction
toTime: string | null; // "HH:MM", end of allowed enable window (local time), null = no restriction
enableUrl: string | null; // if set, called (HTTP POST) to enable/restore the device instead of Home Assistant
@@ -39,6 +39,11 @@ export interface QuarterHourStatus {
overTarget: boolean;
}
export interface CurrentReadings {
powerW: number | null; // most recent house power sample, null if none within lookback
solarW: number | null; // most recent solar production sample, null if unconfigured or none within lookback
}
export interface ControlAction {
timestampMs: number;
entityId: string;

View File

@@ -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>

View File

@@ -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>
</>

View File

@@ -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"),

View File

@@ -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);