API added

This commit is contained in:
jhartworks
2026-07-24 13:43:47 +02:00
parent 19d27f9de9
commit 986a43d5a5
12 changed files with 1112 additions and 146 deletions
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 MiB

+172 -62
View File
@@ -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
View File
@@ -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
View File
@@ -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;
}