feat(bacnet-collector): add initial implementation of BACnet collector with scanning and reading capabilities
- Created package.json for the bacnet-collector project with necessary dependencies. - Implemented main server logic in src/index.js to handle BACnet device scanning and reading. - Added raw discovery functionality in src/raw-discover.js to facilitate device detection over the network. - Introduced utility functions for handling IP addresses, broadcasting, and BACnet packet construction. - Implemented JSON response handling for health checks and capabilities endpoints.
This commit is contained in:
-77153
File diff suppressed because one or more lines are too long
+180
-30
@@ -1,4 +1,4 @@
|
||||
|
||||
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { api } from "./api.js";
|
||||
import Gauge from "./components/Gauge.jsx";
|
||||
@@ -129,6 +129,10 @@ const defaultSourceDraft = {
|
||||
displayName: "",
|
||||
writeMode: "interval",
|
||||
pollIntervalSeconds: "60",
|
||||
bacnetMode: "broadcast",
|
||||
bacnetBroadcastAddress: "auto",
|
||||
bacnetScanSubnets: "",
|
||||
bacnetForeignDeviceTtl: "120",
|
||||
};
|
||||
|
||||
|
||||
@@ -814,6 +818,17 @@ export default function App() {
|
||||
])),
|
||||
[collectorPointFilter, scanResults.bacnet]
|
||||
);
|
||||
const filteredBacnetDevices = useMemo(
|
||||
() => (scanResults.bacnet?.devices || []).filter((device) => matchesSearch(collectorPointFilter, [
|
||||
device.name,
|
||||
device.deviceId,
|
||||
device.host,
|
||||
device.address,
|
||||
device.vendorId,
|
||||
device.pointCount,
|
||||
])),
|
||||
[collectorPointFilter, scanResults.bacnet]
|
||||
);
|
||||
const filteredAvailableCollectorPoints = useMemo(
|
||||
() => availableCollectorPoints.filter((item) => matchesSearch(collectorPointFilter, [
|
||||
item.alias,
|
||||
@@ -1120,6 +1135,10 @@ export default function App() {
|
||||
displayName: currentSource.displayName || currentSource.name || "",
|
||||
writeMode: currentSource.writeMode === "cov" ? "cov" : "interval",
|
||||
pollIntervalSeconds: String(currentSource.pollIntervalSeconds || 60),
|
||||
bacnetMode: currentSource.options?.bacnetMode || "broadcast",
|
||||
bacnetBroadcastAddress: currentSource.options?.bacnetBroadcastAddress || "auto",
|
||||
bacnetScanSubnets: currentSource.options?.bacnetScanSubnets || "",
|
||||
bacnetForeignDeviceTtl: String(currentSource.options?.bacnetForeignDeviceTtl || 120),
|
||||
});
|
||||
setManualDatapointDraft((current) => ({
|
||||
...current,
|
||||
@@ -1599,6 +1618,7 @@ export default function App() {
|
||||
...current,
|
||||
protocol: nextProtocol,
|
||||
port: shouldReplacePort ? nextDefaultPort : current.port,
|
||||
host: nextProtocol === "bacnet-ip" && current.bacnetMode === "broadcast" ? "" : current.host,
|
||||
tableName: current.id ? current.tableName : buildCollectorTableName(current.name, nextProtocol),
|
||||
};
|
||||
});
|
||||
@@ -1608,6 +1628,33 @@ export default function App() {
|
||||
}));
|
||||
};
|
||||
|
||||
const saveBacnetSubnetSetting = async () => {
|
||||
if (!currentSource || currentSource.protocol !== "bacnet-ip") {
|
||||
return;
|
||||
}
|
||||
const nextSubnets = String(sourceDraft.bacnetScanSubnets || "").trim();
|
||||
const previousSubnets = String(currentSource.options?.bacnetScanSubnets || "").trim();
|
||||
if (nextSubnets === previousSubnets) {
|
||||
return;
|
||||
}
|
||||
|
||||
resetFlash();
|
||||
try {
|
||||
await api.updateCollectorSource(currentSource.id, {
|
||||
...currentSource,
|
||||
bacnetScanSubnets: nextSubnets,
|
||||
options: {
|
||||
...(currentSource.options || {}),
|
||||
bacnetScanSubnets: nextSubnets,
|
||||
},
|
||||
});
|
||||
await refreshCollectorData();
|
||||
await refreshCollectorDebug().catch(() => undefined);
|
||||
setSuccessMessage("BACnet-Subnetze gespeichert.");
|
||||
} catch (error) {
|
||||
setBootError(error.message);
|
||||
}
|
||||
};
|
||||
const saveCollectorSource = async () => {
|
||||
if (!sourceDraft.name.trim()) {
|
||||
setBootError("Bitte einen Alias bzw. Anzeigenamen angeben.");
|
||||
@@ -1803,29 +1850,58 @@ export default function App() {
|
||||
}
|
||||
};
|
||||
|
||||
const runBacnetScan = async () => {
|
||||
const activeSourceId = selectedSourceId || sourceDraft.id;
|
||||
if (!activeSourceId) {
|
||||
setBootError("Bitte zuerst eine Quelle auswählen oder anlegen.");
|
||||
return;
|
||||
}
|
||||
const selectBacnetDevice = (device) => {
|
||||
const host = device.host || device.address || "";
|
||||
const name = normalizeDisplayText(device.name, host || "BACnet Quelle");
|
||||
setSourceDraft((current) => ({
|
||||
...current,
|
||||
host,
|
||||
deviceId: String(device.deviceId || ""),
|
||||
port: String(device.port || current.port || "47808"),
|
||||
name: current.name?.trim() ? current.name : name,
|
||||
displayName: current.displayName?.trim() ? current.displayName : name,
|
||||
tableName: current.id ? current.tableName : buildCollectorTableName(current.name?.trim() ? current.name : name, "bacnet-ip"),
|
||||
}));
|
||||
};
|
||||
|
||||
const runBacnetScan = async (options = {}) => {
|
||||
const sourceOverride = options.source || null;
|
||||
const draft = sourceOverride ? {
|
||||
...defaultSourceDraft,
|
||||
...sourceOverride,
|
||||
name: sourceOverride.name || sourceOverride.displayName || "",
|
||||
bacnetMode: sourceOverride.options?.bacnetMode || "broadcast",
|
||||
bacnetBroadcastAddress: sourceOverride.options?.bacnetBroadcastAddress || "auto",
|
||||
bacnetScanSubnets: sourceOverride.options?.bacnetScanSubnets || sourceOverride.bacnetScanSubnets || "",
|
||||
bacnetForeignDeviceTtl: String(sourceOverride.options?.bacnetForeignDeviceTtl || 120),
|
||||
writeMode: sourceOverride.writeMode || sourceOverride.options?.writeMode || "interval",
|
||||
pollIntervalSeconds: String(sourceOverride.pollIntervalSeconds || sourceOverride.options?.pollIntervalSeconds || 60),
|
||||
} : sourceDraft;
|
||||
const activeSourceId = options.sourceId || sourceOverride?.id || selectedSourceId || draft.id || "";
|
||||
const scanHosts = draft.host ? String(draft.host).split(/[;,\s]+/).map((entry) => entry.trim()).filter(Boolean) : [];
|
||||
|
||||
resetFlash();
|
||||
try {
|
||||
const hosts = sourceDraft.host
|
||||
.split(/[;,\s]+/)
|
||||
.map((entry) => entry.trim())
|
||||
.filter(Boolean);
|
||||
const result = await api.scanBacnet({
|
||||
hosts,
|
||||
port: sourceDraft.port || "47808",
|
||||
deviceId: sourceDraft.deviceId,
|
||||
sourceId: activeSourceId,
|
||||
port: draft.port || "47808",
|
||||
deviceId: draft.deviceId,
|
||||
sourceId: activeSourceId || undefined,
|
||||
host: draft.host,
|
||||
scanHosts,
|
||||
scanSubnets: draft.bacnetScanSubnets || "",
|
||||
bacnetScanSubnets: draft.bacnetScanSubnets || "",
|
||||
includeObjects: options.includeObjects === true,
|
||||
bacnetMode: draft.bacnetMode || "broadcast",
|
||||
bacnetBroadcastAddress: draft.bacnetBroadcastAddress || "auto",
|
||||
clientHost: typeof window !== "undefined" ? window.location.hostname : "",
|
||||
bacnetForeignDeviceTtl: draft.bacnetForeignDeviceTtl || "120",
|
||||
});
|
||||
setScanResults((current) => ({ ...current, bacnet: result }));
|
||||
await refreshCollectorData();
|
||||
await refreshCollectorDebug().catch(() => undefined);
|
||||
setSuccessMessage("BACnet-Scan abgeschlossen.");
|
||||
const deviceCount = result.devices?.length || 0;
|
||||
const pointCount = result.points?.length || 0;
|
||||
setSuccessMessage(options.includeObjects === true ? `BACnet-Objektscan abgeschlossen: ${deviceCount} Gerät(e), ${pointCount} Objekt(e).` : `BACnet-Scan abgeschlossen: ${deviceCount} Gerät(e) gefunden.`);
|
||||
} catch (error) {
|
||||
setBootError(error.message);
|
||||
}
|
||||
@@ -2315,6 +2391,7 @@ export default function App() {
|
||||
</select>
|
||||
</label>
|
||||
|
||||
|
||||
<div className="form-grid compact-forms">
|
||||
<label>
|
||||
<span>Name</span>
|
||||
@@ -2565,7 +2642,7 @@ export default function App() {
|
||||
<div className="source-tile-actions">
|
||||
<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); runBacnetScan(); }}>BACnet 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}
|
||||
<button className="ghost-button compact-button danger" type="button" onClick={(event) => { event.stopPropagation(); setSelectedSourceId(source.id); setIsCreatingSource(false); deleteCollectorSource(); }}>Löschen</button>
|
||||
</div>
|
||||
</button>
|
||||
@@ -2602,13 +2679,17 @@ export default function App() {
|
||||
</div>
|
||||
|
||||
<div className="protocol-block">
|
||||
<strong>{sourceToolProtocol === "opc-ua" ? "OPC UA" : sourceToolProtocol === "bacnet-ip" ? "BACnet IP" : sourceToolProtocol === "modbus-tcp" ? "Modbus TCP" : sourceToolProtocol === "knx-ip" ? "KNX IP" : "Quelle"}</strong>
|
||||
<div className="button-row wrap-tight">
|
||||
{sourceToolProtocol === "opc-ua" ? <button className="ghost-button compact-button" type="button" onClick={runOpcuaScan}>Scan mit Host/Port der aktiven Quelle</button> : null}
|
||||
{sourceToolProtocol === "bacnet-ip" ? <button className="ghost-button compact-button" type="button" onClick={runBacnetScan}>Scan mit Host/Port der aktiven Quelle</button> : null}
|
||||
</div>
|
||||
{sourceToolProtocol === "opc-ua" ? <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 === "bacnet-ip" ? <small className="muted">{scanResults.bacnet ? `${scanResults.bacnet.devices?.length || 0} Gerät(e) gefunden` : "Für mehrere Hosts im Host-Feld Komma, Leerzeichen oder Semikolon verwenden."}</small> : null}
|
||||
<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}
|
||||
{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}
|
||||
</div>
|
||||
@@ -2617,6 +2698,7 @@ export default function App() {
|
||||
{canManageSources ? (
|
||||
<div className="protocol-block manual-point-card source-settings-card">
|
||||
<strong>Einzelnen Datenpunkt anlegen</strong>
|
||||
|
||||
<div className="form-grid compact-forms">
|
||||
<label>
|
||||
<span>Alias</span>
|
||||
@@ -2688,6 +2770,13 @@ export default function App() {
|
||||
<button className="ghost-button compact-button" type="button" onClick={closeSourceEditor}>Schließen</button>
|
||||
</div>
|
||||
|
||||
{(bootError || collectorError || successMessage) ? (
|
||||
<div className={bootError || collectorError ? "modal-status error-box" : "modal-status success-box"}>
|
||||
{bootError || collectorError || successMessage}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
|
||||
<div className="form-grid compact-forms">
|
||||
<label>
|
||||
<span>Protokoll</span>
|
||||
@@ -2702,18 +2791,43 @@ export default function App() {
|
||||
<span>Alias / Anzeigename</span>
|
||||
<input value={sourceDraft.name} onChange={(event) => setSourceDraft((current) => ({ ...current, name: event.target.value, displayName: event.target.value, tableName: buildCollectorTableName(event.target.value, current.protocol) }))} />
|
||||
</label>
|
||||
<label>
|
||||
<span>Host / IP</span>
|
||||
<input value={sourceDraft.host} onChange={(event) => setSourceDraft((current) => ({ ...current, host: event.target.value }))} />
|
||||
</label>
|
||||
{sourceDraft.protocol !== "bacnet-ip" || sourceDraft.bacnetMode !== "broadcast" ? (
|
||||
<label>
|
||||
<span>{sourceDraft.protocol === "bacnet-ip" ? "BBMD Host / IP" : "Host / IP"}</span>
|
||||
<input value={sourceDraft.host} onChange={(event) => setSourceDraft((current) => ({ ...current, host: event.target.value }))} />
|
||||
</label>
|
||||
) : null}
|
||||
<label>
|
||||
<span>Port</span>
|
||||
<input value={sourceDraft.port} onChange={(event) => setSourceDraft((current) => ({ ...current, port: event.target.value }))} />
|
||||
</label>
|
||||
{sourceDraft.protocol === "bacnet-ip" ? (
|
||||
<label>
|
||||
<span>BACnet Modus</span>
|
||||
<select value={sourceDraft.bacnetMode} onChange={(event) => setSourceDraft((current) => ({ ...current, bacnetMode: event.target.value, host: event.target.value === "broadcast" ? "" : current.host }))}>
|
||||
<option value="broadcast">Broadcast / Who-Is</option>
|
||||
<option value="bbmd">BBMD</option>
|
||||
<option value="foreign-device">Foreign Device</option>
|
||||
</select>
|
||||
</label>
|
||||
) : null}
|
||||
<label>
|
||||
<span>Geräte-ID</span>
|
||||
<span>{"Geräte-ID"}</span>
|
||||
<input value={sourceDraft.deviceId} onChange={(event) => setSourceDraft((current) => ({ ...current, deviceId: event.target.value }))} />
|
||||
</label>
|
||||
{sourceDraft.protocol === "bacnet-ip" && sourceDraft.bacnetMode === "broadcast" ? (
|
||||
<label>
|
||||
<span>Broadcast-Adresse</span>
|
||||
<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" && sourceDraft.bacnetMode === "foreign-device" ? (
|
||||
<label>
|
||||
<span>Foreign Device TTL (s)</span>
|
||||
<input type="number" min="30" value={sourceDraft.bacnetForeignDeviceTtl} onChange={(event) => setSourceDraft((current) => ({ ...current, bacnetForeignDeviceTtl: event.target.value }))} />
|
||||
</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 />
|
||||
@@ -2731,8 +2845,44 @@ export default function App() {
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<small className="muted">Der Tabellenname wird automatisch passend erzeugt. So taucht die Quelle später sicher in der Trendansicht auf.</small>
|
||||
<small className="muted">{"Der Tabellenname wird automatisch passend erzeugt. Bei BACnet läuft der Scan standardmäßig über echtes Who-Is per Broadcast. Host/IP ist nur für BBMD oder Foreign Device nötig."}</small>
|
||||
|
||||
{sourceDraft.protocol === "bacnet-ip" ? (
|
||||
<>
|
||||
<div className="button-row wrap-tight">
|
||||
<button className="ghost-button compact-button" type="button" onClick={() => runBacnetScan({ includeObjects: false })}>{"Netz nach BACnet-Geräten scannen"}</button>
|
||||
</div>
|
||||
<div className="bacnet-device-panel">
|
||||
<div className="bacnet-device-title">
|
||||
<strong>Gefundene BACnet-Geräte</strong>
|
||||
<small>{scanResults.bacnet ? `${filteredBacnetDevices.length} Gerät(e)` : "Noch kein Scan gestartet"}</small>
|
||||
</div>
|
||||
{filteredBacnetDevices.length ? (
|
||||
<div className="bacnet-device-list">
|
||||
{filteredBacnetDevices.slice(0, 80).map((device, index) => {
|
||||
const isSelectedDevice = String(sourceDraft.deviceId || "") === String(device.deviceId || "");
|
||||
return (
|
||||
<button
|
||||
key={String(device.deviceId || device.host || device.address || index)}
|
||||
type="button"
|
||||
className={isSelectedDevice ? "bacnet-device-row active" : "bacnet-device-row"}
|
||||
onClick={() => selectBacnetDevice(device)}
|
||||
>
|
||||
<span>
|
||||
<strong>{normalizeDisplayText(device.name, "BACnet Gerät " + (device.deviceId || index + 1))}</strong>
|
||||
<small>{"Device " + (device.deviceId || "?") + " | " + normalizeDisplayText(device.host || device.address) + ":" + (device.port || sourceDraft.port || "47808")}</small>
|
||||
</span>
|
||||
<em>{String(device.pointCount ?? device.points?.length ?? 0) + " DP"}</em>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<div className="empty-state slim-empty"><p>{scanResults.bacnet ? "Kein BACnet-Gerät gefunden. Prüfe Broadcast-Adresse, Device-ID oder BBMD/Foreign-Device-Einstellung." : "Scan starten, dann erscheinen die Geräte hier."}</p></div>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
) : null}
|
||||
<div className="button-row wrap-tight">
|
||||
<button className="primary-button compact-button" type="button" onClick={saveCollectorSource}>Quelle speichern</button>
|
||||
{selectedSourceId ? <button className="ghost-button compact-button danger" type="button" onClick={deleteCollectorSource}>Quelle löschen</button> : null}
|
||||
|
||||
+78
-1
@@ -1,4 +1,4 @@
|
||||
:root {
|
||||
:root {
|
||||
color-scheme: light;
|
||||
--bg: #f4f6fb;
|
||||
--bg-accent: linear-gradient(180deg, #f8fbff 0%, #eef3fb 100%);
|
||||
@@ -1149,11 +1149,88 @@ span {
|
||||
box-shadow: 0 28px 80px rgba(15, 23, 42, 0.22);
|
||||
}
|
||||
|
||||
.bacnet-device-list {
|
||||
display: grid;
|
||||
gap: 0.35rem;
|
||||
max-height: 220px;
|
||||
overflow: auto;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 10px;
|
||||
padding: 0.35rem;
|
||||
}
|
||||
|
||||
.bacnet-device-row {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
width: 100%;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 8px;
|
||||
padding: 0.42rem 0.5rem;
|
||||
background: transparent;
|
||||
color: var(--text);
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.bacnet-device-row:hover,
|
||||
.bacnet-device-row.active {
|
||||
border-color: var(--accent);
|
||||
background: color-mix(in srgb, var(--accent) 10%, transparent);
|
||||
}
|
||||
|
||||
.bacnet-device-row span {
|
||||
display: grid;
|
||||
gap: 0.1rem;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.bacnet-device-row small {
|
||||
color: var(--muted);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.bacnet-device-row em {
|
||||
color: var(--muted);
|
||||
font-style: normal;
|
||||
font-size: 0.72rem;
|
||||
}
|
||||
|
||||
.source-modal {
|
||||
display: grid;
|
||||
gap: 0.65rem;
|
||||
}
|
||||
|
||||
.modal-status {
|
||||
margin: 0;
|
||||
padding: 0.55rem 0.7rem;
|
||||
font-size: 0.78rem;
|
||||
}
|
||||
|
||||
.bacnet-device-panel {
|
||||
display: grid;
|
||||
gap: 0.45rem;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 12px;
|
||||
padding: 0.55rem;
|
||||
background: color-mix(in srgb, var(--surface-raised) 68%, transparent);
|
||||
}
|
||||
|
||||
.bacnet-device-title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 0.75rem;
|
||||
font-size: 0.78rem;
|
||||
}
|
||||
|
||||
.bacnet-device-title small {
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
@media (max-width: 980px) {
|
||||
.source-admin-top,
|
||||
.source-split,
|
||||
|
||||
Reference in New Issue
Block a user