add configuration editor

This commit is contained in:
Mats Tornberg
2025-11-22 22:31:22 +00:00
parent b6343149c8
commit 83f77049e8
6 changed files with 1248 additions and 4 deletions

View File

@@ -500,6 +500,149 @@ input:focus {
animation: shimmer 1.8s linear infinite;
}
.config-panel {
margin-top: 32px;
}
.config-editor-grid {
display: grid;
grid-template-columns: minmax(260px, 320px) 1fr;
gap: 24px;
}
.config-device-list {
display: flex;
flex-direction: column;
gap: 12px;
}
.config-device-row {
width: 100%;
text-align: left;
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 6px;
border-radius: 16px;
border: 1px solid rgba(255, 255, 255, 0.12);
padding: 14px 16px;
background: rgba(5, 6, 10, 0.35);
}
.config-device-row.active {
border-color: var(--accent-color);
background: rgba(107, 140, 255, 0.12);
}
.config-device-name {
margin: 0;
font-weight: 600;
}
.config-device-row-top {
width: 100%;
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.config-device-empty {
padding: 18px;
border-radius: 16px;
border: 1px dashed rgba(255, 255, 255, 0.18);
text-align: center;
color: var(--text-secondary);
font-size: 0.9rem;
}
.config-form {
background: rgba(5, 6, 10, 0.35);
border: 1px solid var(--border-color);
border-radius: 20px;
padding: 22px 24px;
display: flex;
flex-direction: column;
}
.config-form-header {
display: flex;
justify-content: space-between;
align-items: center;
gap: 12px;
margin-bottom: 12px;
}
.config-form h3 {
margin: 0;
}
.input-label {
font-size: 0.75rem;
text-transform: uppercase;
letter-spacing: 0.12rem;
color: var(--text-secondary);
margin: 14px 0 6px;
}
.parameter-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-top: 6px;
}
.parameter-header h4 {
margin: 0;
text-transform: uppercase;
font-size: 0.85rem;
letter-spacing: 0.08rem;
color: var(--text-secondary);
}
.parameter-rows {
display: flex;
flex-direction: column;
gap: 10px;
margin-top: 12px;
}
.parameter-row {
display: grid;
grid-template-columns: 1fr 1fr auto;
gap: 10px;
align-items: center;
}
.parameter-row button {
height: 46px;
}
.config-feedback {
margin-top: 16px;
padding: 12px 16px;
border-radius: 14px;
font-size: 0.9rem;
border: 1px solid transparent;
}
.config-feedback.success {
background: rgba(77, 211, 156, 0.12);
border-color: rgba(77, 211, 156, 0.4);
color: var(--success-color);
}
.config-feedback.error {
background: rgba(255, 123, 123, 0.12);
border-color: rgba(255, 123, 123, 0.4);
color: var(--danger-color);
}
.config-form-actions {
margin-top: 20px;
justify-content: flex-start;
}
.events-list {
margin-top: 20px;
border-top: 1px solid rgba(255, 255, 255, 0.06);
@@ -553,4 +696,8 @@ input:focus {
width: 100%;
justify-content: flex-start;
}
.config-editor-grid {
grid-template-columns: 1fr;
}
}

View File

