beta. Modbus fixed and tested

This commit is contained in:
jhartworks
2026-06-30 09:42:55 +02:00
parent 2ce3be068e
commit e34626f15b
8 changed files with 1355 additions and 246 deletions
+349 -157
View File
@@ -20,8 +20,43 @@ const adminSections = [
{ id: "debug", label: "Debug" },
];
const TREND_PRESET_OPTIONS = [
{ value: "0.0034722222", label: "5 min" },
{ value: "0.0069444444", label: "10 min" },
{ value: "0.0208333333", label: "30 min" },
{ value: "0.0416666667", label: "1 h" },
{ value: "0.0833333333", label: "2 h" },
{ value: "0.25", label: "6 h" },
{ value: "0.5", label: "12 h" },
{ value: "1", label: "24 h" },
{ value: "2", label: "2 Tage" },
{ value: "3", label: "3 Tage" },
{ value: "7", label: "7 Tage" },
{ value: "14", label: "14 Tage" },
{ value: "30", label: "30 Tage" },
];
const SCALE_PRESET_OPTIONS = ["1", "0.1", "0.01", "0.001", "10", "100", "1000"];
const MODBUS_DATA_TYPE_OPTIONS = [
{ value: "coil", label: "Coil" },
{ value: "discrete-input", label: "Discrete Input" },
{ value: "input-register", label: "Input Register (uint16)" },
{ value: "input-register-int16", label: "Input Register (int16)" },
{ value: "input-register-int32", label: "Input Register (int32)" },
{ value: "input-register-uint32", label: "Input Register (uint32)" },
{ value: "input-register-float32", label: "Input Register (float32)" },
{ value: "input-register-float32-swap", label: "Input Register (float32, Word-Swap)" },
{ value: "holding-register", label: "Holding Register (uint16)" },
{ value: "holding-register-int16", label: "Holding Register (int16)" },
{ value: "holding-register-int32", label: "Holding Register (int32)" },
{ value: "holding-register-uint32", label: "Holding Register (uint32)" },
{ value: "holding-register-float32", label: "Holding Register (float32)" },
{ value: "holding-register-float32-swap", label: "Holding Register (float32, Word-Swap)" },
];
const emptyTrendRange = {
presetDays: 1,
presetDays: "1",
from: "",
to: "",
maxRows: 4000,
@@ -129,6 +164,13 @@ function getDefaultPointDataType(protocol) {
return "holding-register";
}
function getPointDataTypeOptions(protocol) {
if (protocol === "modbus-tcp") {
return MODBUS_DATA_TYPE_OPTIONS;
}
return [];
}
function getOpcuaScanDatapointId(sourceId, node) {
return sourceId && node?.nodeId ? `${sourceId}::${node.nodeId}` : "";
}
@@ -427,6 +469,33 @@ function UnitField({ units, value, onChange }) {
);
}
function ScaleField({ value, onChange }) {
const normalizedValue = String(value ?? "1");
const selectedValue = SCALE_PRESET_OPTIONS.includes(normalizedValue) ? normalizedValue : "__custom__";
return (
<div className="unit-field">
<select
value={selectedValue}
onChange={(event) => {
const nextValue = event.target.value;
if (nextValue === "__custom__") {
onChange(SCALE_PRESET_OPTIONS.includes(normalizedValue) ? "" : normalizedValue);
return;
}
onChange(nextValue);
}}
>
{SCALE_PRESET_OPTIONS.map((option) => <option key={option} value={option}>{option}</option>)}
<option value="__custom__">Benutzerdefiniert</option>
</select>
{selectedValue === "__custom__" ? (
<input value={value || ""} placeholder="Eigener Faktor" onChange={(event) => onChange(event.target.value)} />
) : null}
</div>
);
}
function ColorField({ value, onChange }) {
const normalizedValue = hslToHexString(value || "");
const pickerValue = /^#(?:[0-9a-fA-F]{3}){1,2}$/.test(normalizedValue || "") ? normalizedValue : "#d62839";
@@ -475,8 +544,8 @@ function IconButton({ title, onClick, children, danger = false }) {
}
function LoginScreen({ onLogin, error, busy }) {
const [username, setUsername] = useState("admin");
const [password, setPassword] = useState("ChangeMe123!");
const [username, setUsername] = useState("");
const [password, setPassword] = useState("");
const handleSubmit = async (event) => {
event.preventDefault();
@@ -637,6 +706,7 @@ export default function App() {
const [units, setUnits] = useState(DEFAULT_UNIT_OPTIONS);
const [unitDraft, setUnitDraft] = useState({ symbol: "", label: "" });
const [editingUnitSymbol, setEditingUnitSymbol] = useState("");
const [editingCollectorPointId, setEditingCollectorPointId] = useState("");
const [selectedSourceId, setSelectedSourceId] = useState("");
const [isCreatingSource, setIsCreatingSource] = useState(false);
const [sourceDraft, setSourceDraft] = useState(defaultSourceDraft);
@@ -681,6 +751,40 @@ export default function App() {
const availableCollectorPoints = useMemo(() => collectorSourcePoints.filter((item) => item.enabled !== true), [collectorSourcePoints]);
const loggedCollectorPoints = useMemo(() => collectorSourcePoints.filter((item) => item.enabled === true), [collectorSourcePoints]);
const loggedCollectorPointIds = useMemo(() => new Set(loggedCollectorPoints.map((item) => String(item.id))), [loggedCollectorPoints]);
const sourceToolProtocol = currentSource?.protocol || sourceDraft.protocol;
const upsertIspEntry = (entry) => {
if (!entry?.tableName) {
return;
}
setIsps((current) => {
const map = new Map(current.map((item) => [item.tableName, item]));
const previous = map.get(entry.tableName) || {};
map.set(entry.tableName, {
...previous,
...entry,
tableName: entry.tableName,
displayName: entry.displayName || previous.displayName || entry.tableName,
description: entry.description ?? previous.description ?? "",
storage: entry.storage || previous.storage || (String(entry.tableName).startsWith("trend_") ? "narrow" : "wide"),
pointCount: entry.pointCount ?? previous.pointCount ?? 0,
rowCount: entry.rowCount ?? previous.rowCount ?? 0,
latestTimestamp: entry.latestTimestamp ?? previous.latestTimestamp ?? null,
});
return Array.from(map.values()).sort((left, right) => left.tableName.localeCompare(right.tableName));
});
};
const refreshIspCatalog = async () => {
if (!token) {
return [];
}
const result = await api.getIsps(token);
const nextIsps = result.isps || [];
setIsps(nextIsps);
return nextIsps;
};
const filteredOpcuaNodes = useMemo(
() => (scanResults.opcua?.nodes || []).filter((node) => matchesSearch(collectorPointFilter, [
node.displayName,
@@ -767,7 +871,7 @@ export default function App() {
setThemeMode(pref.theme || "system");
setCsvDelimiter(pref.csvDelimiter || ";");
setSidebarPinned(typeof pref.sidebarPinned === "boolean" ? pref.sidebarPinned : true);
setIsps(ispResult.isps);
setIsps(ispResult.isps || []);
setSelections(selectionResult.selections);
setDashboard(dashboardResult);
setActiveView("dashboard");
@@ -940,6 +1044,33 @@ export default function App() {
.catch((error) => setBootError(error.message));
}, [canEditAliases, canManageSources, token]);
useEffect(() => {
if (!token) {
return undefined;
}
let ignore = false;
const refreshIsps = () => {
refreshIspCatalog().catch(() => undefined);
};
const handleVisibilityChange = () => {
if (!document.hidden) {
refreshIsps();
}
};
const timer = window.setInterval(refreshIsps, 5000);
window.addEventListener("focus", refreshIsps);
document.addEventListener("visibilitychange", handleVisibilityChange);
return () => {
ignore = true;
window.clearInterval(timer);
window.removeEventListener("focus", refreshIsps);
document.removeEventListener("visibilitychange", handleVisibilityChange);
};
}, [token]);
useEffect(() => {
if (!token || !(canEditAliases || canManageSources || canViewDebug)) {
setCollectorCapabilities(null);
@@ -1382,8 +1513,7 @@ export default function App() {
try {
const sql = await file.text();
await api.importSql(token, { filename: file.name, sql });
const refreshed = await api.getIsps(token);
setIsps(refreshed.isps);
await refreshIspCatalog();
setSuccessMessage("SQL-Datei importiert.");
} catch (error) {
setBootError(error.message || "SQL-Datei konnte nicht importiert werden.");
@@ -1401,8 +1531,15 @@ export default function App() {
resetFlash();
try {
const result = await api.createTrendTable(token, newTableDraft);
const refreshed = await api.getIsps(token);
setIsps(refreshed.isps);
upsertIspEntry({
tableName: result.tableName,
displayName: result.metadata?.displayName || newTableDraft.displayName || result.tableName,
description: result.metadata?.description || "",
storage: "narrow",
pointCount: 0,
rowCount: 0,
});
await refreshIspCatalog().catch(() => undefined);
setAdminIsp(result.tableName);
setNewTableDraft({ tableName: "", displayName: "" });
setSuccessMessage(`Trendtabelle ${result.tableName} angelegt.`);
@@ -1475,10 +1612,17 @@ export default function App() {
displayName: payload.displayName,
});
upsertIspEntry({
tableName: payload.tableName,
displayName: payload.displayName,
description: "",
storage: "narrow",
pointCount: 0,
rowCount: 0,
});
await refreshCollectorData();
await refreshCollectorDebug().catch(() => undefined);
const refreshed = await api.getIsps(token);
setIsps(refreshed.isps);
await refreshIspCatalog().catch(() => undefined);
setIsCreatingSource(false);
setSelectedSourceId(saved.id);
setSourceModalOpen(false);
@@ -1520,6 +1664,26 @@ export default function App() {
downloadText("modbus-muster.csv", sample, "text/csv;charset=utf-8;");
};
const resetManualDatapointEditor = (protocol = sourceDraft.protocol || defaultSourceDraft.protocol) => {
setEditingCollectorPointId("");
setManualDatapointDraft(emptyManualDatapointDraft(protocol));
};
const editCollectorPoint = (datapoint) => {
setEditingCollectorPointId(datapoint.id);
setManualDatapointDraft({
alias: datapoint.alias || datapoint.name || "",
address: datapoint.address || datapoint.nodeId || "",
pointIndex: String(datapoint.pointIndex || ""),
unit: normalizeDisplayUnit(datapoint.unit || "°C"),
dataType: datapoint.dataType || getDefaultPointDataType(sourceDraft.protocol),
kind: datapoint.kind || "analog",
scale: String(datapoint.scale ?? "1"),
writeMode: datapoint.writeMode === "cov" ? "cov" : datapoint.writeMode || "",
condition: datapoint.condition || "",
});
};
const saveManualCollectorDatapoint = async () => {
const activeSourceId = selectedSourceId || sourceDraft.id;
if (!activeSourceId) {
@@ -1529,7 +1693,7 @@ export default function App() {
resetFlash();
try {
await api.createCollectorDatapoint({
const payload = {
sourceId: activeSourceId,
protocol: sourceDraft.protocol,
alias: manualDatapointDraft.alias,
@@ -1543,11 +1707,17 @@ export default function App() {
scale: manualDatapointDraft.scale,
writeMode: manualDatapointDraft.writeMode,
condition: manualDatapointDraft.condition,
});
};
if (editingCollectorPointId) {
await api.updateCollectorDatapoint(editingCollectorPointId, payload);
} else {
await api.createCollectorDatapoint(payload);
}
await refreshCollectorData();
await refreshCollectorDebug().catch(() => undefined);
setManualDatapointDraft(emptyManualDatapointDraft(sourceDraft.protocol));
setSuccessMessage("Datenpunkt angelegt.");
resetManualDatapointEditor(sourceDraft.protocol);
setSuccessMessage(editingCollectorPointId ? "Datenpunkt aktualisiert." : "Datenpunkt angelegt.");
} catch (error) {
setBootError(error.message);
}
@@ -1864,8 +2034,10 @@ export default function App() {
</select>
</label>
<label>
<span>Tage</span>
<input type="number" min="1" max="365" value={widgetDraft.rangePresetDays} onChange={(event) => setWidgetDraft((current) => ({ ...current, rangePresetDays: event.target.value }))} />
<span>Zeitraum</span>
<select value={widgetDraft.rangePresetDays} onChange={(event) => setWidgetDraft((current) => ({ ...current, rangePresetDays: event.target.value }))}>
{TREND_PRESET_OPTIONS.map((option) => <option key={option.value} value={option.value}>{option.label}</option>)}
</select>
</label>
{widgetDraft.source === "saved" ? (
<label className="span-two">
@@ -2022,8 +2194,10 @@ export default function App() {
<div className="form-grid compact-forms">
<label>
<span>Tage</span>
<input type="number" min="1" max="365" value={trendRange.presetDays} onChange={(event) => setTrendRange((current) => ({ ...current, presetDays: event.target.value }))} />
<span>Zeitraum</span>
<select value={trendRange.presetDays} onChange={(event) => setTrendRange((current) => ({ ...current, presetDays: event.target.value }))}>
{TREND_PRESET_OPTIONS.map((option) => <option key={option.value} value={option.value}>{option.label}</option>)}
</select>
</label>
<label>
<span>Max. Zeilen</span>
@@ -2205,7 +2379,7 @@ export default function App() {
<div className="alias-index">Wert {point.pointIndex}</div>
<input value={point.alias} onChange={(event) => updateAliasPoint(String(point.pointIndex), { alias: event.target.value })} />
<UnitField units={availableUnits} value={point.unit} onChange={(nextValue) => updateAliasPoint(String(point.pointIndex), { unit: nextValue })} />
<input type="number" step="0.01" value={point.factor} onChange={(event) => updateAliasPoint(String(point.pointIndex), { factor: event.target.value })} />
<ScaleField value={String(point.factor ?? "1")} onChange={(nextValue) => updateAliasPoint(String(point.pointIndex), { factor: nextValue })} />
<select value={point.kind} onChange={(event) => updateAliasPoint(String(point.pointIndex), { kind: event.target.value })}>
<option value="analog">analog</option>
<option value="digital">digital</option>
@@ -2294,45 +2468,149 @@ export default function App() {
</div>
</div>
<div className="protocol-panel">
<div className="panel-header compact-header nested-gap">
<div>
<h3>Protokollquellen</h3>
<p>{collectorCapabilities?.protocols?.join(" · ") || "Modbus TCP · OPC UA · BACnet IP · KNX IP"}</p>
<div className="source-admin-top">
<div className="protocol-panel source-overview-panel">
<div className="panel-header compact-header nested-gap">
<div>
<h3>Protokollquellen</h3>
<p>{collectorCapabilities?.protocols?.join(" | ") || "Modbus TCP | OPC UA | BACnet IP | KNX IP"}</p>
</div>
</div>
<div className={collectorSources.length <= 2 ? "selection-list compact source-list source-grid" : "selection-list source-list source-grid"}>
{collectorSources.map((source) => {
const isActive = source.id === selectedSourceId;
const pointCount = collectorDatapoints.filter((item) => item.sourceId === source.id).length;
return (
<button
key={source.id}
type="button"
className={isActive ? "selection-card source-card active source-tile" : "selection-card source-card source-tile"}
onClick={() => {
setIsCreatingSource(false);
setSelectedSourceId(source.id);
}}
>
<div className="source-tile-copy">
<strong>{normalizeDisplayText(source.name, "Quelle")}</strong>
<span>{`${source.protocol} | ${normalizeDisplayText(source.tableName)}`}</span>
<small>{`${String(source.pollIntervalSeconds || 60)}s | ${pointCount} DP`}</small>
<small>{normalizeDisplayText(source.lastPollStatus, "noch nicht gepollt")}</small>
</div>
<div className="source-tile-actions">
<button className="ghost-button compact-button" type="button" onClick={(event) => { event.stopPropagation(); openSourceEditor(source.id); }}>Bearbeiten</button>
{source.protocol === "opc-ua" ? <button className="ghost-button compact-button" type="button" onClick={(event) => { event.stopPropagation(); setSelectedSourceId(source.id); setIsCreatingSource(false); runOpcuaScan(); }}>OPC scannen</button> : null}
{source.protocol === "bacnet-ip" ? <button className="ghost-button compact-button" type="button" onClick={(event) => { event.stopPropagation(); setSelectedSourceId(source.id); setIsCreatingSource(false); runBacnetScan(); }}>BACnet scannen</button> : null}
<button className="ghost-button compact-button danger" type="button" onClick={(event) => { event.stopPropagation(); setSelectedSourceId(source.id); setIsCreatingSource(false); deleteCollectorSource(); }}>L?schen</button>
</div>
</button>
);
})}
{!collectorSources.length ? <div className="empty-state slim-empty"><p>Noch keine Protokollquelle angelegt. ?ber <strong>Neue Quelle</strong> startest du sauber mit leerem Formular.</p></div> : null}
</div>
</div>
<div className={collectorSources.length <= 2 ? "selection-list compact source-list source-grid" : "selection-list source-list source-grid"}>
{collectorSources.map((source) => {
const isActive = source.id === selectedSourceId;
const pointCount = collectorDatapoints.filter((item) => item.sourceId === source.id).length;
return (
<button
key={source.id}
type="button"
className={isActive ? "selection-card source-card active source-tile" : "selection-card source-card source-tile"}
onClick={() => {
setIsCreatingSource(false);
setSelectedSourceId(source.id);
}}
>
<div className="source-tile-copy">
<strong>{normalizeDisplayText(source.name, "Quelle")}</strong>
<span>{`${source.protocol} · ${normalizeDisplayText(source.tableName)}`}</span>
<small>{`${String(source.pollIntervalSeconds || 60)}s · ${pointCount} DP`}</small>
<small>{normalizeDisplayText(source.lastPollStatus, "noch nicht gepollt")}</small>
</div>
<div className="source-tile-actions">
<button className="ghost-button compact-button" type="button" onClick={(event) => { event.stopPropagation(); openSourceEditor(source.id); }}>Bearbeiten</button>
{source.protocol === "opc-ua" ? <button className="ghost-button compact-button" type="button" onClick={(event) => { event.stopPropagation(); setSelectedSourceId(source.id); setIsCreatingSource(false); runOpcuaScan(); }}>OPC scannen</button> : null}
{source.protocol === "bacnet-ip" ? <button className="ghost-button compact-button" type="button" onClick={(event) => { event.stopPropagation(); setSelectedSourceId(source.id); setIsCreatingSource(false); runBacnetScan(); }}>BACnet scannen</button> : null}
<button className="ghost-button compact-button danger" type="button" onClick={(event) => { event.stopPropagation(); setSelectedSourceId(source.id); setIsCreatingSource(false); deleteCollectorSource(); }}>Löschen</button>
</div>
</button>
);
})}
{!collectorSources.length ? <div className="empty-state slim-empty"><p>Noch keine Protokollquelle angelegt. Über <strong>Neue Quelle</strong> startest du sauber mit leerem Formular.</p></div> : null}
<div className="protocol-panel source-settings-panel">
<div className="panel-header compact-header nested-gap">
<div>
<h3>Datenpunkte & Werkzeuge</h3>
<p>Import, Scan und Einzelpunkte liegen rechts bei der aktiven Quelle.</p>
</div>
<small className="muted">Aktive Quelle: {normalizeDisplayText(currentSource?.name, "noch keine ausgew?hlt")}</small>
</div>
<div className="protocol-grid source-tools-grid">
<div className="protocol-block">
<strong>Import / Vorlagen</strong>
<div className="button-row wrap-tight">
<label className="ghost-button compact-button file-button">
<span>Modbus CSV</span>
<input type="file" accept=".csv,text/csv" onChange={(event) => importCollectorFile(event, "modbus")} />
</label>
<button className="ghost-button compact-button" type="button" onClick={downloadModbusTemplate}>Muster CSV</button>
<label className="ghost-button compact-button file-button">
<span>KNX XML</span>
<input type="file" accept=".xml,text/xml,application/xml" onChange={(event) => importCollectorFile(event, "knx")} />
</label>
</div>
<small className="muted">SQL-Import bleibt oben. Bei Modbus kommt der Function Code jetzt aus dem Dropdown Datenart am Datenpunkt.</small>
</div>
<div className="protocol-block">
<strong>{sourceToolProtocol === "opc-ua" ? "OPC UA" : sourceToolProtocol === "bacnet-ip" ? "BACnet IP" : sourceToolProtocol === "modbus-tcp" ? "Modbus TCP" : sourceToolProtocol === "knx-ip" ? "KNX IP" : "Quelle"}</strong>
<div className="button-row wrap-tight">
{sourceToolProtocol === "opc-ua" ? <button className="ghost-button compact-button" type="button" onClick={runOpcuaScan}>Scan mit Host/Port der aktiven Quelle</button> : null}
{sourceToolProtocol === "bacnet-ip" ? <button className="ghost-button compact-button" type="button" onClick={runBacnetScan}>Scan mit Host/Port der aktiven Quelle</button> : null}
</div>
{sourceToolProtocol === "opc-ua" ? <small className="muted">{scanResults.opcua ? (scanResults.opcua.reachable ? `${scanResults.opcua.nodes?.length || 0} Node(s), ${scanResults.opcua.endpoints?.length || 0} Endpunkt(e)` : "Host nicht erreichbar") : "Verwendet Host und Port der aktiven Quelle."}</small> : null}
{sourceToolProtocol === "bacnet-ip" ? <small className="muted">{scanResults.bacnet ? `${scanResults.bacnet.devices?.length || 0} Ger?t(e) gefunden` : "F?r mehrere Hosts im Host-Feld Komma, Leerzeichen oder Semikolon verwenden."}</small> : null}
{sourceToolProtocol === "modbus-tcp" ? <small className="muted">F?r 192.168.153.50 ist Input Register / FC4 best?tigt. Den Registertyp w?hlst du pro Datenpunkt ?ber Datenart.</small> : null}
{sourceToolProtocol === "knx-ip" ? <small className="muted">KNX-Import oder Einzelpunkt rechts anlegen. Gefundene und importierte Punkte landen unten links.</small> : null}
</div>
</div>
{canManageSources ? (
<div className="protocol-block manual-point-card source-settings-card">
<strong>Einzelnen Datenpunkt anlegen</strong>
<div className="form-grid compact-forms">
<label>
<span>Alias</span>
<input value={manualDatapointDraft.alias} onChange={(event) => setManualDatapointDraft((current) => ({ ...current, alias: event.target.value }))} />
</label>
<label>
<span>{getManualAddressLabel(sourceToolProtocol)}</span>
<input value={manualDatapointDraft.address} onChange={(event) => setManualDatapointDraft((current) => ({ ...current, address: event.target.value }))} />
</label>
<label>
<span>Wertnummer</span>
<input type="number" min="1" max="2000" value={manualDatapointDraft.pointIndex} onChange={(event) => setManualDatapointDraft((current) => ({ ...current, pointIndex: event.target.value }))} />
</label>
<label>
<span>Typ</span>
<select value={manualDatapointDraft.kind} onChange={(event) => setManualDatapointDraft((current) => ({ ...current, kind: event.target.value }))}>
<option value="analog">analog</option>
<option value="digital">digital</option>
</select>
</label>
<label>
<span>Datenart</span>
{sourceToolProtocol === "modbus-tcp" ? (
<select value={manualDatapointDraft.dataType} onChange={(event) => setManualDatapointDraft((current) => ({ ...current, dataType: event.target.value }))}>
{getPointDataTypeOptions(sourceToolProtocol).map((option) => <option key={option.value} value={option.value}>{option.label}</option>)}
</select>
) : (
<input value={manualDatapointDraft.dataType} onChange={(event) => setManualDatapointDraft((current) => ({ ...current, dataType: event.target.value }))} />
)}
</label>
<label>
<span>Einheit</span>
<UnitField units={availableUnits} value={manualDatapointDraft.unit} onChange={(nextValue) => setManualDatapointDraft((current) => ({ ...current, unit: nextValue }))} />
</label>
<label>
<span>Faktor</span>
<ScaleField value={String(manualDatapointDraft.scale ?? "1")} onChange={(nextValue) => setManualDatapointDraft((current) => ({ ...current, scale: nextValue }))} />
</label>
<label>
<span>Schreibmodus</span>
<select value={manualDatapointDraft.writeMode} onChange={(event) => setManualDatapointDraft((current) => ({ ...current, writeMode: event.target.value }))}>
<option value="">Default der Quelle</option>
<option value="interval">Jeden Poll</option>
<option value="cov">Nur bei ?nderung (COV)</option>
</select>
</label>
<label className="span-two">
<span>Bedingung / Notiz</span>
<input value={manualDatapointDraft.condition} placeholder="z.B. nur wenn Anlage l?uft" onChange={(event) => setManualDatapointDraft((current) => ({ ...current, condition: event.target.value }))} />
</label>
</div>
<div className="button-row wrap-tight">
{editingCollectorPointId ? <button className="ghost-button compact-button" type="button" onClick={() => resetManualDatapointEditor(sourceToolProtocol)}>Abbrechen</button> : null}
<button className="ghost-button compact-button" type="button" onClick={saveManualCollectorDatapoint}>{editingCollectorPointId ? "Datenpunkt speichern" : "Datenpunkt anlegen"}</button>
</div>
</div>
) : null}
</div>
</div>
{sourceModalOpen ? (
<div className="modal-backdrop" onClick={closeSourceEditor}>
@@ -2342,7 +2620,7 @@ export default function App() {
<h3>{sourceDraft.id ? "Quelle bearbeiten" : "Neue Quelle"}</h3>
<p>Host, Port, Schreibmodus und Poll-Intervall werden hier zentral verwaltet.</p>
</div>
<button className="ghost-button compact-button" type="button" onClick={closeSourceEditor}>Schließen</button>
<button className="ghost-button compact-button" type="button" onClick={closeSourceEditor}>Schlie?en</button>
</div>
<div className="form-grid compact-forms">
@@ -2368,7 +2646,7 @@ export default function App() {
<input value={sourceDraft.port} onChange={(event) => setSourceDraft((current) => ({ ...current, port: event.target.value }))} />
</label>
<label>
<span>Geräte-ID</span>
<span>Ger?te-ID</span>
<input value={sourceDraft.deviceId} onChange={(event) => setSourceDraft((current) => ({ ...current, deviceId: event.target.value }))} />
</label>
<label className="span-two">
@@ -2379,7 +2657,7 @@ export default function App() {
<span>Schreibmodus</span>
<select value={sourceDraft.writeMode} onChange={(event) => setSourceDraft((current) => ({ ...current, writeMode: event.target.value }))}>
<option value="interval">Jeden Poll schreiben</option>
<option value="cov">Nur bei Änderung (COV)</option>
<option value="cov">Nur bei ?nderung (COV)</option>
</select>
</label>
<label>
@@ -2388,11 +2666,11 @@ export default function App() {
</label>
</div>
<small className="muted">Der Tabellenname wird automatisch passend erzeugt. So taucht die Quelle später sicher in der Trendansicht auf.</small>
<small className="muted">Der Tabellenname wird automatisch passend erzeugt. So taucht die Quelle sp?ter sicher in der Trendansicht auf.</small>
<div className="button-row wrap-tight">
<button className="primary-button compact-button" type="button" onClick={saveCollectorSource}>Quelle speichern</button>
{selectedSourceId ? <button className="ghost-button compact-button danger" type="button" onClick={deleteCollectorSource}>Quelle löschen</button> : null}
{selectedSourceId ? <button className="ghost-button compact-button danger" type="button" onClick={deleteCollectorSource}>Quelle l?schen</button> : null}
</div>
</div>
</div>
@@ -2414,46 +2692,6 @@ export default function App() {
onChange={(event) => setCollectorPointFilter(event.target.value)}
/>
<div className="protocol-grid source-tools-grid">
<div className="protocol-block">
<strong>Importe</strong>
<div className="button-row wrap-tight">
<label className="ghost-button compact-button file-button">
<span>Modbus CSV</span>
<input type="file" accept=".csv,text/csv" onChange={(event) => importCollectorFile(event, "modbus")} />
</label>
<button className="ghost-button compact-button" type="button" onClick={downloadModbusTemplate}>Muster CSV</button>
<label className="ghost-button compact-button file-button">
<span>KNX XML</span>
<input type="file" accept=".xml,text/xml,application/xml" onChange={(event) => importCollectorFile(event, "knx")} />
</label>
</div>
<small className="muted">Aktive Quelle: {normalizeDisplayText(currentSource?.name, "noch keine ausgewählt")}</small>
</div>
{sourceDraft.protocol === "opc-ua" ? (
<div className="protocol-block">
<strong>OPC UA</strong>
<div className="button-row wrap-tight">
<button className="ghost-button compact-button" type="button" onClick={runOpcuaScan}>Scan mit Host/Port der aktiven Quelle</button>
</div>
<small className="muted">Verwendet Host und Port der oben ausgewählten Quelle.</small>
{scanResults.opcua ? <small className="muted">{scanResults.opcua.reachable ? `${scanResults.opcua.nodes?.length || 0} Node(s), ${scanResults.opcua.endpoints?.length || 0} Endpunkt(e)` : "Host nicht erreichbar"}</small> : null}
</div>
) : null}
{sourceDraft.protocol === "bacnet-ip" ? (
<div className="protocol-block">
<strong>BACnet IP</strong>
<div className="button-row wrap-tight">
<button className="ghost-button compact-button" type="button" onClick={runBacnetScan}>Scan mit Host/Port der aktiven Quelle</button>
</div>
<small className="muted">Für mehrere Hosts im Host-Feld Komma, Leerzeichen oder Semikolon verwenden.</small>
{scanResults.bacnet ? <small className="muted">{`${scanResults.bacnet.devices?.length || 0} Gerät(e)`}</small> : null}
</div>
) : null}
</div>
<div className="scan-result-list">
{filteredOpcuaNodes.slice(0, 160).map((node, index) => {
const pointId = getOpcuaScanDatapointId(selectedSourceId, node);
@@ -2482,7 +2720,7 @@ export default function App() {
<div key={`${point.id}-${point.host || "host"}-${index}`} className="datapoint-row inactive">
<div className="datapoint-main">
<strong>{normalizeDisplayText(point.alias || point.name)}</strong>
<span>{`${normalizeDisplayText(point.host)} · ${normalizeDisplayText(point.dataType || "obj")}${point.objectInstance || ""}`}</span>
<span>{`${normalizeDisplayText(point.host)} ? ${normalizeDisplayText(point.dataType || "obj")}${point.objectInstance || ""}`}</span>
<small>{point.currentValue === null || point.currentValue === undefined ? "kein Live-Wert" : formatCollectorValue(point.currentValue)}</small>
</div>
<span className="datapoint-value">{linkedPoint?.pointIndex || index + 1}</span>
@@ -2503,11 +2741,12 @@ export default function App() {
<span className="datapoint-value">{item.pointIndex}</span>
<div className="button-row wrap-tight">
<button className="ghost-button compact-button" type="button" onClick={() => readCollectorPoint(item)}>Lesen</button>
<button className="ghost-button compact-button" type="button" onClick={() => editCollectorPoint(item)}>Bearbeiten</button>
<button className="primary-button compact-button" type="button" disabled={item.enabled === true} onClick={() => updateCollectorPoint(item, { enabled: true })}>{item.enabled === true ? "Im Logging" : "Ins Logging"}</button>
</div>
</div>
)) : null}
{!filteredAvailableCollectorPoints.length && !filteredOpcuaNodes.length && !filteredBacnetPoints.length ? <div className="empty-state slim-empty"><p>{collectorPointFilter.trim() ? "Keine Treffer für die aktuelle Suche." : "Nach Scan oder Import erscheinen die gefundenen Punkte genau hier links."}</p></div> : null}
{!filteredAvailableCollectorPoints.length && !filteredOpcuaNodes.length && !filteredBacnetPoints.length ? <div className="empty-state slim-empty"><p>{collectorPointFilter.trim() ? "Keine Treffer f?r die aktuelle Suche." : "Nach Scan oder Import erscheinen die gefundenen Punkte genau hier links."}</p></div> : null}
</div>
</div>
@@ -2519,69 +2758,18 @@ export default function App() {
</div>
</div>
{canManageSources ? (
<div className="protocol-block manual-point-card">
<strong>Einzelnen Datenpunkt anlegen</strong>
<div className="form-grid compact-forms">
<label>
<span>Alias</span>
<input value={manualDatapointDraft.alias} onChange={(event) => setManualDatapointDraft((current) => ({ ...current, alias: event.target.value }))} />
</label>
<label>
<span>{getManualAddressLabel(sourceDraft.protocol)}</span>
<input value={manualDatapointDraft.address} onChange={(event) => setManualDatapointDraft((current) => ({ ...current, address: event.target.value }))} />
</label>
<label>
<span>Wertnummer</span>
<input type="number" min="1" max="2000" value={manualDatapointDraft.pointIndex} onChange={(event) => setManualDatapointDraft((current) => ({ ...current, pointIndex: event.target.value }))} />
</label>
<label>
<span>Typ</span>
<select value={manualDatapointDraft.kind} onChange={(event) => setManualDatapointDraft((current) => ({ ...current, kind: event.target.value }))}>
<option value="analog">analog</option>
<option value="digital">digital</option>
</select>
</label>
<label>
<span>Datenart</span>
<input value={manualDatapointDraft.dataType} onChange={(event) => setManualDatapointDraft((current) => ({ ...current, dataType: event.target.value }))} />
</label>
<label>
<span>Einheit</span>
<UnitField units={availableUnits} value={manualDatapointDraft.unit} onChange={(nextValue) => setManualDatapointDraft((current) => ({ ...current, unit: nextValue }))} />
</label>
<label>
<span>Faktor</span>
<input type="number" step="0.0001" value={manualDatapointDraft.scale} onChange={(event) => setManualDatapointDraft((current) => ({ ...current, scale: event.target.value }))} />
</label>
<label>
<span>Schreibmodus</span>
<select value={manualDatapointDraft.writeMode} onChange={(event) => setManualDatapointDraft((current) => ({ ...current, writeMode: event.target.value }))}>
<option value="">Default der Quelle</option>
<option value="interval">Jeden Poll</option>
<option value="cov">Nur bei Änderung (COV)</option>
</select>
</label>
<label className="span-two">
<span>Bedingung / Notiz</span>
<input value={manualDatapointDraft.condition} placeholder="z.B. nur wenn Anlage läuft" onChange={(event) => setManualDatapointDraft((current) => ({ ...current, condition: event.target.value }))} />
</label>
</div>
<button className="ghost-button compact-button" type="button" onClick={saveManualCollectorDatapoint}>Datenpunkt anlegen</button>
</div>
) : null}
<div className="datapoint-table">
{loggedCollectorPoints.length ? loggedCollectorPoints.slice(0, 2000).map((item) => (
<div key={item.id} className="datapoint-row active">
<div className="datapoint-main">
<strong>{normalizeDisplayText(item.alias || item.name)}</strong>
<span>{`Wert ${item.pointIndex} · ${normalizeDisplayText(item.address || item.nodeId || item.dataType || item.protocol)}`}</span>
<small>{`${item.writeMode === "cov" ? "COV" : "Intervall"}${item.condition ? ` · ${normalizeDisplayText(item.condition)}` : ""}`}</small>
<span>{`Wert ${item.pointIndex} ? ${normalizeDisplayText(item.address || item.nodeId || item.dataType || item.protocol)}`}</span>
<small>{`${item.writeMode === "cov" ? "COV" : "Intervall"}${item.condition ? ` ? ${normalizeDisplayText(item.condition)}` : ""}`}</small>
</div>
<span className="datapoint-value">{formatCollectorValue(item.currentValue)}{item.unit ? ` ${normalizeDisplayUnit(item.unit)}` : ""}</span>
<div className="button-row wrap-tight">
<button className="ghost-button compact-button" type="button" onClick={() => readCollectorPoint(item)}>Lesen</button>
<button className="ghost-button compact-button" type="button" onClick={() => editCollectorPoint(item)}>Bearbeiten</button>
<button className="ghost-button compact-button" type="button" onClick={() => updateCollectorPoint(item, { enabled: false })}>Rausnehmen</button>
</div>
</div>
@@ -2590,9 +2778,7 @@ export default function App() {
</div>
</div>
</div>
</div>
) : null}
{adminSection === "users" && canManageUsers ? (
<div className="panel slim-panel">
<div className="panel-header compact-header nested-gap">
@@ -2817,3 +3003,9 @@ export default function App() {
+1
View File
@@ -29,6 +29,7 @@ async function request(baseUrl, path, options = {}) {
const response = await fetch(baseUrl + path, {
headers,
method: options.method || "GET",
cache: options.cache || "no-store",
body: options.body === undefined ? undefined : isJsonBody ? JSON.stringify(options.body) : options.body,
});
+31 -15
View File
@@ -1,4 +1,4 @@
import { useMemo, useRef, useState } from "react";
import { useEffect, useMemo, useRef, useState } from "react";
import { jsPDF } from "jspdf";
import * as XLSX from "xlsx";
import {
@@ -144,6 +144,7 @@ export default function TrendChart({
if (event.button !== 0) {
return;
}
event.preventDefault();
const mapped = mapDragToDomain(event.clientX, event.clientY);
if (!mapped) {
return;
@@ -151,36 +152,54 @@ export default function TrendChart({
setDragBox({ start: mapped, end: mapped });
};
const handleMouseMove = (event) => {
if (!dragBox) {
return;
}
const mapped = mapDragToDomain(event.clientX, event.clientY);
const updateDragBox = (clientX, clientY) => {
const mapped = mapDragToDomain(clientX, clientY);
if (!mapped) {
return;
}
setDragBox((current) => current ? { ...current, end: mapped } : current);
};
const handleMouseUp = () => {
if (!dragBox) {
const finalizeDragBox = (box) => {
if (!box) {
return;
}
const deltaX = Math.abs(dragBox.end.xPixel - dragBox.start.xPixel);
const deltaY = Math.abs(dragBox.end.yPixel - dragBox.start.yPixel);
const deltaX = Math.abs(box.end.xPixel - box.start.xPixel);
const deltaY = Math.abs(box.end.yPixel - box.start.yPixel);
if (deltaX < 12 || deltaY < 12) {
setDragBox(null);
return;
}
setZoomDomain({
x: [Math.min(dragBox.start.xValue, dragBox.end.xValue), Math.max(dragBox.start.xValue, dragBox.end.xValue)],
y: [Math.min(dragBox.start.yValue, dragBox.end.yValue), Math.max(dragBox.start.yValue, dragBox.end.yValue)],
x: [Math.min(box.start.xValue, box.end.xValue), Math.max(box.start.xValue, box.end.xValue)],
y: [Math.min(box.start.yValue, box.end.yValue), Math.max(box.start.yValue, box.end.yValue)],
});
setDragBox(null);
};
useEffect(() => {
if (!dragBox) {
return undefined;
}
const handleWindowMouseMove = (event) => {
updateDragBox(event.clientX, event.clientY);
};
const handleWindowMouseUp = () => {
finalizeDragBox(dragBox);
};
window.addEventListener("mousemove", handleWindowMouseMove);
window.addEventListener("mouseup", handleWindowMouseUp);
return () => {
window.removeEventListener("mousemove", handleWindowMouseMove);
window.removeEventListener("mouseup", handleWindowMouseUp);
};
}, [dragBox, xDomain, yDomain]);
const resetZoom = () => {
setZoomDomain(null);
};
@@ -367,9 +386,6 @@ export default function TrendChart({
ref={wrapperRef}
className={dragBox ? "chart-wrapper dragging" : "chart-wrapper"}
onMouseDown={handleMouseDown}
onMouseMove={handleMouseMove}
onMouseUp={handleMouseUp}
onMouseLeave={handleMouseUp}
>
<div ref={chartHostRef} className="chart-host">
<ResponsiveContainer width="100%" height={expanded ? 680 : compact ? 280 : 360}>
+18 -1
View File
@@ -920,6 +920,22 @@ span {
white-space: nowrap;
}
.source-admin-top {
display: grid;
grid-template-columns: minmax(0, 0.92fr) minmax(0, 1.08fr);
gap: 0.65rem;
align-items: start;
}
.source-overview-panel,
.source-settings-panel {
min-height: 100%;
}
.source-settings-card {
margin-top: 0.1rem;
}
.source-split {
display: grid;
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
@@ -995,7 +1011,7 @@ span {
}
.source-tools-grid {
grid-template-columns: repeat(3, minmax(0, 1fr));
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.manual-point-card {
@@ -1030,6 +1046,7 @@ span {
}
@media (max-width: 980px) {
.source-admin-top,
.source-split,
.source-tools-grid,
.source-tile,