API added
This commit is contained in:
Binary file not shown.
|
After Width: | Height: | Size: 1.8 MiB |
Binary file not shown.
|
After Width: | Height: | Size: 1.7 MiB |
+172
-62
@@ -1,4 +1,4 @@
|
||||
|
||||
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { api } from "./api.js";
|
||||
import Gauge from "./components/Gauge.jsx";
|
||||
@@ -123,6 +123,7 @@ const PROTOCOL_DEFAULT_PORTS = {
|
||||
"opc-ua": "4840",
|
||||
"bacnet-ip": "47808",
|
||||
"knx-ip": "3671",
|
||||
ipsymcon: "3777",
|
||||
};
|
||||
const defaultSourceDraft = {
|
||||
id: "",
|
||||
@@ -139,6 +140,8 @@ const defaultSourceDraft = {
|
||||
bacnetBroadcastAddress: "auto",
|
||||
bacnetScanSubnets: "",
|
||||
bacnetForeignDeviceTtl: "120",
|
||||
ipsymconCategoryIds: "",
|
||||
ipsymconApiKey: "",
|
||||
};
|
||||
|
||||
|
||||
@@ -190,6 +193,10 @@ function getOpcuaScanDatapointId(sourceId, node) {
|
||||
return sourceId && node?.nodeId ? `${sourceId}::${node.nodeId}` : "";
|
||||
}
|
||||
|
||||
function getIpsymconScanDatapointId(sourceId, point) {
|
||||
return sourceId && point?.address ? `${sourceId}::ipsymcon::${point.address}` : "";
|
||||
}
|
||||
|
||||
function getBacnetScanDatapointId(sourceId, sourceHost, point, index = 0) {
|
||||
if (!sourceId || !point) {
|
||||
return "";
|
||||
@@ -732,10 +739,11 @@ export default function App() {
|
||||
const [collectorPointFilter, setCollectorPointFilter] = useState("");
|
||||
const [alarmPointFilter, setAlarmPointFilter] = useState("");
|
||||
const [manualDatapointDraft, setManualDatapointDraft] = useState(emptyManualDatapointDraft());
|
||||
const [scanResults, setScanResults] = useState({ opcua: null, bacnet: null });
|
||||
const [scanResults, setScanResults] = useState({ opcua: null, bacnet: null, ipsymcon: null });
|
||||
const [debugSnapshot, setDebugSnapshot] = useState(null);
|
||||
const [integrationSettings, setIntegrationSettings] = useState({ mailApiKey: "", whatsappApiKey: "", phoneApiKey: "" });
|
||||
const [runnerJobs, setRunnerJobs] = useState([]);
|
||||
const [runnerQueue, setRunnerQueue] = 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]);
|
||||
@@ -811,18 +819,36 @@ export default function App() {
|
||||
setIsps(nextIsps);
|
||||
return nextIsps;
|
||||
};
|
||||
const scannedLoggedPointIds = useMemo(() => new Set(
|
||||
collectorDatapoints
|
||||
.filter((item) => item.sourceId === selectedSourceId && item.enabled === true)
|
||||
.map((item) => String(item.id))
|
||||
), [collectorDatapoints, selectedSourceId]);
|
||||
const filteredOpcuaNodes = useMemo(
|
||||
() => (scanResults.opcua?.nodes || []).filter((node) => matchesSearch(collectorPointFilter, [
|
||||
() => (scanResults.opcua?.nodes || []).filter((node) => !scannedLoggedPointIds.has(getOpcuaScanDatapointId(selectedSourceId, node)) && matchesSearch(collectorPointFilter, [
|
||||
node.displayName,
|
||||
node.browseName,
|
||||
node.nodeId,
|
||||
node.path,
|
||||
node.currentValue,
|
||||
])),
|
||||
[collectorPointFilter, scanResults.opcua]
|
||||
[collectorPointFilter, scanResults.opcua, scannedLoggedPointIds, selectedSourceId]
|
||||
);
|
||||
const filteredIpsymconPoints = useMemo(
|
||||
() => (scanResults.ipsymcon?.points || []).filter((point) => !scannedLoggedPointIds.has(getIpsymconScanDatapointId(selectedSourceId, point)) && matchesSearch(collectorPointFilter, [
|
||||
point.alias,
|
||||
point.name,
|
||||
point.address,
|
||||
point.nodeId,
|
||||
point.path,
|
||||
point.categoryId,
|
||||
point.currentValue,
|
||||
point.unit,
|
||||
])),
|
||||
[collectorPointFilter, scanResults.ipsymcon, scannedLoggedPointIds, selectedSourceId]
|
||||
);
|
||||
const filteredBacnetPoints = useMemo(
|
||||
() => (scanResults.bacnet?.points || []).filter((point) => matchesSearch(collectorPointFilter, [
|
||||
() => (scanResults.bacnet?.points || []).filter((point, index) => !scannedLoggedPointIds.has(getBacnetScanDatapointId(selectedSourceId, currentSource?.host, point, index)) && matchesSearch(collectorPointFilter, [
|
||||
point.alias,
|
||||
point.name,
|
||||
point.id,
|
||||
@@ -831,7 +857,7 @@ export default function App() {
|
||||
point.objectInstance,
|
||||
point.currentValue,
|
||||
])),
|
||||
[collectorPointFilter, scanResults.bacnet]
|
||||
[collectorPointFilter, currentSource?.host, scanResults.bacnet, scannedLoggedPointIds, selectedSourceId]
|
||||
);
|
||||
const filteredBacnetDevices = useMemo(
|
||||
() => (scanResults.bacnet?.devices || []).filter((device) => matchesSearch(collectorPointFilter, [
|
||||
@@ -842,7 +868,7 @@ export default function App() {
|
||||
device.vendorId,
|
||||
device.pointCount,
|
||||
])),
|
||||
[collectorPointFilter, scanResults.bacnet]
|
||||
[collectorPointFilter, currentSource?.host, scanResults.bacnet, scannedLoggedPointIds, selectedSourceId]
|
||||
);
|
||||
const filteredAvailableCollectorPoints = useMemo(
|
||||
() => availableCollectorPoints.filter((item) => matchesSearch(collectorPointFilter, [
|
||||
@@ -1000,7 +1026,7 @@ export default function App() {
|
||||
}, [collectorSources, isCreatingSource, selectedSourceId]);
|
||||
|
||||
useEffect(() => {
|
||||
setScanResults({ opcua: null, bacnet: null });
|
||||
setScanResults({ opcua: null, bacnet: null, ipsymcon: null });
|
||||
setCollectorPointFilter("");
|
||||
}, [selectedSourceId]);
|
||||
|
||||
@@ -1123,11 +1149,12 @@ export default function App() {
|
||||
useEffect(() => {
|
||||
if (!token || !canManageRunner) {
|
||||
setRunnerJobs([]);
|
||||
setRunnerQueue([]);
|
||||
return;
|
||||
}
|
||||
|
||||
api.getRunnerJobs(token)
|
||||
.then((result) => setRunnerJobs(result.jobs || []))
|
||||
.then((result) => { setRunnerJobs(result.jobs || []); setRunnerQueue(result.queue || []); })
|
||||
.catch((error) => setBootError(error.message));
|
||||
}, [canManageRunner, token]);
|
||||
|
||||
@@ -1187,6 +1214,8 @@ export default function App() {
|
||||
bacnetBroadcastAddress: currentSource.options?.bacnetBroadcastAddress || "auto",
|
||||
bacnetScanSubnets: currentSource.options?.bacnetScanSubnets || "",
|
||||
bacnetForeignDeviceTtl: String(currentSource.options?.bacnetForeignDeviceTtl || 120),
|
||||
ipsymconCategoryIds: currentSource.options?.ipsymconCategoryIds || "",
|
||||
ipsymconApiKey: "",
|
||||
});
|
||||
setManualDatapointDraft((current) => ({
|
||||
...current,
|
||||
@@ -1722,6 +1751,8 @@ export default function App() {
|
||||
bacnetBroadcastAddress: sourceDraft.bacnetBroadcastAddress,
|
||||
bacnetScanSubnets: sourceDraft.bacnetScanSubnets,
|
||||
bacnetForeignDeviceTtl: sourceDraft.bacnetForeignDeviceTtl,
|
||||
ipsymconCategoryIds: sourceDraft.ipsymconCategoryIds,
|
||||
ipsymconApiKey: sourceDraft.ipsymconApiKey,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -1906,6 +1937,25 @@ export default function App() {
|
||||
}
|
||||
};
|
||||
|
||||
const runIpsymconScan = async () => {
|
||||
const activeSourceId = selectedSourceId || sourceDraft.id;
|
||||
if (!activeSourceId) {
|
||||
setBootError("Bitte zuerst eine IP-Symcon-Quelle auswählen oder anlegen.");
|
||||
return;
|
||||
}
|
||||
|
||||
resetFlash();
|
||||
try {
|
||||
const result = await api.scanIpsymcon({ sourceId: activeSourceId });
|
||||
setScanResults((current) => ({ ...current, ipsymcon: result }));
|
||||
await refreshCollectorData();
|
||||
await refreshCollectorDebug().catch(() => undefined);
|
||||
setSuccessMessage(`IP-Symcon-Scan abgeschlossen: ${result.points?.length || 0} Datenpunkt(e) aus ${result.categories?.length || 0} Kategorie(n).`);
|
||||
} catch (error) {
|
||||
setBootError(error.message);
|
||||
}
|
||||
};
|
||||
|
||||
const selectBacnetDevice = (device) => {
|
||||
const host = device.host || device.address || "";
|
||||
const name = normalizeDisplayText(device.name, host || "BACnet Quelle");
|
||||
@@ -2085,6 +2135,7 @@ export default function App() {
|
||||
}
|
||||
const refreshed = await api.getRunnerJobs(token);
|
||||
setRunnerJobs(refreshed.jobs || []);
|
||||
setRunnerQueue(refreshed.queue || []);
|
||||
resetRunnerDraft();
|
||||
setSuccessMessage("Runner-Job gespeichert.");
|
||||
} catch (error) {
|
||||
@@ -2092,6 +2143,26 @@ export default function App() {
|
||||
}
|
||||
};
|
||||
|
||||
const testRunnerJob = async (job) => {
|
||||
if (!window.confirm(`Test für "${job.name || job.id}" jetzt ausführen? Der CSV-Export wird sofort erstellt und bei aktivierter Mailzustellung verschickt.`)) {
|
||||
return;
|
||||
}
|
||||
resetFlash();
|
||||
try {
|
||||
await api.testRunnerJob(token, job.id);
|
||||
setSuccessMessage("Testauftrag eingereiht. Das Ergebnis wird in wenigen Sekunden am Job angezeigt.");
|
||||
window.setTimeout(async () => {
|
||||
try {
|
||||
const refreshed = await api.getRunnerJobs(token);
|
||||
setRunnerJobs(refreshed.jobs || []);
|
||||
setRunnerQueue(refreshed.queue || []);
|
||||
} catch {}
|
||||
}, 1800);
|
||||
} catch (error) {
|
||||
setBootError(error.message);
|
||||
}
|
||||
};
|
||||
|
||||
const deleteRunnerJob = async (job) => {
|
||||
resetFlash();
|
||||
if (!window.confirm(`Runner-Job "${job.name || job.id}" wirklich löschen?`)) {
|
||||
@@ -2101,6 +2172,7 @@ export default function App() {
|
||||
await api.deleteRunnerJob(token, job.id);
|
||||
const refreshed = await api.getRunnerJobs(token);
|
||||
setRunnerJobs(refreshed.jobs || []);
|
||||
setRunnerQueue(refreshed.queue || []);
|
||||
if (runnerDraft.id === job.id) {
|
||||
resetRunnerDraft();
|
||||
}
|
||||
@@ -2309,7 +2381,7 @@ export default function App() {
|
||||
<div className={selectionListClassName}>
|
||||
{selections.map((selection) => (
|
||||
<div key={selection.id} className="stack-card">
|
||||
<button type="button" className="selection-card" onClick={() => loadSelection(selection)}>
|
||||
<button type="button" className="selection-card saved-selection-card" onClick={() => loadSelection(selection)}>
|
||||
<div>
|
||||
<strong>{selection.name}</strong>
|
||||
<span>{selection.points.length} Punkte</span>
|
||||
@@ -2785,6 +2857,7 @@ export default function App() {
|
||||
<button className="ghost-button compact-button" type="button" onClick={(event) => { event.stopPropagation(); openSourceEditor(source.id); }}>Bearbeiten</button>
|
||||
{source.protocol === "opc-ua" ? <button className="ghost-button compact-button" type="button" onClick={(event) => { event.stopPropagation(); setSelectedSourceId(source.id); setIsCreatingSource(false); runOpcuaScan(); }}>OPC scannen</button> : null}
|
||||
{source.protocol === "bacnet-ip" ? <button className="ghost-button compact-button" type="button" onClick={(event) => { event.stopPropagation(); setSelectedSourceId(source.id); setIsCreatingSource(false); setScanResults((current) => ({ ...current, bacnet: null })); runBacnetScan({ source, includeObjects: true }); }}>BACnet scannen</button> : null}
|
||||
{source.protocol === "ipsymcon" ? <button className="ghost-button compact-button" type="button" onClick={(event) => { event.stopPropagation(); setSelectedSourceId(source.id); setIsCreatingSource(false); runIpsymconScan(); }}>IP-Symcon scannen</button> : null}
|
||||
<button className="ghost-button compact-button danger" type="button" onClick={(event) => { event.stopPropagation(); setSelectedSourceId(source.id); setIsCreatingSource(false); deleteCollectorSource(); }}>Löschen</button>
|
||||
</div>
|
||||
</button>
|
||||
@@ -2825,6 +2898,7 @@ export default function App() {
|
||||
{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}
|
||||
{sourceToolProtocol === "ipsymcon" ? <><div className="button-row wrap-tight"><button className="ghost-button compact-button" type="button" onClick={runIpsymconScan}>Kategorien scannen</button></div><small className="muted">Liest die in der Quelle hinterlegten Kategorie-IDs rekursiv aus und führt doppelte Variablen zusammen.</small></> : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -2918,6 +2992,7 @@ export default function App() {
|
||||
<option value="opc-ua">OPC UA</option>
|
||||
<option value="bacnet-ip">BACnet IP</option>
|
||||
<option value="knx-ip">KNX IP</option>
|
||||
<option value="ipsymcon">IP-Symcon WebHook API</option>
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
@@ -2926,7 +3001,7 @@ export default function App() {
|
||||
</label>
|
||||
{sourceDraft.protocol !== "bacnet-ip" || sourceDraft.bacnetMode !== "broadcast" ? (
|
||||
<label>
|
||||
<span>{sourceDraft.protocol === "bacnet-ip" ? "BBMD Host / IP" : "Host / IP"}</span>
|
||||
<span>{sourceDraft.protocol === "bacnet-ip" ? "BBMD Host / IP" : sourceDraft.protocol === "ipsymcon" ? "IP-Symcon Host / URL" : "Host / IP"}</span>
|
||||
<input value={sourceDraft.host} onChange={(event) => setSourceDraft((current) => ({ ...current, host: event.target.value }))} />
|
||||
</label>
|
||||
) : null}
|
||||
@@ -2944,10 +3019,12 @@ export default function App() {
|
||||
</select>
|
||||
</label>
|
||||
) : null}
|
||||
<label>
|
||||
<span>{"Geräte-ID"}</span>
|
||||
<input value={sourceDraft.deviceId} onChange={(event) => setSourceDraft((current) => ({ ...current, deviceId: event.target.value }))} />
|
||||
</label>
|
||||
{sourceDraft.protocol !== "ipsymcon" ? (
|
||||
<label>
|
||||
<span>{"Geräte-ID"}</span>
|
||||
<input value={sourceDraft.deviceId} onChange={(event) => setSourceDraft((current) => ({ ...current, deviceId: event.target.value }))} />
|
||||
</label>
|
||||
) : null}
|
||||
{sourceDraft.protocol === "bacnet-ip" && sourceDraft.bacnetMode === "broadcast" ? (
|
||||
<label>
|
||||
<span>Broadcast-Adresse</span>
|
||||
@@ -2967,6 +3044,19 @@ export default function App() {
|
||||
<input type="number" min="30" value={sourceDraft.bacnetForeignDeviceTtl} onChange={(event) => setSourceDraft((current) => ({ ...current, bacnetForeignDeviceTtl: event.target.value }))} />
|
||||
</label>
|
||||
) : null}
|
||||
{sourceDraft.protocol === "ipsymcon" ? (
|
||||
<>
|
||||
<label className="span-two">
|
||||
<span>Kategorie-IDs</span>
|
||||
<input value={sourceDraft.ipsymconCategoryIds || ""} onChange={(event) => setSourceDraft((current) => ({ ...current, ipsymconCategoryIds: event.target.value }))} placeholder="z.B. 25724; 25725; 25726" />
|
||||
<small className="muted">Mehrere IDs mit Komma, Semikolon oder Leerzeichen trennen. Die Kategorien werden beim Scan zusammengeführt.</small>
|
||||
</label>
|
||||
<label className="span-two">
|
||||
<span>API-Schlüssel</span>
|
||||
<input type="password" autoComplete="new-password" value={sourceDraft.ipsymconApiKey || ""} onChange={(event) => setSourceDraft((current) => ({ ...current, ipsymconApiKey: event.target.value }))} placeholder={currentSource?.hasIpsymconApiKey ? "Schlüssel gespeichert - nur zum Ersetzen eingeben" : "API-Schlüssel eingeben"} />
|
||||
</label>
|
||||
</>
|
||||
) : null}
|
||||
<label className="span-two">
|
||||
<span>Technischer Tabellenname</span>
|
||||
<input value={sourceDraft.id ? (currentSource?.tableName || sourceDraft.tableName || buildCollectorTableName(sourceDraft.name, sourceDraft.protocol)) : buildCollectorTableName(sourceDraft.name, sourceDraft.protocol)} readOnly />
|
||||
@@ -3035,7 +3125,7 @@ export default function App() {
|
||||
<div className="panel-header compact-header nested-gap">
|
||||
<div>
|
||||
<h3>Gefunden / importiert</h3>
|
||||
<p>Links landen Scan-Ergebnisse und importierte Punkte. Von hier schiebst du Werte ins Logging.</p>
|
||||
<p>Links landen Scan-Ergebnisse und importierte Punkte. Bereits geloggte Werte sind ausgeblendet.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -3066,6 +3156,25 @@ export default function App() {
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{filteredIpsymconPoints.slice(0, 2000).map((point, index) => {
|
||||
const pointId = getIpsymconScanDatapointId(selectedSourceId, point);
|
||||
const linkedPoint = collectorSourcePointById.get(pointId);
|
||||
const isLogged = linkedPoint?.enabled === true || loggedCollectorPointIds.has(pointId);
|
||||
return (
|
||||
<div key={pointId || `${point.address}-${index}`} className="datapoint-row inactive">
|
||||
<div className="datapoint-main">
|
||||
<strong>{normalizeDisplayText(point.alias || point.name || `Variable ${point.address}`)}</strong>
|
||||
<span>{`${normalizeDisplayText(point.path || "IP-Symcon")} · ID ${point.address}${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>
|
||||
<div className="button-row wrap-tight">
|
||||
{linkedPoint ? <button className="ghost-button compact-button" type="button" onClick={() => readCollectorPoint(linkedPoint)}>Lesen</button> : null}
|
||||
<button className="primary-button compact-button" type="button" disabled={!linkedPoint || isLogged} onClick={() => linkedPoint && updateCollectorPoint(linkedPoint, { enabled: true })}>{isLogged ? "Im Logging" : "Ins Logging"}</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{filteredBacnetPoints.slice(0, 160).map((point, index) => {
|
||||
const pointId = getBacnetScanDatapointId(selectedSourceId, currentSource?.host, point, index);
|
||||
const linkedPoint = collectorSourcePointById.get(pointId);
|
||||
@@ -3085,7 +3194,7 @@ export default function App() {
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
{(!scanResults.opcua && !scanResults.bacnet) && filteredAvailableCollectorPoints.length ? filteredAvailableCollectorPoints.slice(0, 2000).map((item) => (
|
||||
{(!filteredOpcuaNodes.length && !filteredIpsymconPoints.length && !filteredBacnetPoints.length) && filteredAvailableCollectorPoints.length ? filteredAvailableCollectorPoints.slice(0, 2000).map((item) => (
|
||||
<div key={item.id} className="datapoint-row inactive">
|
||||
<div className="datapoint-main">
|
||||
<strong>{normalizeDisplayText(item.alias || item.name)}</strong>
|
||||
@@ -3100,7 +3209,7 @@ export default function App() {
|
||||
</div>
|
||||
</div>
|
||||
)) : null}
|
||||
{!filteredAvailableCollectorPoints.length && !filteredOpcuaNodes.length && !filteredBacnetPoints.length ? <div className="empty-state slim-empty"><p>{collectorPointFilter.trim() ? "Keine Treffer für die aktuelle Suche." : "Nach Scan oder Import erscheinen die gefundenen Punkte genau hier links."}</p></div> : null}
|
||||
{!filteredAvailableCollectorPoints.length && !filteredOpcuaNodes.length && !filteredIpsymconPoints.length && !filteredBacnetPoints.length ? <div className="empty-state slim-empty"><p>{collectorPointFilter.trim() ? "Keine Treffer für die aktuelle Suche." : "Nach Scan oder Import erscheinen die gefundenen Punkte genau hier links."}</p></div> : null}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -3376,14 +3485,17 @@ export default function App() {
|
||||
<label>
|
||||
<span>Mail API-Key</span>
|
||||
<input type="password" value={integrationSettings.mailApiKey || ""} onChange={(event) => setIntegrationSettings((current) => ({ ...current, mailApiKey: event.target.value }))} />
|
||||
<small className="muted">{integrationSettings.mailApiKey ? "Key gespeichert" : "Kein Key hinterlegt"}</small>
|
||||
</label>
|
||||
<label>
|
||||
<span>WhatsApp API-Key</span>
|
||||
<input type="password" value={integrationSettings.whatsappApiKey || ""} onChange={(event) => setIntegrationSettings((current) => ({ ...current, whatsappApiKey: event.target.value }))} />
|
||||
<small className="muted">{integrationSettings.whatsappApiKey ? "Key gespeichert" : "Kein Key hinterlegt"}</small>
|
||||
</label>
|
||||
<label>
|
||||
<span>Telefon API-Key</span>
|
||||
<input type="password" value={integrationSettings.phoneApiKey || ""} onChange={(event) => setIntegrationSettings((current) => ({ ...current, phoneApiKey: event.target.value }))} />
|
||||
<small className="muted">{integrationSettings.phoneApiKey ? "Key gespeichert" : "Kein Key hinterlegt"}</small>
|
||||
</label>
|
||||
</div>
|
||||
<button className="primary-button compact-button" type="button" onClick={saveIntegrationSettings}>API-Keys speichern</button>
|
||||
@@ -3413,8 +3525,9 @@ export default function App() {
|
||||
</select>
|
||||
</label>
|
||||
<label>
|
||||
<span>Uhrzeit</span>
|
||||
<input type="time" value={runnerDraft.time} onChange={(event) => setRunnerDraft((current) => ({ ...current, time: event.target.value }))} />
|
||||
<span>Uhrzeit (deutsch)</span>
|
||||
<input value={runnerDraft.time} inputMode="numeric" placeholder="z.B. 06:30" onChange={(event) => setRunnerDraft((current) => ({ ...current, time: event.target.value }))} />
|
||||
<small className="muted">Format HH:MM Uhr, Zeitzone Europe/Berlin.</small>
|
||||
</label>
|
||||
<label>
|
||||
<span>Zeitraum</span>
|
||||
@@ -3446,7 +3559,8 @@ export default function App() {
|
||||
</label>
|
||||
<label>
|
||||
<span>Mail an</span>
|
||||
<input value={runnerDraft.mailTo} onChange={(event) => setRunnerDraft((current) => ({ ...current, mailTo: event.target.value }))} placeholder="kunde@example.com" />
|
||||
<input value={runnerDraft.mailTo} onChange={(event) => setRunnerDraft((current) => ({ ...current, mailTo: event.target.value }))} placeholder="kunde@example.com; service@example.com" />
|
||||
<small className="muted">Mehrere Empfänger mit Semikolon oder Komma trennen.</small>
|
||||
</label>
|
||||
<label>
|
||||
<span>Betreff</span>
|
||||
@@ -3462,24 +3576,44 @@ export default function App() {
|
||||
<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 className="runner-status-grid">
|
||||
<section className="runner-status-panel">
|
||||
<div className="runner-status-header"><h4>Vorhandene Runner</h4><span>{runnerJobs.length}</span></div>
|
||||
<div className="runner-job-list">
|
||||
{runnerJobs.map((job) => {
|
||||
const selection = selections.find((item) => item.id === job.selectionId);
|
||||
return (
|
||||
<div key={job.id} className="runner-job-row">
|
||||
<div className="runner-job-copy">
|
||||
<strong>{normalizeDisplayText(job.name, "Runner-Job")}</strong>
|
||||
<span>{`${selection?.name || job.selectionId || "keine Auswahl"} · ${job.time || "06:00"} Uhr · ${job.enabled === false ? "pausiert" : "aktiv"}`}</span>
|
||||
<small>{job.lastResult ? (job.lastResult.ok ? `Letzter Lauf: ${formatTimestampLabel(job.lastResult.ranAt)}` : `Fehler: ${normalizeDisplayText(job.lastResult.error, "unbekannt")}`) : "Noch nicht gelaufen"}</small>
|
||||
</div>
|
||||
<div className="button-row wrap-tight runner-job-actions">
|
||||
<button className="ghost-button compact-button" type="button" onClick={() => testRunnerJob(job)}>Test</button>
|
||||
<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>
|
||||
</section>
|
||||
<section className="runner-status-panel">
|
||||
<div className="runner-status-header"><h4>Warteschlange</h4><span>{runnerQueue.length}</span></div>
|
||||
<div className="runner-queue-table">
|
||||
<div className="runner-queue-head"><span>#</span><span>Job</span><span>Eingereiht</span></div>
|
||||
{runnerQueue.map((entry) => (
|
||||
<div key={`${entry.position}-${entry.jobId}-${entry.requestedAt}`} className="runner-queue-row">
|
||||
<span>{entry.position}</span>
|
||||
<strong>{normalizeDisplayText(entry.jobName, "Unbekannter Job")}</strong>
|
||||
<small>{entry.requestedAt ? formatTimestampLabel(entry.requestedAt) : "-"}</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}
|
||||
))}
|
||||
{!runnerQueue.length ? <div className="empty-state slim-empty"><p>Keine Aufträge warten auf die Ausführung.</p></div> : null}
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
@@ -3604,27 +3738,3 @@ export default function App() {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
+3
-2
@@ -1,4 +1,4 @@
|
||||
function resolveServiceUrl(explicitEnv, fallbackPort, suffix = "") {
|
||||
function resolveServiceUrl(explicitEnv, fallbackPort, suffix = "") {
|
||||
const explicit = String(explicitEnv || "").trim();
|
||||
if (explicit) {
|
||||
return explicit.replace(/\/$/, "") + suffix;
|
||||
@@ -85,6 +85,7 @@ export const api = {
|
||||
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 }),
|
||||
testRunnerJob: (token, id) => apiRequest(`/runner/jobs/${encodeURIComponent(id)}/test`, { method: "POST", token }),
|
||||
deleteRunnerJob: (token, id) => apiRequest(`/runner/jobs/${encodeURIComponent(id)}`, { method: "DELETE", token }),
|
||||
getCollectorCapabilities: () => collectorRequest("/capabilities"),
|
||||
getCollectorSources: () => collectorRequest("/sources"),
|
||||
@@ -100,5 +101,5 @@ export const api = {
|
||||
importKnxXml: (body) => collectorRequest("/imports/knx-xml", { method: "POST", body }),
|
||||
scanOpcua: (body) => collectorRequest("/scan/opcua", { method: "POST", body }),
|
||||
scanBacnet: (body) => collectorRequest("/scan/bacnet", { method: "POST", body }),
|
||||
scanIpsymcon: (body) => collectorRequest("/scan/ipsymcon", { method: "POST", body }),
|
||||
};
|
||||
|
||||
|
||||
+177
-5
@@ -1,8 +1,9 @@
|
||||
:root {
|
||||
color-scheme: light;
|
||||
--bg: #f4f6fb;
|
||||
--bg-accent: linear-gradient(180deg, #f8fbff 0%, #eef3fb 100%);
|
||||
--surface: rgba(255, 255, 255, 0.96);
|
||||
--bg-accent: linear-gradient(180deg, rgba(248, 251, 255, 0.24) 0%, rgba(238, 243, 251, 0.32) 100%);
|
||||
--page-image: url("/bg-whitemode.png");
|
||||
--surface: rgba(255, 255, 255, 0.91);
|
||||
--surface-strong: #ffffff;
|
||||
--text: #18212f;
|
||||
--muted: #667085;
|
||||
@@ -23,8 +24,9 @@
|
||||
:root[data-theme="dark"] {
|
||||
color-scheme: dark;
|
||||
--bg: #0e1624;
|
||||
--bg-accent: linear-gradient(180deg, #101827 0%, #0b1320 100%);
|
||||
--surface: rgba(16, 24, 38, 0.96);
|
||||
--bg-accent: linear-gradient(180deg, rgba(16, 24, 39, 0.2) 0%, rgba(11, 19, 32, 0.3) 100%);
|
||||
--page-image: url("/bg-darkmode.png");
|
||||
--surface: rgba(16, 24, 38, 0.92);
|
||||
--surface-strong: #121b2a;
|
||||
--text: #edf2f7;
|
||||
--muted: #94a3b8;
|
||||
@@ -52,7 +54,11 @@ body,
|
||||
|
||||
body {
|
||||
margin: 0;
|
||||
background: var(--bg-accent);
|
||||
background-color: var(--bg);
|
||||
background-image: var(--bg-accent), var(--page-image);
|
||||
background-position: center, center;
|
||||
background-size: cover, cover;
|
||||
background-attachment: fixed, fixed;
|
||||
color: var(--text);
|
||||
font-size: 12px;
|
||||
}
|
||||
@@ -1405,3 +1411,169 @@ span {
|
||||
justify-content: flex-start;
|
||||
}
|
||||
}
|
||||
|
||||
.runner-status-grid {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
|
||||
gap: 0.65rem;
|
||||
margin-top: 0.75rem;
|
||||
}
|
||||
|
||||
.runner-status-panel {
|
||||
min-width: 0;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 10px;
|
||||
background: color-mix(in srgb, var(--panel) 92%, var(--accent) 8%);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.runner-status-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.5rem;
|
||||
padding: 0.46rem 0.58rem;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
|
||||
.runner-status-header h4 {
|
||||
margin: 0;
|
||||
font-size: 0.78rem;
|
||||
}
|
||||
|
||||
.runner-status-header > span {
|
||||
display: inline-grid;
|
||||
place-items: center;
|
||||
min-width: 1.35rem;
|
||||
height: 1.2rem;
|
||||
border-radius: 999px;
|
||||
background: var(--accent-soft);
|
||||
color: var(--accent);
|
||||
font-size: 0.68rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
.runner-job-list,
|
||||
.runner-queue-table {
|
||||
display: grid;
|
||||
gap: 0;
|
||||
max-height: 230px;
|
||||
overflow: auto;
|
||||
}
|
||||
|
||||
.runner-job-row,
|
||||
.runner-queue-row,
|
||||
.runner-queue-head {
|
||||
display: grid;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.42rem 0.58rem;
|
||||
}
|
||||
|
||||
.runner-job-row {
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
border-bottom: 1px solid color-mix(in srgb, var(--border) 75%, transparent);
|
||||
}
|
||||
|
||||
.runner-job-row:last-child,
|
||||
.runner-queue-row:last-child {
|
||||
border-bottom: 0;
|
||||
}
|
||||
|
||||
.runner-job-copy {
|
||||
display: grid;
|
||||
gap: 0.08rem;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.runner-job-copy strong,
|
||||
.runner-job-copy span,
|
||||
.runner-job-copy small,
|
||||
.runner-queue-row strong,
|
||||
.runner-queue-row small {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.runner-job-copy strong,
|
||||
.runner-queue-row strong {
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.runner-job-copy span,
|
||||
.runner-job-copy small,
|
||||
.runner-queue-row small {
|
||||
color: var(--muted);
|
||||
font-size: 0.67rem;
|
||||
}
|
||||
|
||||
.runner-job-actions {
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.runner-queue-head,
|
||||
.runner-queue-row {
|
||||
grid-template-columns: 1.4rem minmax(0, 1fr) auto;
|
||||
}
|
||||
|
||||
.runner-queue-head {
|
||||
color: var(--muted);
|
||||
font-size: 0.65rem;
|
||||
font-weight: 700;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
|
||||
.runner-queue-row {
|
||||
border-bottom: 1px solid color-mix(in srgb, var(--border) 75%, transparent);
|
||||
}
|
||||
|
||||
.runner-queue-row > span {
|
||||
color: var(--accent);
|
||||
font-size: 0.7rem;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.runner-status-grid {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.runner-job-row {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.runner-job-actions {
|
||||
justify-content: flex-start;
|
||||
}
|
||||
}
|
||||
.saved-selection-card {
|
||||
min-height: 36px;
|
||||
justify-content: flex-start;
|
||||
padding: 0.38rem 0.55rem;
|
||||
}
|
||||
|
||||
.saved-selection-copy {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.42rem;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.saved-selection-copy strong {
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.saved-selection-copy .selection-count {
|
||||
flex: 0 0 auto;
|
||||
padding: 0.08rem 0.32rem;
|
||||
border-radius: 999px;
|
||||
background: color-mix(in srgb, var(--accent) 12%, transparent);
|
||||
color: var(--accent);
|
||||
font-size: 0.65rem;
|
||||
font-weight: 700;
|
||||
line-height: 1.2;
|
||||
}
|
||||
Reference in New Issue
Block a user