commit
This commit is contained in:
+4
-4
@@ -1,13 +1,13 @@
|
||||
<!doctype html>
|
||||
<!doctype html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>SE Trendportal</title>
|
||||
<title>SE-LTD</title>
|
||||
<link rel="icon" type="image/png" href="/logo.png" />
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.jsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
</html>
|
||||
|
||||
+371
-21
@@ -463,12 +463,16 @@ export default function App() {
|
||||
const [trendResult, setTrendResult] = useState(null);
|
||||
const [latestValues, setLatestValues] = useState([]);
|
||||
const [selections, setSelections] = useState([]);
|
||||
const [selectedSelectionId, setSelectedSelectionId] = useState("");
|
||||
const [selectionDraft, setSelectionDraft] = useState(defaultSelectionDraft);
|
||||
const [dashboard, setDashboard] = useState({ widgets: [] });
|
||||
const [dashboardData, setDashboardData] = useState({});
|
||||
const [widgetDraft, setWidgetDraft] = useState(defaultWidgetDraft);
|
||||
const [users, setUsers] = useState([]);
|
||||
const [roles, setRoles] = useState([]);
|
||||
const [rolePermissions, setRolePermissions] = useState(PERMISSION_OPTIONS);
|
||||
const [userDraft, setUserDraft] = useState({ name: "", username: "", password: "", role: "viewer" });
|
||||
const [roleDraft, setRoleDraft] = useState({ name: "", label: "", permissions: ["view_dashboard", "view_trends"] });
|
||||
const [themeMode, setThemeMode] = useState("system");
|
||||
const [csvDelimiter, setCsvDelimiter] = useState(";");
|
||||
const [sidebarPinned, setSidebarPinned] = useState(true);
|
||||
@@ -484,15 +488,24 @@ 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: "holding-register", kind: "analog" });
|
||||
const [scanResults, setScanResults] = useState({ opcua: null, bacnet: null });
|
||||
const [debugSnapshot, setDebugSnapshot] = useState(null);
|
||||
|
||||
const trendIsps = useMemo(() => isps.filter((isp) => Number(isp.rowCount) > 0), [isps]);
|
||||
const selectedMetadata = selectedIsp ? metadataCache[selectedIsp] : null;
|
||||
const canEditAliases = user?.role === "admin" || user?.role === "technician";
|
||||
const canManageUsers = user?.role === "admin";
|
||||
const canEditAliases = hasPermission(user, "edit_aliases");
|
||||
const canManageUsers = hasPermission(user, "manage_users");
|
||||
const canManageRoles = hasPermission(user, "manage_roles");
|
||||
const canViewDebug = hasPermission(user, "view_debug");
|
||||
const sidebarCollapsed = !sidebarPinned && !sidebarHover;
|
||||
const currentView = views.find((view) => view.id === activeView) || views[0];
|
||||
const selectionListClassName = selections.length <= 2 ? "selection-list compact" : "selection-list";
|
||||
const roleOptions = roles.length ? roles : [
|
||||
{ name: "viewer", label: "Viewer" },
|
||||
{ name: "technician", label: "Techniker" },
|
||||
{ name: "admin", label: "Admin" },
|
||||
];
|
||||
const currentSource = useMemo(() => collectorSources.find((source) => source.id === selectedSourceId) || null, [collectorSources, selectedSourceId]);
|
||||
const collectorSourcePoints = useMemo(() => (
|
||||
selectedSourceId
|
||||
@@ -677,7 +690,7 @@ export default function App() {
|
||||
}, [dashboard, token]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!token || user?.role !== "admin") {
|
||||
if (!token || !canManageUsers) {
|
||||
setUsers([]);
|
||||
return;
|
||||
}
|
||||
@@ -685,10 +698,24 @@ export default function App() {
|
||||
api.getUsers(token)
|
||||
.then((result) => setUsers(result.users))
|
||||
.catch((error) => setBootError(error.message));
|
||||
}, [token, user]);
|
||||
}, [canManageUsers, token]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!token || !canEditAliases) {
|
||||
if (!token || !canManageRoles) {
|
||||
setRoles([]);
|
||||
return;
|
||||
}
|
||||
|
||||
api.getRoles(token)
|
||||
.then((result) => {
|
||||
setRoles(result.roles || []);
|
||||
setRolePermissions(result.permissions || PERMISSION_OPTIONS);
|
||||
})
|
||||
.catch((error) => setBootError(error.message));
|
||||
}, [canManageRoles, token]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!token || !(canEditAliases || canManageSources || canViewDebug)) {
|
||||
setCollectorCapabilities(null);
|
||||
setCollectorSources([]);
|
||||
setCollectorDatapoints([]);
|
||||
@@ -700,7 +727,7 @@ export default function App() {
|
||||
.catch((error) => setBootError(error.message));
|
||||
|
||||
refreshCollectorData().catch((error) => setBootError(error.message));
|
||||
}, [canEditAliases, token]);
|
||||
}, [canEditAliases, canManageSources, canViewDebug, token]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!currentSource) {
|
||||
@@ -719,13 +746,45 @@ export default function App() {
|
||||
writeMode: currentSource.writeMode === "cov" ? "cov" : "interval",
|
||||
pollIntervalSeconds: String(currentSource.pollIntervalSeconds || 60),
|
||||
});
|
||||
setManualDatapointDraft((current) => ({
|
||||
...current,
|
||||
dataType: currentSource.protocol === "knx-ip" ? "group-address" : "holding-register",
|
||||
}));
|
||||
}, [currentSource]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!canManageUsers && adminSection === "users") {
|
||||
setAdminSection("aliases");
|
||||
return;
|
||||
}
|
||||
}, [adminSection, canManageUsers]);
|
||||
|
||||
if (!canManageRoles && adminSection === "roles") {
|
||||
setAdminSection("aliases");
|
||||
return;
|
||||
}
|
||||
|
||||
if (!canViewDebug && adminSection === "debug") {
|
||||
setAdminSection("aliases");
|
||||
}
|
||||
}, [activeView, adminSection, canAccessAdmin, canManageRoles, canManageUsers, canViewDashboard, canViewDebug, canViewTrends]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!selectedSelectionId) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!selections.some((selection) => selection.id === selectedSelectionId)) {
|
||||
setSelectedSelectionId("");
|
||||
}
|
||||
}, [selectedSelectionId, selections]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!token || !canViewDebug || adminSection !== "debug") {
|
||||
return;
|
||||
}
|
||||
|
||||
refreshCollectorDebug().catch((error) => setBootError(error.message));
|
||||
}, [adminSection, canViewDebug, token]);
|
||||
|
||||
const handleLogin = async (credentials) => {
|
||||
setBusy(true);
|
||||
@@ -771,6 +830,7 @@ export default function App() {
|
||||
};
|
||||
|
||||
const startNewSelection = () => {
|
||||
setSelectedSelectionId("");
|
||||
setWorkingPoints([]);
|
||||
setSelectionDraft(defaultSelectionDraft);
|
||||
setTrendResult(null);
|
||||
@@ -780,6 +840,7 @@ export default function App() {
|
||||
};
|
||||
|
||||
const loadSelection = (selection) => {
|
||||
setSelectedSelectionId(selection.id);
|
||||
setWorkingPoints(selection.points);
|
||||
setSelectionDraft({
|
||||
name: selection.name,
|
||||
@@ -840,6 +901,10 @@ export default function App() {
|
||||
});
|
||||
const refreshed = await api.getSelections(token);
|
||||
setSelections(refreshed.selections);
|
||||
const savedSelection = refreshed.selections.find((selection) => selection.name === (selectionDraft.name || "Neue Auswahl")) || refreshed.selections[0];
|
||||
if (savedSelection) {
|
||||
setSelectedSelectionId(savedSelection.id);
|
||||
}
|
||||
setSuccessMessage("Auswahl gespeichert.");
|
||||
} catch (error) {
|
||||
setBootError(error.message);
|
||||
@@ -897,6 +962,11 @@ export default function App() {
|
||||
setCollectorDatapoints(datapointResult.datapoints || []);
|
||||
};
|
||||
|
||||
const refreshCollectorDebug = async () => {
|
||||
const result = await api.getCollectorDebug();
|
||||
setDebugSnapshot(result);
|
||||
};
|
||||
|
||||
const updateAliasPoint = (pointIndex, changes) => {
|
||||
setAliasEditor((current) => ({
|
||||
...current,
|
||||
@@ -1038,6 +1108,7 @@ export default function App() {
|
||||
const startNewSource = () => {
|
||||
setIsCreatingSource(true);
|
||||
setSelectedSourceId("");
|
||||
setManualDatapointDraft({ alias: "", address: "", pointIndex: "", unit: "°C", dataType: "holding-register", kind: "analog" });
|
||||
setSourceDraft({
|
||||
...defaultSourceDraft,
|
||||
port: getDefaultProtocolPort(defaultSourceDraft.protocol),
|
||||
@@ -1084,6 +1155,7 @@ export default function App() {
|
||||
});
|
||||
|
||||
await refreshCollectorData();
|
||||
await refreshCollectorDebug().catch(() => undefined);
|
||||
const refreshed = await api.getIsps(token);
|
||||
setIsps(refreshed.isps);
|
||||
setIsCreatingSource(false);
|
||||
@@ -1103,6 +1175,7 @@ export default function App() {
|
||||
try {
|
||||
await api.deleteCollectorSource(selectedSourceId);
|
||||
await refreshCollectorData();
|
||||
await refreshCollectorDebug().catch(() => undefined);
|
||||
setIsCreatingSource(false);
|
||||
setSelectedSourceId("");
|
||||
setSourceDraft(defaultSourceDraft);
|
||||
@@ -1112,6 +1185,44 @@ export default function App() {
|
||||
}
|
||||
};
|
||||
|
||||
const downloadModbusTemplate = () => {
|
||||
const sample = [
|
||||
"pointIndex;address;name;alias;unit;datatype;kind;scale",
|
||||
"1;40001;Vorlauf;Vorlauf;°C;holding-register;analog;1",
|
||||
"2;40002;Freigabe;Freigabe;;coil;digital;1",
|
||||
].join("\n");
|
||||
downloadText("modbus-muster.csv", sample, "text/csv;charset=utf-8;");
|
||||
};
|
||||
|
||||
const saveManualCollectorDatapoint = async () => {
|
||||
const activeSourceId = selectedSourceId || sourceDraft.id;
|
||||
if (!activeSourceId) {
|
||||
setBootError("Bitte zuerst eine Quelle speichern oder auswählen.");
|
||||
return;
|
||||
}
|
||||
|
||||
resetFlash();
|
||||
try {
|
||||
await api.createCollectorDatapoint({
|
||||
sourceId: activeSourceId,
|
||||
protocol: sourceDraft.protocol,
|
||||
alias: manualDatapointDraft.alias,
|
||||
name: manualDatapointDraft.alias,
|
||||
address: manualDatapointDraft.address,
|
||||
pointIndex: manualDatapointDraft.pointIndex,
|
||||
unit: manualDatapointDraft.unit || "°C",
|
||||
dataType: manualDatapointDraft.dataType,
|
||||
kind: manualDatapointDraft.kind,
|
||||
});
|
||||
await refreshCollectorData();
|
||||
await refreshCollectorDebug().catch(() => undefined);
|
||||
setManualDatapointDraft({ alias: "", address: "", pointIndex: "", unit: "°C", dataType: sourceDraft.protocol === "knx-ip" ? "group-address" : "holding-register", kind: "analog" });
|
||||
setSuccessMessage("Datenpunkt angelegt.");
|
||||
} catch (error) {
|
||||
setBootError(error.message);
|
||||
}
|
||||
};
|
||||
|
||||
const importCollectorFile = async (event, type) => {
|
||||
const file = event.target.files?.[0];
|
||||
if (!file) {
|
||||
@@ -1140,6 +1251,7 @@ export default function App() {
|
||||
}
|
||||
|
||||
await refreshCollectorData();
|
||||
await refreshCollectorDebug().catch(() => undefined);
|
||||
setSuccessMessage(type === "modbus" ? "Modbus-CSV importiert." : "KNX-XML importiert.");
|
||||
} catch (error) {
|
||||
setBootError(error.message);
|
||||
@@ -1156,6 +1268,7 @@ export default function App() {
|
||||
port: sourceDraft.port || "4840",
|
||||
});
|
||||
setScanResults((current) => ({ ...current, opcua: result }));
|
||||
await refreshCollectorDebug().catch(() => undefined);
|
||||
setSuccessMessage("OPC-UA-Scan abgeschlossen.");
|
||||
} catch (error) {
|
||||
setBootError(error.message);
|
||||
@@ -1175,6 +1288,7 @@ export default function App() {
|
||||
deviceId: sourceDraft.deviceId,
|
||||
});
|
||||
setScanResults((current) => ({ ...current, bacnet: result }));
|
||||
await refreshCollectorDebug().catch(() => undefined);
|
||||
setSuccessMessage("BACnet-Scan abgeschlossen.");
|
||||
} catch (error) {
|
||||
setBootError(error.message);
|
||||
@@ -1184,8 +1298,12 @@ export default function App() {
|
||||
const createUser = async () => {
|
||||
resetFlash();
|
||||
try {
|
||||
await api.createUser(token, userDraft);
|
||||
setUserDraft({ name: "", username: "", password: "", role: "viewer" });
|
||||
await api.createUser(token, {
|
||||
...userDraft,
|
||||
name: userDraft.name.trim(),
|
||||
username: userDraft.username.trim(),
|
||||
});
|
||||
setUserDraft({ name: "", username: "", password: "", role: roleOptions.find((role) => role.name === "viewer")?.name || "viewer" });
|
||||
const refreshed = await api.getUsers(token);
|
||||
setUsers(refreshed.users);
|
||||
setSuccessMessage("Benutzer angelegt.");
|
||||
@@ -1194,6 +1312,35 @@ export default function App() {
|
||||
}
|
||||
};
|
||||
|
||||
const saveRoleDefinition = async () => {
|
||||
resetFlash();
|
||||
try {
|
||||
const payload = {
|
||||
name: roleDraft.name.trim(),
|
||||
label: roleDraft.label.trim() || roleDraft.name.trim(),
|
||||
permissions: roleDraft.permissions,
|
||||
};
|
||||
if (!payload.name) {
|
||||
setBootError("Bitte zuerst einen Rollennamen vergeben.");
|
||||
return;
|
||||
}
|
||||
|
||||
const existing = roles.find((role) => role.name === payload.name);
|
||||
if (existing) {
|
||||
await api.updateRole(token, payload.name, payload);
|
||||
} else {
|
||||
await api.createRole(token, payload);
|
||||
}
|
||||
|
||||
const refreshed = await api.getRoles(token);
|
||||
setRoles(refreshed.roles || []);
|
||||
setRolePermissions(refreshed.permissions || PERMISSION_OPTIONS);
|
||||
setSuccessMessage("Rolle gespeichert.");
|
||||
} catch (error) {
|
||||
setBootError(error.message);
|
||||
}
|
||||
};
|
||||
|
||||
const updateUser = async (targetUser, changes) => {
|
||||
resetFlash();
|
||||
try {
|
||||
@@ -1262,7 +1409,18 @@ export default function App() {
|
||||
</div>
|
||||
|
||||
<nav className="nav-list">
|
||||
{views.filter((view) => view.id !== "admin" || canEditAliases).map((view) => (
|
||||
{views.filter((view) => {
|
||||
if (view.id === "dashboard") {
|
||||
return canViewDashboard;
|
||||
}
|
||||
if (view.id === "trends") {
|
||||
return canViewTrends;
|
||||
}
|
||||
if (view.id === "admin") {
|
||||
return canAccessAdmin;
|
||||
}
|
||||
return true;
|
||||
}).map((view) => (
|
||||
<button
|
||||
key={view.id}
|
||||
type="button"
|
||||
@@ -1515,6 +1673,26 @@ export default function App() {
|
||||
<button className="primary-button compact-button" type="button" onClick={runTrendQuery}>{busy ? "Lädt ..." : "Trend anzeigen"}</button>
|
||||
</div>
|
||||
|
||||
<label>
|
||||
<span>Gespeicherte Auswahl</span>
|
||||
<select
|
||||
value={selectedSelectionId}
|
||||
onChange={(event) => {
|
||||
if (!event.target.value) {
|
||||
startNewSelection();
|
||||
return;
|
||||
}
|
||||
const selection = selections.find((item) => item.id === event.target.value);
|
||||
if (selection) {
|
||||
loadSelection(selection);
|
||||
}
|
||||
}}
|
||||
>
|
||||
<option value="">Neue Auswahl</option>
|
||||
{selections.map((selection) => <option key={selection.id} value={selection.id}>{selection.name}</option>)}
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<div className="form-grid compact-forms">
|
||||
<label>
|
||||
<span>Name</span>
|
||||
@@ -1574,7 +1752,18 @@ export default function App() {
|
||||
{activeView === "admin" ? (
|
||||
<section className="admin-section">
|
||||
<div className="admin-subnav">
|
||||
{adminSections.filter((section) => section.id !== "users" || canManageUsers).map((section) => (
|
||||
{adminSections.filter((section) => {
|
||||
if (section.id === "users") {
|
||||
return canManageUsers;
|
||||
}
|
||||
if (section.id === "roles") {
|
||||
return canManageRoles;
|
||||
}
|
||||
if (section.id === "debug") {
|
||||
return canViewDebug;
|
||||
}
|
||||
return true;
|
||||
}).map((section) => (
|
||||
<button
|
||||
key={section.id}
|
||||
type="button"
|
||||
@@ -1586,7 +1775,7 @@ export default function App() {
|
||||
))}
|
||||
</div>
|
||||
|
||||
{adminSection === "aliases" ? (
|
||||
{adminSection === "aliases" && canEditAliases ? (
|
||||
<div className="panel slim-panel">
|
||||
<div className="panel-header compact-header">
|
||||
<div>
|
||||
@@ -1652,7 +1841,7 @@ export default function App() {
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{adminSection === "sources" ? (
|
||||
{adminSection === "sources" && (canManageSources || canEditAliases) ? (
|
||||
<div className="panel slim-panel">
|
||||
<div className="panel-header compact-header">
|
||||
<div>
|
||||
@@ -1777,6 +1966,7 @@ export default function App() {
|
||||
<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")} />
|
||||
@@ -1785,6 +1975,42 @@ export default function App() {
|
||||
<small className="muted">Zuletzt ausgewählte Quelle: {currentSource?.name || "noch keine"}</small>
|
||||
</div>
|
||||
|
||||
{(sourceDraft.protocol === "modbus-tcp" || sourceDraft.protocol === "knx-ip") ? (
|
||||
<div className="protocol-block">
|
||||
<strong>Einzelner Datenpunkt</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>{sourceDraft.protocol === "knx-ip" ? "Gruppenadresse" : "Adresse / Register"}</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="200" 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 value={manualDatapointDraft.unit} onChange={(nextValue) => setManualDatapointDraft((current) => ({ ...current, unit: nextValue }))} />
|
||||
</label>
|
||||
</div>
|
||||
<button className="ghost-button compact-button" type="button" onClick={saveManualCollectorDatapoint}>Datenpunkt anlegen</button>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="protocol-block">
|
||||
<strong>OPC UA</strong>
|
||||
<div className="button-row wrap-tight">
|
||||
@@ -1834,7 +2060,7 @@ export default function App() {
|
||||
<div className="panel-header compact-header nested-gap">
|
||||
<div>
|
||||
<h3>Benutzer</h3>
|
||||
<p>Login erfolgt über Username statt E-Mail. Rollen können nachträglich geändert werden.</p>
|
||||
<p>Login erfolgt über Username statt E-Mail. Die eigene Rolle bleibt gesperrt.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="form-grid compact-forms">
|
||||
@@ -1853,9 +2079,7 @@ export default function App() {
|
||||
<label>
|
||||
<span>Rolle</span>
|
||||
<select value={userDraft.role} onChange={(event) => setUserDraft((current) => ({ ...current, role: event.target.value }))}>
|
||||
<option value="viewer">viewer</option>
|
||||
<option value="technician">technician</option>
|
||||
<option value="admin">admin</option>
|
||||
{roleOptions.map((role) => <option key={role.name} value={role.name}>{role.label}</option>)}
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
@@ -1868,11 +2092,10 @@ export default function App() {
|
||||
<strong>{listedUser.name}</strong>
|
||||
<span>@{listedUser.username}</span>
|
||||
</div>
|
||||
<select value={listedUser.role} onChange={(event) => updateUser(listedUser, { role: event.target.value })}>
|
||||
<option value="viewer">viewer</option>
|
||||
<option value="technician">technician</option>
|
||||
<option value="admin">admin</option>
|
||||
<select value={listedUser.role} disabled={listedUser.id === user?.id} onChange={(event) => updateUser(listedUser, { role: event.target.value })}>
|
||||
{roleOptions.map((role) => <option key={role.name} value={role.name}>{role.label}</option>)}
|
||||
</select>
|
||||
{listedUser.id === user?.id ? <small className="muted">Eigene Rolle gesperrt</small> : null}
|
||||
<label className="checkbox-field compact-check">
|
||||
<input type="checkbox" checked={listedUser.active} onChange={(event) => updateUser(listedUser, { active: event.target.checked })} />
|
||||
<span>aktiv</span>
|
||||
@@ -1882,6 +2105,111 @@ export default function App() {
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{adminSection === "roles" && canManageRoles ? (
|
||||
<div className="panel slim-panel">
|
||||
<div className="panel-header compact-header nested-gap">
|
||||
<div>
|
||||
<h3>Rollen</h3>
|
||||
<p>Neue Rollen anlegen oder vorhandene Rechte gezielt anpassen.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="selection-list compact">
|
||||
{roles.map((role) => (
|
||||
<button
|
||||
key={role.name}
|
||||
type="button"
|
||||
className={roleDraft.name === role.name ? "selection-card source-card active" : "selection-card source-card"}
|
||||
onClick={() => setRoleDraft({ name: role.name, label: role.label, permissions: role.permissions || [] })}
|
||||
>
|
||||
<div>
|
||||
<strong>{role.label}</strong>
|
||||
<span>{role.name}</span>
|
||||
</div>
|
||||
<span>{(role.permissions || []).length} Rechte</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="form-grid compact-forms">
|
||||
<label>
|
||||
<span>Rollenname</span>
|
||||
<input value={roleDraft.name} readOnly={Boolean(roles.find((role) => role.name === roleDraft.name)?.isSystem)} onChange={(event) => setRoleDraft((current) => ({ ...current, name: event.target.value }))} />
|
||||
</label>
|
||||
<label>
|
||||
<span>Anzeigename</span>
|
||||
<input value={roleDraft.label} onChange={(event) => setRoleDraft((current) => ({ ...current, label: event.target.value }))} />
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="selection-list compact">
|
||||
{rolePermissions.map((permission) => (
|
||||
<label key={permission.id} className="checkbox-field compact-check">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={roleDraft.permissions.includes(permission.id)}
|
||||
onChange={(event) => setRoleDraft((current) => ({
|
||||
...current,
|
||||
permissions: event.target.checked
|
||||
? current.permissions.concat(permission.id).filter((value, index, list) => list.indexOf(value) === index)
|
||||
: current.permissions.filter((entry) => entry !== permission.id),
|
||||
}))}
|
||||
/>
|
||||
<span>{permission.label}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="button-row wrap-tight">
|
||||
<button className="ghost-button compact-button" type="button" onClick={() => setRoleDraft({ name: "", label: "", permissions: ["view_dashboard", "view_trends"] })}>Neue Rolle</button>
|
||||
<button className="primary-button compact-button" type="button" onClick={saveRoleDefinition}>Rolle speichern</button>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{adminSection === "debug" && canViewDebug ? (
|
||||
<div className="panel slim-panel">
|
||||
<div className="panel-header compact-header nested-gap">
|
||||
<div>
|
||||
<h3>Treiber-Debug</h3>
|
||||
<p>Status von Quellen, Datenpunkten und letzten Scans.</p>
|
||||
</div>
|
||||
<button className="ghost-button compact-button" type="button" onClick={refreshCollectorDebug}>Aktualisieren</button>
|
||||
</div>
|
||||
|
||||
<div className="grid two compact-grid">
|
||||
<div className="panel nested-panel">
|
||||
<h3>Collector</h3>
|
||||
<p>{debugSnapshot?.driverState?.note || "Noch keine Debug-Daten geladen."}</p>
|
||||
<div className="selection-list compact">
|
||||
<div className="selection-card source-card preview-card"><strong>Quellen</strong><span>{debugSnapshot?.sourceCount || 0}</span></div>
|
||||
<div className="selection-card source-card preview-card"><strong>Datenpunkte</strong><span>{debugSnapshot?.datapointCount || 0}</span></div>
|
||||
<div className="selection-card source-card preview-card"><strong>Letztes Update</strong><span>{debugSnapshot?.updatedAt ? formatTimestampLabel(debugSnapshot.updatedAt) : "-"}</span></div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="panel nested-panel">
|
||||
<h3>Letzte Scans</h3>
|
||||
<div className="selection-list compact">
|
||||
<div className="selection-card source-card preview-card"><strong>OPC UA</strong><span>{debugSnapshot?.latestScans?.opcua?.reachable ? `${debugSnapshot.latestScans.opcua.endpoints.length} Endpunkte` : "kein Treffer"}</span></div>
|
||||
<div className="selection-card source-card preview-card"><strong>BACnet</strong><span>{debugSnapshot?.latestScans?.bacnet?.devices?.length || 0} Geräte</span></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="selection-list compact source-list">
|
||||
{(debugSnapshot?.sources || []).map((source) => (
|
||||
<div key={source.id} className="selection-card source-card preview-card">
|
||||
<div>
|
||||
<strong>{source.name}</strong>
|
||||
<span>{source.protocol} · {source.host || "ohne Host"}</span>
|
||||
</div>
|
||||
<span>{source.datapointCount} DP</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
@@ -1963,3 +2291,25 @@ export default function App() {
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -62,6 +62,9 @@ export const api = {
|
||||
getUsers: (token) => apiRequest("/users", { token }),
|
||||
createUser: (token, body) => apiRequest("/users", { method: "POST", token, body }),
|
||||
updateUser: (token, id, body) => apiRequest(`/users/${id}`, { method: "PATCH", token, body }),
|
||||
getRoles: (token) => apiRequest("/roles", { token }),
|
||||
createRole: (token, body) => apiRequest("/roles", { method: "POST", token, body }),
|
||||
updateRole: (token, name, body) => apiRequest(`/roles/${encodeURIComponent(name)}`, { method: "PATCH", token, body }),
|
||||
getSelections: (token) => apiRequest("/selections", { token }),
|
||||
createSelection: (token, body) => apiRequest("/selections", { method: "POST", token, body }),
|
||||
updateSelection: (token, id, body) => apiRequest(`/selections/${id}`, { method: "PUT", token, body }),
|
||||
@@ -79,6 +82,8 @@ export const api = {
|
||||
createCollectorSource: (body) => collectorRequest("/sources", { method: "POST", body }),
|
||||
updateCollectorSource: (id, body) => collectorRequest(`/sources/${id}`, { method: "PUT", body }),
|
||||
deleteCollectorSource: (id) => collectorRequest(`/sources/${id}`, { method: "DELETE" }),
|
||||
createCollectorDatapoint: (body) => collectorRequest("/datapoints/manual", { method: "POST", body }),
|
||||
getCollectorDebug: () => collectorRequest("/debug"),
|
||||
importModbusCsv: (body) => collectorRequest("/imports/modbus-csv", { method: "POST", body }),
|
||||
importKnxXml: (body) => collectorRequest("/imports/knx-xml", { method: "POST", body }),
|
||||
scanOpcua: (body) => collectorRequest("/scan/opcua", { method: "POST", body }),
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useMemo, useRef, useState } from "react";
|
||||
import { useMemo, useRef, useState } from "react";
|
||||
import { jsPDF } from "jspdf";
|
||||
import * as XLSX from "xlsx";
|
||||
import {
|
||||
@@ -32,6 +32,31 @@ function buildRandomColor(index) {
|
||||
return `hsl(${hue} ${saturation}% ${lightness}%)`;
|
||||
}
|
||||
|
||||
function getAnalogDomain(rows, visibleSeries) {
|
||||
const values = [];
|
||||
rows.forEach((row) => {
|
||||
visibleSeries.filter((item) => item.kind !== "digital").forEach((item) => {
|
||||
const value = row[item.key];
|
||||
if (Number.isFinite(value)) {
|
||||
values.push(value);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
if (!values.length) {
|
||||
return [0, 1];
|
||||
}
|
||||
|
||||
const min = Math.min(...values);
|
||||
const max = Math.max(...values);
|
||||
if (min === max) {
|
||||
return [min - 1, max + 1];
|
||||
}
|
||||
|
||||
const padding = (max - min) * 0.08;
|
||||
return [min - padding, max + padding];
|
||||
}
|
||||
|
||||
export default function TrendChart({
|
||||
result,
|
||||
title = "Trendansicht",
|
||||
@@ -66,6 +91,8 @@ export default function TrendChart({
|
||||
})), [result]);
|
||||
|
||||
const visibleSeries = selectedKeys.length ? series.filter((item) => selectedKeys.includes(item.key)) : series;
|
||||
const analogSeries = visibleSeries.filter((item) => item.kind !== "digital");
|
||||
const digitalSeries = visibleSeries.filter((item) => item.kind === "digital");
|
||||
|
||||
const baseXDomain = useMemo(() => {
|
||||
if (!rows.length) {
|
||||
@@ -75,29 +102,11 @@ export default function TrendChart({
|
||||
}, [rows]);
|
||||
|
||||
const baseYDomain = useMemo(() => {
|
||||
const values = [];
|
||||
rows.forEach((row) => {
|
||||
visibleSeries.forEach((item) => {
|
||||
const value = row[item.key];
|
||||
if (Number.isFinite(value)) {
|
||||
values.push(value);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
if (!values.length) {
|
||||
return [0, 1];
|
||||
if (analogSeries.length === 0 && digitalSeries.length > 0) {
|
||||
return [0, 2];
|
||||
}
|
||||
|
||||
const min = Math.min(...values);
|
||||
const max = Math.max(...values);
|
||||
if (min === max) {
|
||||
return [min - 1, max + 1];
|
||||
}
|
||||
|
||||
const padding = (max - min) * 0.08;
|
||||
return [min - padding, max + padding];
|
||||
}, [rows, visibleSeries]);
|
||||
return getAnalogDomain(rows, visibleSeries);
|
||||
}, [analogSeries.length, digitalSeries.length, rows, visibleSeries]);
|
||||
|
||||
const xDomain = zoomDomain?.x || baseXDomain;
|
||||
const yDomain = zoomDomain?.y || baseYDomain;
|
||||
@@ -375,7 +384,18 @@ export default function TrendChart({
|
||||
minTickGap={28}
|
||||
stroke="var(--muted)"
|
||||
/>
|
||||
<YAxis stroke="var(--muted)" domain={yDomain} width={52} />
|
||||
{analogSeries.length ? <YAxis yAxisId="analog" stroke="var(--muted)" domain={yDomain} width={52} /> : null}
|
||||
{digitalSeries.length ? (
|
||||
<YAxis
|
||||
yAxisId="digital"
|
||||
orientation="right"
|
||||
domain={[0, 2]}
|
||||
ticks={[0, 1]}
|
||||
width={46}
|
||||
stroke="var(--muted)"
|
||||
allowDecimals={false}
|
||||
/>
|
||||
) : null}
|
||||
<Tooltip
|
||||
labelFormatter={(value) => new Date(value).toLocaleString("de-DE")}
|
||||
contentStyle={{
|
||||
@@ -401,7 +421,8 @@ export default function TrendChart({
|
||||
return (
|
||||
<Line
|
||||
key={entry.key}
|
||||
type="monotone"
|
||||
yAxisId={entry.kind === "digital" ? "digital" : "analog"}
|
||||
type={entry.kind === "digital" ? "stepAfter" : "monotone"}
|
||||
dataKey={entry.key}
|
||||
dot={false}
|
||||
strokeWidth={selectedKeys.includes(entry.key) ? 2.35 : 1.8}
|
||||
|
||||
Reference in New Issue
Block a user