Add update script, enhance admin features, and improve styles
- Created a PowerShell script (update.ps1) for automating the process of pulling the latest repository changes, stopping, building, and starting the Docker Compose stack. - Updated App.jsx to include new admin sections for alarms and integrations, added state management for runner jobs, and enhanced permission checks. - Extended the API module (api.js) with new endpoints for managing integrations and runner jobs. - Improved CSS styles for a more compact and responsive layout in the admin interface, focusing on source and datapoint cards.
This commit is contained in:
+409
-18
@@ -1,4 +1,4 @@
|
||||
|
||||
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { api } from "./api.js";
|
||||
import Gauge from "./components/Gauge.jsx";
|
||||
@@ -15,8 +15,11 @@ const adminSections = [
|
||||
{ id: "aliases", label: "Aliase" },
|
||||
{ id: "units", label: "Einheiten" },
|
||||
{ id: "sources", label: "Quellen" },
|
||||
{ id: "alarms", label: "Alarme" },
|
||||
{ id: "users", label: "Benutzer" },
|
||||
{ id: "roles", label: "Rollen" },
|
||||
{ id: "integrations", label: "API" },
|
||||
{ id: "runner", label: "Runner" },
|
||||
{ id: "debug", label: "Debug" },
|
||||
];
|
||||
|
||||
@@ -86,6 +89,9 @@ const PERMISSION_OPTIONS = [
|
||||
{ id: "manage_users", label: "Benutzer verwalten" },
|
||||
{ id: "manage_roles", label: "Rollen verwalten" },
|
||||
{ id: "view_debug", label: "Debug anzeigen" },
|
||||
{ id: "manage_alarms", label: "Alarme verwalten" },
|
||||
{ id: "manage_integrations", label: "API-Verbindungen verwalten" },
|
||||
{ id: "manage_runner", label: "CSV-Runner verwalten" },
|
||||
];
|
||||
|
||||
const pointColors = [
|
||||
@@ -724,9 +730,13 @@ export default function App() {
|
||||
const [sourceModalOpen, setSourceModalOpen] = useState(false);
|
||||
const emptyManualDatapointDraft = (protocol = defaultSourceDraft.protocol, kind = "analog") => ({ alias: "", address: "", pointIndex: "", unit: getDefaultUnitForKind(kind), dataType: getDefaultPointDataType(protocol), kind, scale: "1", writeMode: "", condition: "" });
|
||||
const [collectorPointFilter, setCollectorPointFilter] = useState("");
|
||||
const [alarmPointFilter, setAlarmPointFilter] = useState("");
|
||||
const [manualDatapointDraft, setManualDatapointDraft] = useState(emptyManualDatapointDraft());
|
||||
const [scanResults, setScanResults] = useState({ opcua: null, bacnet: null });
|
||||
const [debugSnapshot, setDebugSnapshot] = useState(null);
|
||||
const [integrationSettings, setIntegrationSettings] = useState({ mailApiKey: "", whatsappApiKey: "", phoneApiKey: "" });
|
||||
const [runnerJobs, setRunnerJobs] = useState([]);
|
||||
const [runnerDraft, setRunnerDraft] = useState({ name: "", selectionId: "", enabled: true, time: "06:00", rangeHours: "24", delimiter: ";", mailEnabled: false, mailTo: "", mailSubject: "Trenddaten", mailMessage: "Automatischer Export aus SE Local Trenddata." });
|
||||
|
||||
const trendIsps = useMemo(() => isps.filter((isp) => Number(isp.rowCount) > 0), [isps]);
|
||||
const selectedMetadata = selectedIsp ? metadataCache[selectedIsp] : null;
|
||||
@@ -738,8 +748,11 @@ export default function App() {
|
||||
const canManageUsers = isAuthenticated && hasPermission(user, "manage_users");
|
||||
const canManageRoles = isAuthenticated && hasPermission(user, "manage_roles");
|
||||
const canViewDebug = isAuthenticated && hasPermission(user, "view_debug");
|
||||
const canAccessAdmin = canEditAliases || canManageSources || canManageUsers || canManageRoles || canViewDebug;
|
||||
const firstAdminSection = canEditAliases ? "aliases" : canManageSources ? "sources" : canManageUsers ? "users" : canManageRoles ? "roles" : canViewDebug ? "debug" : "aliases";
|
||||
const canManageAlarms = isAuthenticated && hasPermission(user, "manage_alarms");
|
||||
const canManageIntegrations = isAuthenticated && hasPermission(user, "manage_integrations");
|
||||
const canManageRunner = isAuthenticated && hasPermission(user, "manage_runner");
|
||||
const canAccessAdmin = canEditAliases || canManageSources || canManageAlarms || canManageUsers || canManageRoles || canManageIntegrations || canManageRunner || canViewDebug;
|
||||
const firstAdminSection = canEditAliases ? "aliases" : canManageSources ? "sources" : canManageAlarms ? "alarms" : canManageUsers ? "users" : canManageRoles ? "roles" : canManageIntegrations ? "integrations" : canManageRunner ? "runner" : canViewDebug ? "debug" : "aliases";
|
||||
const sidebarCollapsed = !sidebarPinned && !sidebarHover;
|
||||
const currentView = views.find((view) => view.id === activeView) || views[0];
|
||||
const selectionListClassName = selections.length <= 2 ? "selection-list compact" : "selection-list";
|
||||
@@ -765,6 +778,8 @@ 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 alarmCollectorPoints = useMemo(() => collectorSourcePoints.filter((item) => item.alarmEnabled === true), [collectorSourcePoints]);
|
||||
const availableAlarmCollectorPoints = useMemo(() => collectorSourcePoints.filter((item) => item.alarmEnabled !== true), [collectorSourcePoints]);
|
||||
const sourceToolProtocol = currentSource?.protocol || sourceDraft.protocol;
|
||||
|
||||
const upsertIspEntry = (entry) => {
|
||||
@@ -841,6 +856,18 @@ export default function App() {
|
||||
item.currentValue,
|
||||
])),
|
||||
[availableCollectorPoints, collectorPointFilter]
|
||||
); const filteredAvailableAlarmPoints = useMemo(
|
||||
() => availableAlarmCollectorPoints.filter((item) => matchesSearch(alarmPointFilter, [
|
||||
item.alias,
|
||||
item.name,
|
||||
item.address,
|
||||
item.nodeId,
|
||||
item.dataType,
|
||||
item.protocol,
|
||||
item.pointIndex,
|
||||
item.currentValue,
|
||||
])),
|
||||
[alarmPointFilter, availableAlarmCollectorPoints]
|
||||
);
|
||||
|
||||
const resetFlash = () => {
|
||||
@@ -1082,6 +1109,27 @@ export default function App() {
|
||||
.then((result) => setUnits(result.units || DEFAULT_UNIT_OPTIONS))
|
||||
.catch((error) => setBootError(error.message));
|
||||
}, [canEditAliases, canManageSources, token]);
|
||||
useEffect(() => {
|
||||
if (!token || !canManageIntegrations) {
|
||||
setIntegrationSettings({ mailApiKey: "", whatsappApiKey: "", phoneApiKey: "" });
|
||||
return;
|
||||
}
|
||||
|
||||
api.getIntegrations(token)
|
||||
.then((result) => setIntegrationSettings(result.settings || { mailApiKey: "", whatsappApiKey: "", phoneApiKey: "" }))
|
||||
.catch((error) => setBootError(error.message));
|
||||
}, [canManageIntegrations, token]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!token || !canManageRunner) {
|
||||
setRunnerJobs([]);
|
||||
return;
|
||||
}
|
||||
|
||||
api.getRunnerJobs(token)
|
||||
.then((result) => setRunnerJobs(result.jobs || []))
|
||||
.catch((error) => setBootError(error.message));
|
||||
}, [canManageRunner, token]);
|
||||
|
||||
useEffect(() => {
|
||||
const refreshIsps = () => {
|
||||
@@ -1105,7 +1153,7 @@ export default function App() {
|
||||
}, [token]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!token || !(canEditAliases || canManageSources || canViewDebug)) {
|
||||
if (!token || !(canEditAliases || canManageSources || canManageAlarms || canViewDebug)) {
|
||||
setCollectorCapabilities(null);
|
||||
setCollectorSources([]);
|
||||
setCollectorDatapoints([]);
|
||||
@@ -1117,7 +1165,7 @@ export default function App() {
|
||||
.catch((error) => { setCollectorCapabilities(null); setCollectorError(error.message); });
|
||||
|
||||
refreshCollectorData().catch((error) => setCollectorError(error.message));
|
||||
}, [canEditAliases, canManageSources, canViewDebug, token]);
|
||||
}, [canEditAliases, canManageSources, canManageAlarms, canViewDebug, token]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!currentSource) {
|
||||
@@ -1668,6 +1716,13 @@ export default function App() {
|
||||
name: sourceDraft.name.trim(),
|
||||
tableName: sourceDraft.id ? (currentSource?.tableName || sourceDraft.tableName || buildCollectorTableName(sourceDraft.name, sourceDraft.protocol)) : buildCollectorTableName(sourceDraft.name, sourceDraft.protocol),
|
||||
displayName: sourceDraft.name.trim(),
|
||||
options: {
|
||||
...(currentSource?.options || {}),
|
||||
bacnetMode: sourceDraft.bacnetMode,
|
||||
bacnetBroadcastAddress: sourceDraft.bacnetBroadcastAddress,
|
||||
bacnetScanSubnets: sourceDraft.bacnetScanSubnets,
|
||||
bacnetForeignDeviceTtl: sourceDraft.bacnetForeignDeviceTtl,
|
||||
},
|
||||
};
|
||||
|
||||
const saved = payload.id
|
||||
@@ -1829,6 +1884,7 @@ export default function App() {
|
||||
|
||||
const runOpcuaScan = async () => {
|
||||
const activeSourceId = selectedSourceId || sourceDraft.id;
|
||||
const activeSource = collectorSources.find((source) => source.id === activeSourceId) || null;
|
||||
if (!activeSourceId) {
|
||||
setBootError("Bitte zuerst eine Quelle auswählen oder anlegen.");
|
||||
return;
|
||||
@@ -1837,8 +1893,8 @@ export default function App() {
|
||||
resetFlash();
|
||||
try {
|
||||
const result = await api.scanOpcua({
|
||||
host: sourceDraft.host,
|
||||
port: sourceDraft.port || "4840",
|
||||
host: activeSource?.host || sourceDraft.host,
|
||||
port: activeSource?.port || sourceDraft.port || "4840",
|
||||
sourceId: activeSourceId,
|
||||
});
|
||||
setScanResults((current) => ({ ...current, opcua: result }));
|
||||
@@ -1895,6 +1951,10 @@ export default function App() {
|
||||
bacnetBroadcastAddress: draft.bacnetBroadcastAddress || "auto",
|
||||
clientHost: typeof window !== "undefined" ? window.location.hostname : "",
|
||||
bacnetForeignDeviceTtl: draft.bacnetForeignDeviceTtl || "120",
|
||||
timeout: options.timeout || 12000,
|
||||
initialWaitMs: options.initialWaitMs || 6000,
|
||||
finalWaitMs: options.finalWaitMs || 1800,
|
||||
unicastDelayMs: options.unicastDelayMs || 140,
|
||||
});
|
||||
setScanResults((current) => ({ ...current, bacnet: result }));
|
||||
await refreshCollectorData();
|
||||
@@ -1976,6 +2036,79 @@ export default function App() {
|
||||
}
|
||||
};
|
||||
|
||||
const saveIntegrationSettings = async () => {
|
||||
resetFlash();
|
||||
try {
|
||||
const result = await api.saveIntegrations(token, integrationSettings);
|
||||
setIntegrationSettings(result.settings || integrationSettings);
|
||||
setSuccessMessage("API-Verbindungen gespeichert.");
|
||||
} catch (error) {
|
||||
setBootError(error.message);
|
||||
}
|
||||
};
|
||||
|
||||
const resetRunnerDraft = () => {
|
||||
setRunnerDraft({ name: "", selectionId: "", enabled: true, time: "06:00", rangeHours: "24", delimiter: ";", mailEnabled: false, mailTo: "", mailSubject: "Trenddaten", mailMessage: "Automatischer Export aus SE Local Trenddata." });
|
||||
};
|
||||
|
||||
const editRunnerJob = (job) => {
|
||||
setRunnerDraft({
|
||||
id: job.id,
|
||||
name: job.name || "",
|
||||
selectionId: job.selectionId || "",
|
||||
enabled: job.enabled !== false,
|
||||
time: job.time || "06:00",
|
||||
rangeHours: String(job.rangeHours || 24),
|
||||
delimiter: job.delimiter || ";",
|
||||
mailEnabled: job.mailEnabled === true,
|
||||
mailTo: job.mailTo || "",
|
||||
mailSubject: job.mailSubject || "Trenddaten",
|
||||
mailMessage: job.mailMessage || "Automatischer Export aus SE Local Trenddata.",
|
||||
});
|
||||
};
|
||||
|
||||
const saveRunnerJob = async () => {
|
||||
resetFlash();
|
||||
try {
|
||||
if (!runnerDraft.selectionId) {
|
||||
setBootError("Bitte eine gespeicherte Auswahl für den Export wählen.");
|
||||
return;
|
||||
}
|
||||
const payload = {
|
||||
...runnerDraft,
|
||||
rangeHours: Number(runnerDraft.rangeHours) || 24,
|
||||
};
|
||||
if (runnerDraft.id) {
|
||||
await api.updateRunnerJob(token, runnerDraft.id, payload);
|
||||
} else {
|
||||
await api.createRunnerJob(token, payload);
|
||||
}
|
||||
const refreshed = await api.getRunnerJobs(token);
|
||||
setRunnerJobs(refreshed.jobs || []);
|
||||
resetRunnerDraft();
|
||||
setSuccessMessage("Runner-Job gespeichert.");
|
||||
} catch (error) {
|
||||
setBootError(error.message);
|
||||
}
|
||||
};
|
||||
|
||||
const deleteRunnerJob = async (job) => {
|
||||
resetFlash();
|
||||
if (!window.confirm(`Runner-Job "${job.name || job.id}" wirklich löschen?`)) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await api.deleteRunnerJob(token, job.id);
|
||||
const refreshed = await api.getRunnerJobs(token);
|
||||
setRunnerJobs(refreshed.jobs || []);
|
||||
if (runnerDraft.id === job.id) {
|
||||
resetRunnerDraft();
|
||||
}
|
||||
setSuccessMessage("Runner-Job gelöscht.");
|
||||
} catch (error) {
|
||||
setBootError(error.message);
|
||||
}
|
||||
};
|
||||
const updateUser = async (targetUser, changes) => {
|
||||
resetFlash();
|
||||
try {
|
||||
@@ -2448,12 +2581,21 @@ export default function App() {
|
||||
if (section.id === "sources") {
|
||||
return canManageSources || canEditAliases;
|
||||
}
|
||||
if (section.id === "alarms") {
|
||||
return canManageAlarms;
|
||||
}
|
||||
if (section.id === "users") {
|
||||
return canManageUsers;
|
||||
}
|
||||
if (section.id === "roles") {
|
||||
return canManageRoles;
|
||||
}
|
||||
if (section.id === "integrations") {
|
||||
return canManageIntegrations;
|
||||
}
|
||||
if (section.id === "runner") {
|
||||
return canManageRunner;
|
||||
}
|
||||
if (section.id === "debug") {
|
||||
return canViewDebug;
|
||||
}
|
||||
@@ -2679,16 +2821,7 @@ export default function App() {
|
||||
</div>
|
||||
|
||||
<div className="protocol-block">
|
||||
<strong>{sourceToolProtocol === "opc-ua" ? "OPC UA" : sourceToolProtocol === "bacnet-ip" ? "BACnet IP Subnetze" : sourceToolProtocol === "modbus-tcp" ? "Modbus TCP" : sourceToolProtocol === "knx-ip" ? "KNX IP" : "Quelle"}</strong>
|
||||
{sourceToolProtocol === "bacnet-ip" ? (
|
||||
<>
|
||||
<label className="compact-label">
|
||||
<span>Subnetze</span>
|
||||
<input value={sourceDraft.bacnetScanSubnets || ""} onBlur={saveBacnetSubnetSetting} onKeyDown={(event) => { if (event.key === "Enter") { event.currentTarget.blur(); } }} onChange={(event) => setSourceDraft((current) => ({ ...current, bacnetScanSubnets: event.target.value }))} placeholder="z.B. 192.168.1.0/24; 10.21.2.0/24" />
|
||||
</label>
|
||||
<small className="muted">Wird gespeichert und vom BACnet-Scan der Quelle für Device- und Objektsuche verwendet.</small>
|
||||
</>
|
||||
) : null}
|
||||
<strong>{sourceToolProtocol === "opc-ua" ? "OPC UA" : sourceToolProtocol === "bacnet-ip" ? "BACnet IP" : sourceToolProtocol === "modbus-tcp" ? "Modbus TCP" : sourceToolProtocol === "knx-ip" ? "KNX IP" : "Quelle"}</strong>
|
||||
{sourceToolProtocol === "opc-ua" ? <><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">{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 === "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}
|
||||
@@ -2821,6 +2954,12 @@ export default function App() {
|
||||
<input value={sourceDraft.bacnetBroadcastAddress} onChange={(event) => setSourceDraft((current) => ({ ...current, bacnetBroadcastAddress: event.target.value }))} placeholder="auto oder z.B. 192.168.1.255" />
|
||||
</label>
|
||||
) : null}
|
||||
{sourceDraft.protocol === "bacnet-ip" ? (
|
||||
<label className="span-two">
|
||||
<span>Scan-Subnetze / Einzelhosts</span>
|
||||
<input value={sourceDraft.bacnetScanSubnets || ""} onChange={(event) => setSourceDraft((current) => ({ ...current, bacnetScanSubnets: event.target.value }))} placeholder="z.B. 192.168.153.0/24; 10.21.2.212; 172.16.9.117/32" />
|
||||
</label>
|
||||
) : null}
|
||||
|
||||
{sourceDraft.protocol === "bacnet-ip" && sourceDraft.bacnetMode === "foreign-device" ? (
|
||||
<label>
|
||||
@@ -2935,7 +3074,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.deviceName || point.deviceId || point.host, "Gerät ?")} · ${normalizeDisplayText(point.host)} · ${normalizeDisplayText(point.dataType || "obj")}${point.objectInstance || ""}${point.unit ? ` · ${normalizeDisplayUnit(point.unit)}` : ""}`}</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>
|
||||
@@ -2994,6 +3133,124 @@ export default function App() {
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
{adminSection === "alarms" && canManageAlarms ? (
|
||||
<div className="panel slim-panel source-admin-panel">
|
||||
<div className="panel-header compact-header">
|
||||
<div>
|
||||
<h3>Alarme</h3>
|
||||
<p>Datenpunkte aus vorhandenen Quellen gezielt in die Alarmüberwachung übernehmen.</p>
|
||||
</div>
|
||||
<div className="button-row wrap-tight">
|
||||
{currentSource?.protocol === "opc-ua" ? <button className="ghost-button compact-button" type="button" onClick={runOpcuaScan}>OPC scannen</button> : null}
|
||||
{currentSource?.protocol === "bacnet-ip" ? <button className="ghost-button compact-button" type="button" onClick={() => runBacnetScan({ source: currentSource, includeObjects: true })}>BACnet scannen</button> : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="source-admin-top alarm-admin-top">
|
||||
<div className="protocol-panel source-overview-panel">
|
||||
<div className="panel-header compact-header nested-gap">
|
||||
<div>
|
||||
<h3>Quellen</h3>
|
||||
<p>Quelle auswählen, danach links Punkte ins Alarming schieben.</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;
|
||||
const alarmCount = collectorDatapoints.filter((item) => item.sourceId === source.id && item.alarmEnabled === true).length;
|
||||
return (
|
||||
<button
|
||||
key={source.id}
|
||||
type="button"
|
||||
className={isActive ? "selection-card source-card active source-tile" : "selection-card source-card source-tile"}
|
||||
onClick={() => {
|
||||
setSelectedSourceId(source.id);
|
||||
setAlarmPointFilter("");
|
||||
}}
|
||||
>
|
||||
<div className="source-tile-copy">
|
||||
<strong>{normalizeDisplayText(source.name, "Quelle")}</strong>
|
||||
<span>{`${source.protocol} | ${normalizeDisplayText(source.tableName)}`}</span>
|
||||
<small>{`${alarmCount} Alarm(e) | ${pointCount} DP`}</small>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
{!collectorSources.length ? <div className="empty-state slim-empty"><p>Noch keine Quelle angelegt.</p></div> : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="protocol-panel source-settings-panel">
|
||||
<div className="panel-header compact-header nested-gap">
|
||||
<div>
|
||||
<h3>Aktive Quelle</h3>
|
||||
<p>{currentSource ? `${normalizeDisplayText(currentSource.name)} · ${currentSource.protocol}` : "Bitte links eine Quelle auswählen."}</p>
|
||||
</div>
|
||||
</div>
|
||||
<input
|
||||
className="search-input"
|
||||
placeholder="Suche: Alias, Adresse, NodeId oder Wertnummer"
|
||||
value={alarmPointFilter}
|
||||
onChange={(event) => setAlarmPointFilter(event.target.value)}
|
||||
/>
|
||||
<small className="muted">Alarming ist unabhängig vom Trendlogging. Ein Punkt kann alarmiert werden, ohne in die Trendtabelle geschrieben zu werden.</small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="source-split alarm-split">
|
||||
<div className="protocol-block datapoint-preview">
|
||||
<div className="panel-header compact-header nested-gap">
|
||||
<div>
|
||||
<h3>Verfügbare Datenpunkte</h3>
|
||||
<p>Gescannt, importiert oder manuell angelegt. Nur per Klick landet ein Punkt im Alarming.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="scan-result-list">
|
||||
{filteredAvailableAlarmPoints.length ? filteredAvailableAlarmPoints.slice(0, 2000).map((item) => (
|
||||
<div key={item.id} className="datapoint-row inactive">
|
||||
<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.currentValue === null || item.currentValue === undefined ? "kein Live-Wert" : formatCollectorValue(item.currentValue)}</small>
|
||||
</div>
|
||||
<span className="datapoint-value">{item.kind === "digital" ? "digital" : 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="primary-button compact-button" type="button" onClick={() => updateCollectorPoint(item, { alarmEnabled: true })}>Ins Alarming</button>
|
||||
</div>
|
||||
</div>
|
||||
)) : <div className="empty-state slim-empty"><p>{currentSource ? "Keine verfügbaren Punkte für diese Quelle. Starte ggf. einen Scan oder Import." : "Bitte zuerst eine Quelle auswählen."}</p></div>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="protocol-block datapoint-preview">
|
||||
<div className="panel-header compact-header nested-gap">
|
||||
<div>
|
||||
<h3>Wird alarmiert</h3>
|
||||
<p>Diese Punkte sind für die spätere Alarmüberwachung markiert.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="datapoint-table">
|
||||
{alarmCollectorPoints.length ? alarmCollectorPoints.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.alarmCondition ? normalizeDisplayText(item.alarmCondition) : "noch keine Bedingung gesetzt"}</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 danger" type="button" onClick={() => updateCollectorPoint(item, { alarmEnabled: false })}>Rausnehmen</button>
|
||||
</div>
|
||||
</div>
|
||||
)) : <div className="empty-state slim-empty"><p>Für diese Quelle ist noch kein Datenpunkt im Alarming.</p></div>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
{adminSection === "users" && canManageUsers ? (
|
||||
<div className="panel slim-panel">
|
||||
<div className="panel-header compact-header nested-gap">
|
||||
@@ -3107,6 +3364,125 @@ export default function App() {
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{adminSection === "integrations" && canManageIntegrations ? (
|
||||
<div className="panel slim-panel">
|
||||
<div className="panel-header compact-header nested-gap">
|
||||
<div>
|
||||
<h3>API-Verbindungen</h3>
|
||||
<p>Keys für Mail, WhatsApp und Telefon. Requests werden serverseitig per IPv4 ausgeführt.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="form-grid compact-forms">
|
||||
<label>
|
||||
<span>Mail API-Key</span>
|
||||
<input type="password" value={integrationSettings.mailApiKey || ""} onChange={(event) => setIntegrationSettings((current) => ({ ...current, mailApiKey: event.target.value }))} />
|
||||
</label>
|
||||
<label>
|
||||
<span>WhatsApp API-Key</span>
|
||||
<input type="password" value={integrationSettings.whatsappApiKey || ""} onChange={(event) => setIntegrationSettings((current) => ({ ...current, whatsappApiKey: event.target.value }))} />
|
||||
</label>
|
||||
<label>
|
||||
<span>Telefon API-Key</span>
|
||||
<input type="password" value={integrationSettings.phoneApiKey || ""} onChange={(event) => setIntegrationSettings((current) => ({ ...current, phoneApiKey: event.target.value }))} />
|
||||
</label>
|
||||
</div>
|
||||
<button className="primary-button compact-button" type="button" onClick={saveIntegrationSettings}>API-Keys speichern</button>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{adminSection === "runner" && canManageRunner ? (
|
||||
<div className="panel slim-panel">
|
||||
<div className="panel-header compact-header nested-gap">
|
||||
<div>
|
||||
<h3>CSV-Runner</h3>
|
||||
<p>Gespeicherte Auswahlen automatisch als CSV ins Dateisystem exportieren und optional per Mail verschicken.</p>
|
||||
</div>
|
||||
<button className="ghost-button compact-button" type="button" onClick={resetRunnerDraft}>Neuer Job</button>
|
||||
</div>
|
||||
|
||||
<div className="form-grid compact-forms">
|
||||
<label>
|
||||
<span>Name</span>
|
||||
<input value={runnerDraft.name} onChange={(event) => setRunnerDraft((current) => ({ ...current, name: event.target.value }))} placeholder="Tagesexport Lüftung" />
|
||||
</label>
|
||||
<label>
|
||||
<span>Auswahl</span>
|
||||
<select value={runnerDraft.selectionId} onChange={(event) => setRunnerDraft((current) => ({ ...current, selectionId: event.target.value }))}>
|
||||
<option value="">Auswahl wählen</option>
|
||||
{selections.map((selection) => <option key={selection.id} value={selection.id}>{selection.name}</option>)}
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
<span>Uhrzeit</span>
|
||||
<input type="time" value={runnerDraft.time} onChange={(event) => setRunnerDraft((current) => ({ ...current, time: event.target.value }))} />
|
||||
</label>
|
||||
<label>
|
||||
<span>Zeitraum</span>
|
||||
<select value={runnerDraft.rangeHours} onChange={(event) => setRunnerDraft((current) => ({ ...current, rangeHours: event.target.value }))}>
|
||||
<option value="1">letzte 1 h</option>
|
||||
<option value="6">letzte 6 h</option>
|
||||
<option value="12">letzte 12 h</option>
|
||||
<option value="24">letzte 24 h</option>
|
||||
<option value="48">letzte 2 Tage</option>
|
||||
<option value="168">letzte 7 Tage</option>
|
||||
<option value="720">letzte 30 Tage</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
<span>CSV-Trennzeichen</span>
|
||||
<select value={runnerDraft.delimiter} onChange={(event) => setRunnerDraft((current) => ({ ...current, delimiter: event.target.value }))}>
|
||||
<option value=";">Semikolon ;</option>
|
||||
<option value=",">Komma ,</option>
|
||||
<option value="\t">Tabulator</option>
|
||||
</select>
|
||||
</label>
|
||||
<label className="checkbox-field compact-check">
|
||||
<input type="checkbox" checked={runnerDraft.enabled !== false} onChange={(event) => setRunnerDraft((current) => ({ ...current, enabled: event.target.checked }))} />
|
||||
<span>aktiv</span>
|
||||
</label>
|
||||
<label className="checkbox-field compact-check">
|
||||
<input type="checkbox" checked={runnerDraft.mailEnabled === true} onChange={(event) => setRunnerDraft((current) => ({ ...current, mailEnabled: event.target.checked }))} disabled={!integrationSettings.mailEnabled && !integrationSettings.mailApiKey} />
|
||||
<span>CSV per Mail verschicken</span>
|
||||
</label>
|
||||
<label>
|
||||
<span>Mail an</span>
|
||||
<input value={runnerDraft.mailTo} onChange={(event) => setRunnerDraft((current) => ({ ...current, mailTo: event.target.value }))} placeholder="kunde@example.com" />
|
||||
</label>
|
||||
<label>
|
||||
<span>Betreff</span>
|
||||
<input value={runnerDraft.mailSubject} onChange={(event) => setRunnerDraft((current) => ({ ...current, mailSubject: event.target.value }))} />
|
||||
</label>
|
||||
<label className="span-two">
|
||||
<span>Nachricht</span>
|
||||
<input value={runnerDraft.mailMessage} onChange={(event) => setRunnerDraft((current) => ({ ...current, mailMessage: event.target.value }))} />
|
||||
</label>
|
||||
</div>
|
||||
<div className="button-row wrap-tight">
|
||||
{runnerDraft.id ? <button className="ghost-button compact-button" type="button" onClick={resetRunnerDraft}>Abbrechen</button> : null}
|
||||
<button className="primary-button compact-button" type="button" onClick={saveRunnerJob}>{runnerDraft.id ? "Runner-Job speichern" : "Runner-Job anlegen"}</button>
|
||||
</div>
|
||||
|
||||
<div className="selection-list compact source-list">
|
||||
{runnerJobs.map((job) => {
|
||||
const selection = selections.find((item) => item.id === job.selectionId);
|
||||
return (
|
||||
<div key={job.id} className="selection-card source-card preview-card">
|
||||
<div>
|
||||
<strong>{normalizeDisplayText(job.name, "Runner-Job")}</strong>
|
||||
<span>{`${selection?.name || job.selectionId || "keine Auswahl"} · ${job.time || "06:00"} · ${job.enabled === false ? "pausiert" : "aktiv"}`}</span>
|
||||
<small>{job.lastRunAt ? `letzter Lauf ${formatTimestampLabel(job.lastRunAt)}` : "noch nicht gelaufen"}</small>
|
||||
</div>
|
||||
<div className="button-row wrap-tight">
|
||||
<button className="ghost-button compact-button" type="button" onClick={() => editRunnerJob(job)}>Bearbeiten</button>
|
||||
<button className="ghost-button compact-button danger" type="button" onClick={() => deleteRunnerJob(job)}>Löschen</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{!runnerJobs.length ? <div className="empty-state slim-empty"><p>Noch kein automatischer Export angelegt.</p></div> : null}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
{adminSection === "debug" && canViewDebug ? (
|
||||
<div className="panel slim-panel">
|
||||
<div className="panel-header compact-header nested-gap">
|
||||
@@ -3233,6 +3609,21 @@ export default function App() {
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -80,6 +80,12 @@ export const api = {
|
||||
loadDashboardData: (token, body) => apiRequest("/dashboard/data", { method: "POST", token, body }),
|
||||
getPreferences: (token) => apiRequest("/preferences", { token }),
|
||||
savePreferences: (token, body) => apiRequest("/preferences", { method: "PUT", token, body }),
|
||||
getIntegrations: (token) => apiRequest("/integrations", { token }),
|
||||
saveIntegrations: (token, body) => apiRequest("/integrations", { method: "PUT", token, body }),
|
||||
getRunnerJobs: (token) => apiRequest("/runner/jobs", { token }),
|
||||
createRunnerJob: (token, body) => apiRequest("/runner/jobs", { method: "POST", token, body }),
|
||||
updateRunnerJob: (token, id, body) => apiRequest(`/runner/jobs/${encodeURIComponent(id)}`, { method: "PATCH", token, body }),
|
||||
deleteRunnerJob: (token, id) => apiRequest(`/runner/jobs/${encodeURIComponent(id)}`, { method: "DELETE", token }),
|
||||
getCollectorCapabilities: () => collectorRequest("/capabilities"),
|
||||
getCollectorSources: () => collectorRequest("/sources"),
|
||||
getCollectorDatapoints: (sourceId = "") => collectorRequest(sourceId ? `/datapoints?sourceId=${encodeURIComponent(sourceId)}` : "/datapoints"),
|
||||
@@ -95,3 +101,4 @@ export const api = {
|
||||
scanOpcua: (body) => collectorRequest("/scan/opcua", { method: "POST", body }),
|
||||
scanBacnet: (body) => collectorRequest("/scan/bacnet", { method: "POST", body }),
|
||||
};
|
||||
|
||||
|
||||
@@ -1253,3 +1253,155 @@ span {
|
||||
width: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
/* Compact source and datapoint cards for dense Full-HD trend/admin views. */
|
||||
.source-tile {
|
||||
min-height: 0;
|
||||
padding: 0.36rem 0.48rem;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.source-tile-copy {
|
||||
gap: 0.08rem;
|
||||
line-height: 1.18;
|
||||
}
|
||||
|
||||
.source-tile-copy strong,
|
||||
.source-tile-copy span,
|
||||
.source-tile-copy small,
|
||||
.datapoint-main strong,
|
||||
.datapoint-main span,
|
||||
.datapoint-main small {
|
||||
min-width: 0;
|
||||
overflow-wrap: anywhere;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.source-tile-actions {
|
||||
gap: 0.18rem;
|
||||
}
|
||||
|
||||
.source-tile-actions .compact-button {
|
||||
min-height: 1.55rem;
|
||||
padding: 0.24rem 0.48rem;
|
||||
}
|
||||
|
||||
.scan-result-list,
|
||||
.datapoint-table {
|
||||
display: grid;
|
||||
gap: 0.22rem;
|
||||
}
|
||||
|
||||
.datapoint-row {
|
||||
align-items: center;
|
||||
gap: 0.42rem;
|
||||
min-height: 0;
|
||||
padding: 0.34rem 0.46rem;
|
||||
}
|
||||
|
||||
.datapoint-main {
|
||||
min-width: 0;
|
||||
line-height: 1.2;
|
||||
}
|
||||
|
||||
.datapoint-main strong {
|
||||
font-size: 0.74rem;
|
||||
}
|
||||
|
||||
.datapoint-main span,
|
||||
.datapoint-main small,
|
||||
.datapoint-value {
|
||||
font-size: 0.68rem;
|
||||
}
|
||||
|
||||
.datapoint-value {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.source-overview-panel .selection-list {
|
||||
gap: 0.28rem;
|
||||
}
|
||||
|
||||
/* Stable dense datapoint lists: compact, but never overlapping. */
|
||||
.source-split .scan-result-list,
|
||||
.source-split .datapoint-table {
|
||||
gap: 0.3rem;
|
||||
max-height: min(58vh, 620px);
|
||||
min-height: 300px;
|
||||
overflow-y: auto;
|
||||
padding-right: 0.18rem;
|
||||
}
|
||||
|
||||
.source-split .datapoint-row {
|
||||
grid-template-columns: minmax(0, 1fr) 3.2rem minmax(10.5rem, max-content);
|
||||
min-height: 42px;
|
||||
padding: 0.4rem 0.5rem;
|
||||
gap: 0.55rem;
|
||||
align-items: center;
|
||||
border-radius: 9px;
|
||||
}
|
||||
|
||||
.source-split .datapoint-main {
|
||||
min-width: 0;
|
||||
line-height: 1.18;
|
||||
gap: 0.08rem;
|
||||
}
|
||||
|
||||
.source-split .datapoint-main strong,
|
||||
.source-split .datapoint-main span,
|
||||
.source-split .datapoint-main small {
|
||||
display: block;
|
||||
min-width: 0;
|
||||
max-width: 100%;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.source-split .datapoint-main strong {
|
||||
font-size: 0.76rem;
|
||||
line-height: 1.18;
|
||||
}
|
||||
|
||||
.source-split .datapoint-main span,
|
||||
.source-split .datapoint-main small {
|
||||
font-size: 0.68rem;
|
||||
line-height: 1.15;
|
||||
}
|
||||
|
||||
.source-split .datapoint-value {
|
||||
justify-self: end;
|
||||
min-width: 2.2rem;
|
||||
font-size: 0.68rem;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.source-split .datapoint-row > .button-row {
|
||||
display: flex;
|
||||
flex-wrap: nowrap;
|
||||
justify-content: flex-end;
|
||||
gap: 0.25rem;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.source-split .datapoint-row > .button-row .compact-button {
|
||||
min-height: 24px;
|
||||
padding: 0.24rem 0.46rem;
|
||||
font-size: 0.68rem;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.source-split .datapoint-row.inactive {
|
||||
opacity: 0.86;
|
||||
}
|
||||
|
||||
@media (max-width: 1180px) {
|
||||
.source-split .datapoint-row {
|
||||
grid-template-columns: minmax(0, 1fr) 2.6rem;
|
||||
}
|
||||
|
||||
.source-split .datapoint-row > .button-row {
|
||||
grid-column: 1 / -1;
|
||||
justify-content: flex-start;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user