alpha
This commit is contained in:
+138
-13
@@ -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>
|
||||
|
||||
@@ -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 }),
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user