add configuration editor
This commit is contained in:
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user