This commit is contained in:
jhartworks
2026-06-25 14:35:57 +02:00
parent bf1214239f
commit a12ef41d1b
116 changed files with 978 additions and 59769 deletions
+138 -13
View File
@@ -42,6 +42,16 @@ const defaultWidgetDraft = {
max: 100,
};
const PERMISSION_OPTIONS = [
{ id: "view_dashboard", label: "Dashboard anzeigen" },
{ id: "view_trends", label: "Trends anzeigen" },
{ id: "edit_aliases", label: "Aliase bearbeiten" },
{ id: "manage_sources", label: "Quellen verwalten" },
{ id: "manage_users", label: "Benutzer verwalten" },
{ id: "manage_roles", label: "Rollen verwalten" },
{ id: "view_debug", label: "Debug anzeigen" },
];
const pointColors = [
"#d62839",
"#2563eb",
@@ -127,6 +137,16 @@ function formatValue(value) {
return Number.isFinite(Number(value)) ? Number(value).toFixed(2) : "--";
}
function formatCollectorValue(value) {
if (value === null || value === undefined || value === "") {
return "kein Wert";
}
if (typeof value === "boolean") {
return value ? "true" : "false";
}
return Number.isFinite(Number(value)) ? Number(value).toLocaleString("de-DE") : String(value);
}
function formatTimestampLabel(timestamp) {
return timestamp ? new Date(timestamp).toLocaleString("de-DE") : "Aktueller Wert";
}
@@ -471,6 +491,19 @@ function PointLibrary({ metadata, filterText, onFilterTextChange, onAddPoint })
);
}
function hasPermission(user, permission) {
if (!user) {
return false;
}
if (user.role === "admin") {
return true;
}
return Array.isArray(user.permissions) && user.permissions.includes(permission);
}
export default function App() {
const [token, setToken] = useState(() => localStorage.getItem("seltd-token") || "");
const [activeView, setActiveView] = useState("dashboard");
@@ -516,7 +549,8 @@ export default function App() {
const [selectedSourceId, setSelectedSourceId] = useState("");
const [isCreatingSource, setIsCreatingSource] = useState(false);
const [sourceDraft, setSourceDraft] = useState(defaultSourceDraft);
const [manualDatapointDraft, setManualDatapointDraft] = useState({ alias: "", address: "", pointIndex: "", unit: "°C", dataType: getDefaultPointDataType(defaultSourceDraft.protocol), kind: "analog" });
const emptyManualDatapointDraft = (protocol = defaultSourceDraft.protocol) => ({ alias: "", address: "", pointIndex: "", unit: "°C", dataType: getDefaultPointDataType(protocol), kind: "analog", scale: "1", writeMode: "", condition: "" });
const [manualDatapointDraft, setManualDatapointDraft] = useState(emptyManualDatapointDraft());
const [scanResults, setScanResults] = useState({ opcua: null, bacnet: null });
const [debugSnapshot, setDebugSnapshot] = useState(null);
@@ -1170,7 +1204,7 @@ export default function App() {
const startNewSource = () => {
setIsCreatingSource(true);
setSelectedSourceId("");
setManualDatapointDraft({ alias: "", address: "", pointIndex: "", unit: "°C", dataType: getDefaultPointDataType(defaultSourceDraft.protocol), kind: "analog" });
setManualDatapointDraft(emptyManualDatapointDraft(defaultSourceDraft.protocol));
setSourceDraft({
...defaultSourceDraft,
port: getDefaultProtocolPort(defaultSourceDraft.protocol),
@@ -1283,7 +1317,7 @@ export default function App() {
});
await refreshCollectorData();
await refreshCollectorDebug().catch(() => undefined);
setManualDatapointDraft({ alias: "", address: "", pointIndex: "", unit: "°C", dataType: getDefaultPointDataType(sourceDraft.protocol), kind: "analog" });
setManualDatapointDraft(emptyManualDatapointDraft(sourceDraft.protocol));
setSuccessMessage("Datenpunkt angelegt.");
} catch (error) {
setBootError(error.message);
@@ -1328,13 +1362,20 @@ export default function App() {
};
const runOpcuaScan = async () => {
const activeSourceId = selectedSourceId || sourceDraft.id;
if (!activeSourceId) {
setBootError("Bitte zuerst eine Quelle speichern oder auswählen.");
return;
}
resetFlash();
try {
const result = await api.scanOpcua({
host: sourceDraft.host,
port: sourceDraft.port || "4840",
sourceId: activeSourceId,
});
setScanResults((current) => ({ ...current, opcua: result }));
await refreshCollectorData();
await refreshCollectorDebug().catch(() => undefined);
setSuccessMessage("OPC-UA-Scan abgeschlossen.");
} catch (error) {
@@ -1343,6 +1384,11 @@ export default function App() {
};
const runBacnetScan = async () => {
const activeSourceId = selectedSourceId || sourceDraft.id;
if (!activeSourceId) {
setBootError("Bitte zuerst eine Quelle speichern oder auswählen.");
return;
}
resetFlash();
try {
const hosts = sourceDraft.host
@@ -1353,8 +1399,10 @@ export default function App() {
hosts,
port: sourceDraft.port || "47808",
deviceId: sourceDraft.deviceId,
sourceId: activeSourceId,
});
setScanResults((current) => ({ ...current, bacnet: result }));
await refreshCollectorData();
await refreshCollectorDebug().catch(() => undefined);
setSuccessMessage("BACnet-Scan abgeschlossen.");
} catch (error) {
@@ -1362,6 +1410,29 @@ export default function App() {
}
};
const updateCollectorPoint = async (datapoint, changes) => {
resetFlash();
try {
await api.updateCollectorDatapoint(datapoint.id, changes);
await refreshCollectorData();
await refreshCollectorDebug().catch(() => undefined);
setSuccessMessage("Datenpunkt aktualisiert.");
} catch (error) {
setBootError(error.message);
}
};
const readCollectorPoint = async (datapoint) => {
resetFlash();
try {
await api.readCollectorDatapoint(datapoint.id);
await refreshCollectorData();
setSuccessMessage("Live-Wert gelesen.");
} catch (error) {
setBootError(error.message);
}
};
const createUser = async () => {
resetFlash();
try {
@@ -2062,7 +2133,7 @@ export default function App() {
</label>
<label>
<span>Wertnummer</span>
<input type="number" min="1" max="200" value={manualDatapointDraft.pointIndex} onChange={(event) => setManualDatapointDraft((current) => ({ ...current, pointIndex: event.target.value }))} />
<input type="number" min="1" max="2000" value={manualDatapointDraft.pointIndex} onChange={(event) => setManualDatapointDraft((current) => ({ ...current, pointIndex: event.target.value }))} />
</label>
<label>
<span>Typ</span>
@@ -2079,6 +2150,22 @@ export default function App() {
<span>Einheit</span>
<UnitField 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 Ä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>
@@ -2090,7 +2177,7 @@ export default function App() {
<button className="ghost-button compact-button" type="button" onClick={runOpcuaScan}>Scan mit Host/Port oben</button>
</div>
<small className="muted">Verwendet Host und Port aus der Quelle oberhalb des Speicher-Buttons.</small>
{scanResults.opcua ? <small className="muted">{scanResults.opcua.reachable ? String(scanResults.opcua.endpoints.length) + " Endpunkt(e) gefunden" : "Host nicht erreichbar"}</small> : null}
{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>
<div className="protocol-block">
@@ -2099,26 +2186,64 @@ export default function App() {
<button className="ghost-button compact-button" type="button" onClick={runBacnetScan}>Scan mit Host/Port oben</button>
</div>
<small className="muted">Für mehrere Hosts einfach im Feld oben Komma, Leerzeichen oder Semikolon verwenden.</small>
{scanResults.bacnet ? <small className="muted">{scanResults.bacnet.devices?.length || 0} Gerät(e) geprüft</small> : null}
{scanResults.bacnet ? <small className="muted">{scanResults.bacnet.devices?.length || 0} Gerät(e), {scanResults.bacnet.points?.length || 0} Objekt(e)</small> : null}
</div>
</div>
{(scanResults.opcua?.nodes?.length || scanResults.bacnet?.points?.length) ? (
<div className="datapoint-preview">
<div className="panel-header compact-header nested-gap">
<div>
<h3>Scan-Ergebnis</h3>
<p>{scanResults.opcua?.nodes?.length || scanResults.bacnet?.points?.length || 0} gefundene Datenpunkte oder Objekte.</p>
</div>
</div>
<div className="scan-result-list">
{(scanResults.opcua?.nodes || []).filter((node) => node.hasValue).slice(0, 120).map((node) => (
<div key={node.nodeId} className="scan-result-row">
<div>
<strong>{node.displayName || node.browseName}</strong>
<span>{node.path || node.nodeId}</span>
</div>
<span>{formatCollectorValue(node.currentValue)}</span>
</div>
))}
{(scanResults.bacnet?.points || []).slice(0, 120).map((point) => (
<div key={point.id} className="scan-result-row">
<div>
<strong>{point.alias || point.name}</strong>
<span>{point.host} · {point.dataType}{point.objectInstance}</span>
</div>
<span>{formatCollectorValue(point.currentValue)}</span>
</div>
))}
</div>
</div>
) : null}
{collectorSourcePoints.length ? (
<div className="datapoint-preview">
<div className="panel-header compact-header nested-gap">
<div>
<h3>Datenpunkte</h3>
<p>{collectorSourcePoints.length} importierte oder erkannte Punkte.</p>
<p>{collectorSourcePoints.length} importierte, gescannte oder manuelle Punkte. Aktivierte Punkte werden geloggt.</p>
</div>
</div>
<div className={collectorSourcePoints.length <= 2 ? "selection-list compact" : "selection-list"}>
{collectorSourcePoints.slice(0, 24).map((item) => (
<div key={item.id} className="selection-card source-card preview-card">
<div>
<div className="datapoint-table">
{collectorSourcePoints.slice(0, 2000).map((item) => (
<div key={item.id} className={item.enabled === false ? "datapoint-row inactive" : "datapoint-row active"}>
<div className="datapoint-main">
<strong>{item.alias || item.name}</strong>
<span>{item.address || item.nodeId || item.dataType || item.protocol}</span>
<span>Wert {item.pointIndex} · {item.address || item.nodeId || item.dataType || item.protocol} · {item.writeMode === "cov" ? "COV" : "Intervall"}</span>
{item.condition ? <small>{item.condition}</small> : null}
</div>
<span className="datapoint-value">{formatCollectorValue(item.currentValue)}{item.unit ? " " + item.unit : ""}</span>
<div className="button-row wrap-tight">
<button className="ghost-button compact-button" type="button" onClick={() => readCollectorPoint(item)}>Wert lesen</button>
<button className={item.enabled === false ? "primary-button compact-button" : "ghost-button compact-button"} type="button" onClick={() => updateCollectorPoint(item, { enabled: item.enabled === false })}>
{item.enabled === false ? "Logging an" : "Logging aus"}
</button>
</div>
<span>{item.unit || "°C"}</span>
</div>
))}
</div>
+2
View File
@@ -83,6 +83,8 @@ export const api = {
updateCollectorSource: (id, body) => collectorRequest(`/sources/${id}`, { method: "PUT", body }),
deleteCollectorSource: (id) => collectorRequest(`/sources/${id}`, { method: "DELETE" }),
createCollectorDatapoint: (body) => collectorRequest("/datapoints/manual", { method: "POST", body }),
updateCollectorDatapoint: (id, body) => collectorRequest(`/datapoints/${encodeURIComponent(id)}`, { method: "PATCH", body }),
readCollectorDatapoint: (id) => collectorRequest(`/datapoints/${encodeURIComponent(id)}/read`, { method: "POST" }),
getCollectorDebug: () => collectorRequest("/debug"),
importModbusCsv: (body) => collectorRequest("/imports/modbus-csv", { method: "POST", body }),
importKnxXml: (body) => collectorRequest("/imports/knx-xml", { method: "POST", body }),
+46
View File
@@ -873,3 +873,49 @@ span {
.scan-result-list,
.datapoint-table {
display: grid;
gap: 0.35rem;
max-height: 420px;
overflow: auto;
}
.scan-result-row,
.datapoint-row {
display: grid;
grid-template-columns: minmax(0, 1fr) auto auto;
gap: 0.5rem;
align-items: center;
border: 1px solid var(--border);
border-radius: 10px;
padding: 0.45rem 0.55rem;
background: color-mix(in srgb, var(--surface) 92%, transparent);
}
.datapoint-row.inactive {
opacity: 0.68;
}
.datapoint-main {
display: grid;
gap: 0.12rem;
min-width: 0;
}
.datapoint-main span,
.scan-result-row span,
.datapoint-main small {
color: var(--muted);
font-size: 0.72rem;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.datapoint-value {
font-variant-numeric: tabular-nums;
font-weight: 700;
white-space: nowrap;
}