@@ -1,4 +1,4 @@
import { useMemo, useState, type CSSProperties } from "react";
import { useEffect, useMemo, useState, type CSSProperties } from "react";
import useSWR from "swr";
import useSWRMutation from "swr/mutation";
import "./App.css";
@@ -8,6 +8,56 @@ interface Device {
name: string;
}
interface ConfigDevice {
id: number;
name: string;
protocol: string;
model?: string;
parameters: Record<string, string>;
}
type ParameterPair = {
key: string;
value: string;
};
interface ConfigDeviceForm {
id: number | null;
name: string;
protocol: string;
model: string;
parameters: ParameterPair[];
}
const emptyParameterRow = (): ParameterPair => ({ key: "", value: "" });
const createEmptyConfigForm = (): ConfigDeviceForm => ({
id: null,
name: "",
protocol: "",
model: "",
parameters: [emptyParameterRow()],
});
const parametersToPairs = (
parameters?: Record<string, string>
): ParameterPair[] => {
if (!parameters || Object.keys(parameters).length === 0) {
return [emptyParameterRow()];
}
return Object.entries(parameters).map(([key, value]) => ({ key, value }));
};
const pairsToParameters = (pairs: ParameterPair[]): Record<string, string> => {
return pairs.reduce((acc, pair) => {
const trimmedKey = pair.key.trim();
if (trimmedKey) {
acc[trimmedKey] = pair.value.trim();
}
return acc;
}, {} as Record<string, string>);
};
interface PotentialDevice {
class: string;
protocol: string;
@@ -116,6 +166,15 @@ function App() {
null
);
const [isRefreshing, setIsRefreshing] = useState(false);
const [configSelection, setConfigSelection] = useState<"new" | number>(
"new"
);
const [configForm, setConfigForm] = useState<ConfigDeviceForm>(
createEmptyConfigForm
);
const [configFeedback, setConfigFeedback] = useState<
{ type: "success" | "error"; message: string } | null
>(null);
const {
data: devicesData,
@@ -135,12 +194,37 @@ function App() {
mutate: mutatePotentialDevices,
} = useSWR<PotentialDevice[]>("/api/potential_devices", fetcher);
const {
data: configDevicesData,
isLoading: configDevicesLoading,
mutate: mutateConfigDevices,
} = useSWR<ConfigDevice[]>("/api/config/devices", fetcher);
const devices = devicesData ?? [];
const sensors = sensorsData ?? [];
const potentialDevices = potentialDevicesData ?? [];
const configDevices = configDevicesData ?? [];
useEffect(() => {
if (configSelection === "new") {
return;
}
const selected = configDevices.find((device) => device.id === configSelection);
if (selected) {
setConfigForm({
id: selected.id,
name: selected.name,
protocol: selected.protocol,
model: selected.model ?? "",
parameters: parametersToPairs(selected.parameters),
});
}
}, [configSelection, configDevices]);
const { trigger: sendControlAction, isMutating: isControlMutating } =
useSWRMutation("control-action", mutationFetcher);
const { trigger: sendConfigAction, isMutating: isConfigMutating } =
useSWRMutation("config-action", mutationFetcher);
const groupedPotentialDevices = useMemo(() => {
const groups: Record<string, PotentialDevice[]> = {};
@@ -161,6 +245,7 @@ function App() {
() => sensors.filter((sensor) => !sensor.hidden).length,
[sensors]
);
const configDeviceCount = configDevices.length;
const latestSensorUpdate = useMemo(() => {
const timestamps = sensors
@@ -176,6 +261,7 @@ function App() {
const dashboardStats = useMemo(
() => [
{ label: "Devices", value: devices.length, accent: "accent" },
{ label: "Config devices", value: configDeviceCount, accent: "accent" },
{ label: "Visible sensors", value: visibleSensors, accent: "success" },
{ label: "Hidden sensors", value: hiddenSensors, accent: "warning" },
{
@@ -184,7 +270,13 @@ function App() {
accent: "muted",
},
],
[devices.length, visibleSensors, hiddenSensors, groupedPotentialDevices]
[
devices.length,
configDeviceCount,
visibleSensors,
hiddenSensors,
groupedPotentialDevices,
]
);
const refreshAll = async () => {
@@ -262,6 +354,150 @@ function App() {
await mutateSensors();
};
const startCreateConfigDevice = () => {
setConfigSelection("new");
setConfigForm(createEmptyConfigForm());
setConfigFeedback(null);
};
const startEditingConfigDevice = (deviceId: number) => {
setConfigSelection(deviceId);
setConfigFeedback(null);
};
const updateConfigField = (
field: "name" | "protocol" | "model",
value: string
) => {
setConfigForm((prev) => ({ ...prev, [field]: value }));
};
const updateParameterRow = (
index: number,
field: "key" | "value",
value: string
) => {
setConfigForm((prev) => {
const nextParameters = prev.parameters.map((param, idx) =>
idx === index ? { ...param, [field]: value } : param
);
return { ...prev, parameters: nextParameters };
});
};
const addParameterRow = () => {
setConfigForm((prev) => ({
...prev,
parameters: [...prev.parameters, emptyParameterRow()],
}));
};
const removeParameterRow = (index: number) => {
setConfigForm((prev) => {
const nextParameters = prev.parameters.filter((_, idx) => idx !== index);
return {
...prev,
parameters: nextParameters.length ? nextParameters : [emptyParameterRow()],
};
});
};
const resetConfigForm = () => {
setConfigFeedback(null);
if (configSelection === "new") {
setConfigForm(createEmptyConfigForm());
return;
}
const selected = configDevices.find((device) => device.id === configSelection);
if (selected) {
setConfigForm({
id: selected.id,
name: selected.name,
protocol: selected.protocol,
model: selected.model ?? "",
parameters: parametersToPairs(selected.parameters),
});
}
};
const handleSaveConfigDevice = async () => {
setConfigFeedback(null);
const isNew = configSelection === "new";
const trimmedName = configForm.name.trim();
const trimmedProtocol = configForm.protocol.trim();
if (!trimmedName || !trimmedProtocol) {
setConfigFeedback({
type: "error",
message: "Name and protocol are required.",
});
return;
}
const basePayload = {
name: trimmedName,
protocol: trimmedProtocol,
model: configForm.model.trim() || undefined,
parameters: pairsToParameters(configForm.parameters),
};
const payload = isNew
? basePayload
: { ...basePayload, id: configSelection };
const url = isNew
? "/api/config/devices"
: `/api/config/devices/${configSelection}`;
const method = isNew ? "POST" : "PUT";
try {
const result = (await sendConfigAction({
url,
method,
data: payload,
})) as ConfigDevice | null;
await mutateConfigDevices();
if (isNew) {
if (result?.id) {
setConfigSelection(result.id);
} else {
startCreateConfigDevice();
}
setConfigFeedback({ type: "success", message: "Device created." });
} else {
setConfigFeedback({ type: "success", message: "Device updated." });
}
} catch (error) {
setConfigFeedback({
type: "error",
message:
error instanceof Error ? error.message : "Failed to save device.",
});
}
};
const handleDeleteConfigDevice = async () => {
if (configSelection === "new") {
return;
}
const confirmed = window.confirm(
"Delete this config device from tellstick.conf?"
);
if (!confirmed) {
return;
}
try {
await sendConfigAction({
url: `/api/config/devices/${configSelection}`,
method: "DELETE",
});
await mutateConfigDevices();
startCreateConfigDevice();
setConfigFeedback({ type: "success", message: "Device deleted." });
} catch (error) {
setConfigFeedback({
type: "error",
message:
error instanceof Error ? error.message : "Failed to delete device.",
});
}
};
return (
<div className="app">
<header className="hero">
@@ -542,6 +778,179 @@ function App() {
</section>
</div>
<section className="panel config-panel">
<div className="panel-head">
<div>
<p className="eyebrow">tellstick.conf</p>
<h2>Configuration Editor</h2>
</div>
<span className="pill">{configDeviceCount} defined</span>
</div>
{configDevicesLoading ? (
<div className="empty-state shimmer">Loading configuration</div>
) : (
<div className="config-editor-grid">
<div className="config-device-list">
<button
type="button"
className={`config-device-row ${
configSelection === "new" ? "active" : ""
}`}
onClick={startCreateConfigDevice}
disabled={isConfigMutating}
>
<div>
<p className="config-device-name"> New device</p>
<p className="device-meta">Start from scratch</p>
</div>
</button>
{configDevices.length === 0 ? (
<div className="config-device-empty">
No devices in config yet.
</div>
) : (
configDevices.map((device) => (
<button
type="button"
key={device.id}
className={`config-device-row ${
configSelection === device.id ? "active" : ""
}`}
onClick={() => startEditingConfigDevice(device.id)}
disabled={isConfigMutating}
>
<div className="config-device-row-top">
<p className="config-device-name">
{device.name || `Device #${device.id}`}
</p>
<span className="badge badge-protocol">
{device.protocol}
</span>
</div>
<p className="device-meta">
#{device.id} · {device.model || "unknown model"}
</p>
</button>
))
)}
</div>
<div className="config-form">
<div className="config-form-header">
<h3>
{configSelection === "new"
? "Create new config device"
: `Editing device #${configSelection}`}
</h3>
{configSelection !== "new" && (
<button
type="button"
className="danger"
onClick={handleDeleteConfigDevice}
disabled={isConfigMutating}
>
Delete
</button>
)}
</div>
<label className="input-label" htmlFor="config-name">
Name
</label>
<input
id="config-name"
value={configForm.name}
onChange={(event) =>
updateConfigField("name", event.target.value)
}
placeholder="Living room lamp"
/>
<label className="input-label" htmlFor="config-protocol">
Protocol
</label>
<input
id="config-protocol"
value={configForm.protocol}
onChange={(event) =>
updateConfigField("protocol", event.target.value)
}
placeholder="arctech"
/>
<label className="input-label" htmlFor="config-model">
Model (optional)
</label>
<input
id="config-model"
value={configForm.model}
onChange={(event) =>
updateConfigField("model", event.target.value)
}
placeholder="selflearning-switch"
/>
<div className="parameter-header">
<h4>Parameters</h4>
<button
type="button"
className="outline"
onClick={addParameterRow}
disabled={isConfigMutating}
>
Add parameter
</button>
</div>
<div className="parameter-rows">
{configForm.parameters.map((parameter, index) => (
<div className="parameter-row" key={`parameter-${index}`}>
<input
value={parameter.key}
placeholder="Key (house, unit, code…)"
onChange={(event) =>
updateParameterRow(index, "key", event.target.value)
}
/>
<input
value={parameter.value}
placeholder="Value"
onChange={(event) =>
updateParameterRow(index, "value", event.target.value)
}
/>
<button
type="button"
className="icon-btn subtle"
onClick={() => removeParameterRow(index)}
>
</button>
</div>
))}
</div>
{configFeedback && (
<div className={`config-feedback ${configFeedback.type}`}>
{configFeedback.message}
</div>
)}
<div className="actions config-form-actions">
<button
type="button"
className="primary"
onClick={handleSaveConfigDevice}
disabled={isConfigMutating}
>
{configSelection === "new" ? "Create device" : "Save changes"}
</button>
<button
type="button"
className="ghost"
onClick={resetConfigForm}
disabled={isConfigMutating}
>
Reset
</button>
</div>
</div>
</div>
)}
</section>
<section className="panel">
<div className="panel-head">
<div>