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:
jhartworks
2026-07-09 15:24:58 +02:00
parent f247d2827d
commit 19d27f9de9
14 changed files with 1077 additions and 54 deletions
+409 -18
View File
@@ -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() {
+7
View File
@@ -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 }),
};
+152
View File
@@ -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;
}
}