feat(admin): live video-presence indicators on cluster DeckLink ports
Adds per-port video signal state to the admin Cluster panel: - New GET /cluster/devices/blackmagic/signal endpoint joins recorders by node_id+device_index and queries each active capture container's /capture/status (local: http://recorder-<id>:3001, remote: api_url/ sidecar/<container_id>/status). Returns receiving/connecting/lost/ error/idle/no-recorder per port plus framesReceived and currentFps. - bmd-card.js render() now accepts portSignals (Map or object) and overlays a colored dot on each BNC connector with pulse animation for receiving/connecting states. - screens-admin.jsx Cluster panel polls the new endpoint every 5s, feeds the signal map into both the port chips (now show RECEIVING/CONNECTING/LOST + fps) and the BMD SVG card diagram rendered below them via a new BmdCardPanel component. - styles-fixes.css adds bmd-card-* styles for the SVG diagram and bmd-port-signal --pulse animation.
This commit is contained in:
parent
d257a19d9d
commit
a44d8bd7c9
4 changed files with 360 additions and 39 deletions
|
|
@ -146,6 +146,113 @@ router.post('/heartbeat', async (req, res, next) => {
|
||||||
} catch (err) { next(err); }
|
} catch (err) { next(err); }
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// GET /devices/blackmagic/signal – live video-presence state for every
|
||||||
|
// DeckLink port across the cluster. For each port we check whether there is
|
||||||
|
// an active SDI recorder assigned to it and, if so, query the capture
|
||||||
|
// container for its real signal state (receiving / lost / connecting /
|
||||||
|
// error). Ports without a recorder get signal = 'no-recorder'.
|
||||||
|
//
|
||||||
|
// Response shape (array):
|
||||||
|
// { node_id, hostname, index, device, model,
|
||||||
|
// signal, framesReceived, currentFps, recorder_id, recorder_status }
|
||||||
|
router.get('/devices/blackmagic/signal', async (req, res, next) => {
|
||||||
|
try {
|
||||||
|
// 1. Fetch all cluster nodes with DeckLink capabilities.
|
||||||
|
const nodesResult = await pool.query(
|
||||||
|
`SELECT id, hostname, ip_address, api_url, capabilities,
|
||||||
|
EXTRACT(EPOCH FROM (NOW() - last_seen)) AS stale_seconds
|
||||||
|
FROM cluster_nodes
|
||||||
|
WHERE capabilities IS NOT NULL`
|
||||||
|
);
|
||||||
|
|
||||||
|
// 2. Fetch all SDI recorders that are pinned to a node+device_index.
|
||||||
|
const recResult = await pool.query(
|
||||||
|
`SELECT id, name, status, container_id, node_id, device_index,
|
||||||
|
source_config
|
||||||
|
FROM recorders
|
||||||
|
WHERE source_type = 'sdi' AND node_id IS NOT NULL`
|
||||||
|
);
|
||||||
|
|
||||||
|
// Build a fast lookup: "${node_id}:${device_index}" → recorder row.
|
||||||
|
const recByPort = new Map();
|
||||||
|
for (const r of recResult.rows) {
|
||||||
|
const devIdx = r.device_index ?? r.source_config?.device ?? 0;
|
||||||
|
recByPort.set(`${r.node_id}:${devIdx}`, r);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 3. For each port, determine signal state. We fire all capture-container
|
||||||
|
// fetches concurrently so the endpoint stays fast even with many ports.
|
||||||
|
const tasks = [];
|
||||||
|
for (const node of nodesResult.rows) {
|
||||||
|
const nodeOnline = Number(node.stale_seconds) < 120;
|
||||||
|
const bm = (node.capabilities && node.capabilities.blackmagic) || [];
|
||||||
|
const model = (node.capabilities && node.capabilities.blackmagic_model) || null;
|
||||||
|
const localHostname = process.env.NODE_HOSTNAME || '';
|
||||||
|
const isRemote = node.api_url && node.hostname !== localHostname;
|
||||||
|
|
||||||
|
bm.forEach((d, idx) => {
|
||||||
|
const portIndex = d.index !== undefined ? d.index : idx;
|
||||||
|
const rec = recByPort.get(`${node.id}:${portIndex}`);
|
||||||
|
|
||||||
|
tasks.push((async () => {
|
||||||
|
const base = {
|
||||||
|
node_id: node.id,
|
||||||
|
hostname: node.hostname,
|
||||||
|
index: portIndex,
|
||||||
|
device: d.device || null,
|
||||||
|
model,
|
||||||
|
node_online: nodeOnline,
|
||||||
|
recorder_id: rec ? rec.id : null,
|
||||||
|
recorder_name: rec ? rec.name : null,
|
||||||
|
recorder_status: rec ? rec.status : null,
|
||||||
|
signal: 'no-recorder',
|
||||||
|
framesReceived: null,
|
||||||
|
currentFps: null,
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!rec || rec.status !== 'recording' || !rec.container_id) {
|
||||||
|
// No active capture — if there's a recorder but it's not recording,
|
||||||
|
// report that; otherwise the port is unassigned.
|
||||||
|
if (rec && rec.status !== 'recording') base.signal = 'idle';
|
||||||
|
return base;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Active recording — query the capture container for real signal.
|
||||||
|
try {
|
||||||
|
let live = null;
|
||||||
|
if (isRemote) {
|
||||||
|
const r = await fetch(
|
||||||
|
`${node.api_url}/sidecar/${rec.container_id}/status`,
|
||||||
|
{ signal: AbortSignal.timeout(2500) }
|
||||||
|
);
|
||||||
|
if (r.ok) live = (await r.json()).live;
|
||||||
|
} else {
|
||||||
|
const r = await fetch(
|
||||||
|
`http://recorder-${rec.id}:3001/capture/status`,
|
||||||
|
{ signal: AbortSignal.timeout(2000) }
|
||||||
|
);
|
||||||
|
if (r.ok) live = await r.json();
|
||||||
|
}
|
||||||
|
if (live && live.signal) {
|
||||||
|
base.signal = live.signal;
|
||||||
|
base.framesReceived = live.framesReceived ?? null;
|
||||||
|
base.currentFps = live.currentFps ?? null;
|
||||||
|
} else {
|
||||||
|
base.signal = 'connecting';
|
||||||
|
}
|
||||||
|
} catch (_) {
|
||||||
|
base.signal = 'connecting';
|
||||||
|
}
|
||||||
|
return base;
|
||||||
|
})());
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const results = await Promise.all(tasks);
|
||||||
|
res.json(results);
|
||||||
|
} catch (err) { next(err); }
|
||||||
|
});
|
||||||
|
|
||||||
// GET /devices/blackmagic – flatten every node's DeckLink cards for the
|
// GET /devices/blackmagic – flatten every node's DeckLink cards for the
|
||||||
// recorder picker. Returns one entry per device with the host node info.
|
// recorder picker. Returns one entry per device with the host node info.
|
||||||
router.get('/devices/blackmagic', async (req, res, next) => {
|
router.get('/devices/blackmagic', async (req, res, next) => {
|
||||||
|
|
|
||||||
|
|
@ -140,6 +140,17 @@ window.BMDCards = (function () {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Signal state → visual properties for the overlay dot.
|
||||||
|
// Colors are CSS custom-property strings so they inherit the app theme.
|
||||||
|
const SIGNAL_STYLE = {
|
||||||
|
'receiving': { fill: 'var(--success, #2dd4a8)', pulse: true },
|
||||||
|
'connecting': { fill: 'var(--accent, #5b7cfa)', pulse: true },
|
||||||
|
'lost': { fill: 'var(--danger, #f87171)', pulse: false },
|
||||||
|
'error': { fill: 'var(--danger, #f87171)', pulse: false },
|
||||||
|
'idle': { fill: 'var(--text-3, #7a8194)', pulse: false },
|
||||||
|
'no-recorder': { fill: 'var(--text-4, #4a4f61)', pulse: false },
|
||||||
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Render the SVG card.
|
* Render the SVG card.
|
||||||
*
|
*
|
||||||
|
|
@ -149,8 +160,12 @@ window.BMDCards = (function () {
|
||||||
* selectedIndex currently-selected port index
|
* selectedIndex currently-selected port index
|
||||||
* onSelect (index) => void
|
* onSelect (index) => void
|
||||||
* compact drop the model label + footer (default false)
|
* compact drop the model label + footer (default false)
|
||||||
|
* portSignals Map<portIndex, signalState> — overlays a presence dot on
|
||||||
|
* each port. signalState is one of: 'receiving', 'connecting',
|
||||||
|
* 'lost', 'error', 'idle', 'no-recorder'. Omit or pass null
|
||||||
|
* to render the card without signal indicators.
|
||||||
*/
|
*/
|
||||||
function render({ model, deviceCount, selectedIndex, onSelect, compact }) {
|
function render({ model, deviceCount, selectedIndex, onSelect, compact, portSignals }) {
|
||||||
const m = resolveModel(model) || genericModel(deviceCount || 2);
|
const m = resolveModel(model) || genericModel(deviceCount || 2);
|
||||||
|
|
||||||
// If we resolved a model but the cluster reports more ports than we
|
// If we resolved a model but the cluster reports more ports than we
|
||||||
|
|
@ -231,6 +246,35 @@ window.BMDCards = (function () {
|
||||||
class: 'bmd-port-sublabel',
|
class: 'bmd-port-sublabel',
|
||||||
})).textContent = p.role;
|
})).textContent = p.role;
|
||||||
|
|
||||||
|
// Video-presence indicator — small dot overlaid on the BNC connector.
|
||||||
|
// Shown whenever portSignals is provided, even for unassigned ports.
|
||||||
|
if (portSignals) {
|
||||||
|
const sig = portSignals instanceof Map
|
||||||
|
? portSignals.get(i)
|
||||||
|
: (portSignals[i] ?? null);
|
||||||
|
const sigKey = sig || 'no-recorder';
|
||||||
|
const style = SIGNAL_STYLE[sigKey] || SIGNAL_STYLE['no-recorder'];
|
||||||
|
|
||||||
|
// Dot positioned at top-right of the BNC ring so it doesn't obscure
|
||||||
|
// the connector centre. HDMI connectors get a slight offset.
|
||||||
|
const dotR = 4;
|
||||||
|
const dotCx = isHdmi ? p.cx + 12 : p.cx + 9;
|
||||||
|
const dotCy = isHdmi ? p.cy - 8 : p.cy - 9;
|
||||||
|
|
||||||
|
const dot = el('circle', {
|
||||||
|
cx: dotCx, cy: dotCy, r: dotR,
|
||||||
|
class: 'bmd-port-signal' + (style.pulse ? ' bmd-port-signal--pulse' : ''),
|
||||||
|
fill: style.fill,
|
||||||
|
'data-signal': sigKey,
|
||||||
|
});
|
||||||
|
g.appendChild(dot);
|
||||||
|
|
||||||
|
// Tooltip title on the group for hover text.
|
||||||
|
const title = document.createElementNS(NS, 'title');
|
||||||
|
title.textContent = sigKey.replace('-', ' ');
|
||||||
|
g.appendChild(title);
|
||||||
|
}
|
||||||
|
|
||||||
svg.appendChild(g);
|
svg.appendChild(g);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -923,9 +923,126 @@ function Containers() {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ────────────────────────────────────────────────────────────────────────────
|
||||||
|
// BmdCardPanel — capture-card section inside the Cluster node detail panel.
|
||||||
|
// Shows port chips with live video-presence dots AND the BMD SVG card diagram.
|
||||||
|
// ────────────────────────────────────────────────────────────────────────────
|
||||||
|
function BmdCardPanel({ sel, portSignals }) {
|
||||||
|
const svgRef = React.useRef(null);
|
||||||
|
|
||||||
|
// Build the port-index → signal-entry map for the selected node.
|
||||||
|
const nodeSignalMap = React.useMemo(() => {
|
||||||
|
const map = new Map();
|
||||||
|
sel.bmdPorts.forEach((p) => {
|
||||||
|
const key = `${sel.dbId}:${p.index}`;
|
||||||
|
const entry = portSignals[key];
|
||||||
|
if (entry) map.set(p.index, entry.signal);
|
||||||
|
});
|
||||||
|
return map;
|
||||||
|
}, [sel.dbId, sel.bmdPorts, portSignals]);
|
||||||
|
|
||||||
|
// (Re-)render the SVG card diagram whenever the node or signals change.
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (!svgRef.current || !window.BMDCards) return;
|
||||||
|
if (sel.bmdPorts.length === 0) return;
|
||||||
|
svgRef.current.innerHTML = '';
|
||||||
|
const svg = window.BMDCards.render({
|
||||||
|
model: sel.bmdPorts[0].model || '',
|
||||||
|
deviceCount: sel.bmdCount,
|
||||||
|
compact: true,
|
||||||
|
portSignals: nodeSignalMap,
|
||||||
|
});
|
||||||
|
svgRef.current.appendChild(svg);
|
||||||
|
}, [sel.dbId, sel.bmdCount, nodeSignalMap]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div style={{ fontSize: 11, color: "var(--text-3)", marginBottom: 6, display: "flex", alignItems: "center", gap: 6 }}>
|
||||||
|
<Icon name="video" size={11} />
|
||||||
|
Capture cards {sel.bmdCount > 0 ? `(${sel.bmdCount} port${sel.bmdCount !== 1 ? 's' : ''})` : '— none reported'}
|
||||||
|
</div>
|
||||||
|
{sel.bmdPorts.length === 0 && (
|
||||||
|
<div style={{ fontSize: 11.5, color: "var(--text-4)", padding: "4px 0" }}>No DeckLink cards detected on this node</div>
|
||||||
|
)}
|
||||||
|
{sel.bmdPorts.length > 0 && (
|
||||||
|
<div style={{ padding: "8px 10px", background: "var(--bg-2)", borderRadius: 5, border: "1px solid rgba(91,124,250,0.2)" }}>
|
||||||
|
{/* Card header */}
|
||||||
|
<div style={{ display: "flex", alignItems: "center", gap: 8, marginBottom: 8 }}>
|
||||||
|
<Icon name="video" size={13} style={{ color: "var(--accent)" }} />
|
||||||
|
<span style={{ fontSize: 12, fontWeight: 600, color: "var(--text-1)" }}>
|
||||||
|
{sel.bmdPorts[0].model || "Blackmagic DeckLink"}
|
||||||
|
</span>
|
||||||
|
<span style={{ marginLeft: "auto", fontSize: 10, fontWeight: 600, padding: "2px 6px", borderRadius: 3, background: "rgba(91,124,250,0.15)", color: "var(--accent)" }}>
|
||||||
|
{sel.bmdCount} PORT{sel.bmdCount !== 1 ? 'S' : ''}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Port chips with signal state */}
|
||||||
|
<div style={{ display: "flex", flexWrap: "wrap", gap: 4, marginBottom: 10 }}>
|
||||||
|
{sel.bmdPorts.map((p) => {
|
||||||
|
const sigEntry = portSignals[`${sel.dbId}:${p.index}`];
|
||||||
|
const sig = sigEntry ? sigEntry.signal : (p.online !== false ? null : 'offline');
|
||||||
|
const { label, color } = _signalChip(sig);
|
||||||
|
const isReceiving = sig === 'receiving';
|
||||||
|
return (
|
||||||
|
<div key={p.index} title={sigEntry ? `${sigEntry.recorder_name || 'recorder'} — ${label}` : label}
|
||||||
|
style={{
|
||||||
|
display: "flex", alignItems: "center", gap: 5,
|
||||||
|
fontSize: 10.5, fontFamily: "var(--font-mono)",
|
||||||
|
padding: "3px 8px", borderRadius: 3,
|
||||||
|
background: isReceiving ? "rgba(45,212,168,0.1)" : "rgba(255,255,255,0.04)",
|
||||||
|
border: `1px solid ${isReceiving ? "rgba(45,212,168,0.3)" : "var(--border)"}`,
|
||||||
|
}}>
|
||||||
|
{/* Signal presence dot */}
|
||||||
|
<span style={{
|
||||||
|
width: 6, height: 6, borderRadius: "50%", flexShrink: 0,
|
||||||
|
background: sig ? color : "var(--text-4)",
|
||||||
|
animation: isReceiving ? "signalPulse 1.4s ease-in-out infinite" : "none",
|
||||||
|
}} />
|
||||||
|
<span style={{ color: "var(--text-2)" }}>
|
||||||
|
{p.device ? p.device.split('/').pop() : `port ${p.index}`}
|
||||||
|
</span>
|
||||||
|
{sig && (
|
||||||
|
<span style={{ color, fontSize: 9, fontWeight: 700, marginLeft: 2, letterSpacing: "0.04em" }}>
|
||||||
|
{label}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{sigEntry && sigEntry.currentFps != null && (
|
||||||
|
<span style={{ color: "var(--text-4)", fontSize: 9 }}>
|
||||||
|
{Number(sigEntry.currentFps).toFixed(1)} fps
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* BMD SVG card diagram */}
|
||||||
|
<div ref={svgRef} className="bmd-card-diagram" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Signal state → { label, color } for the port chip indicator.
|
||||||
|
function _signalChip(sig) {
|
||||||
|
switch (sig) {
|
||||||
|
case 'receiving': return { label: 'RECEIVING', color: 'var(--success)' };
|
||||||
|
case 'connecting': return { label: 'CONNECTING', color: 'var(--accent)' };
|
||||||
|
case 'lost': return { label: 'LOST', color: 'var(--danger)' };
|
||||||
|
case 'error': return { label: 'ERROR', color: 'var(--danger)' };
|
||||||
|
case 'idle': return { label: 'IDLE', color: 'var(--text-3)' };
|
||||||
|
case 'no-recorder': return { label: 'NO RECORDER', color: 'var(--text-4)' };
|
||||||
|
default: return { label: sig || '—', color: 'var(--text-4)' };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function Cluster() {
|
function Cluster() {
|
||||||
const [nodesData, setNodesData] = React.useState(window.ZAMPP_DATA.NODES);
|
const [nodesData, setNodesData] = React.useState(window.ZAMPP_DATA.NODES);
|
||||||
const [hovered, setHovered] = React.useState(null);
|
const [hovered, setHovered] = React.useState(null);
|
||||||
|
// Map of "node_id:portIndex" → signal entry from /cluster/devices/blackmagic/signal
|
||||||
|
const [portSignals, setPortSignals] = React.useState({});
|
||||||
|
|
||||||
const refresh = React.useCallback(() => {
|
const refresh = React.useCallback(() => {
|
||||||
window.ZAMPP_API.fetch('/cluster')
|
window.ZAMPP_API.fetch('/cluster')
|
||||||
|
|
@ -936,6 +1053,22 @@ function Cluster() {
|
||||||
.catch(() => {});
|
.catch(() => {});
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
// Poll live video-presence state for all DeckLink ports every 5 s.
|
||||||
|
React.useEffect(() => {
|
||||||
|
const poll = () => {
|
||||||
|
window.ZAMPP_API.fetch('/cluster/devices/blackmagic/signal')
|
||||||
|
.then(entries => {
|
||||||
|
const map = {};
|
||||||
|
(entries || []).forEach(e => { map[`${e.node_id}:${e.index}`] = e; });
|
||||||
|
setPortSignals(map);
|
||||||
|
})
|
||||||
|
.catch(() => {});
|
||||||
|
};
|
||||||
|
poll();
|
||||||
|
const id = setInterval(poll, 5000);
|
||||||
|
return () => clearInterval(id);
|
||||||
|
}, []);
|
||||||
|
|
||||||
const nodesArr = Array.isArray(nodesData) ? nodesData : (nodesData?.nodes || []);
|
const nodesArr = Array.isArray(nodesData) ? nodesData : (nodesData?.nodes || []);
|
||||||
|
|
||||||
const NODES = React.useMemo(() => {
|
const NODES = React.useMemo(() => {
|
||||||
|
|
@ -1187,44 +1320,7 @@ function Cluster() {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* ── Capture cards ── */}
|
{/* ── Capture cards ── */}
|
||||||
<div>
|
<BmdCardPanel sel={sel} portSignals={portSignals} />
|
||||||
<div style={{ fontSize: 11, color: "var(--text-3)", marginBottom: 6, display: "flex", alignItems: "center", gap: 6 }}>
|
|
||||||
<Icon name="video" size={11} />
|
|
||||||
Capture cards {sel.bmdCount > 0 ? `(${sel.bmdCount} port${sel.bmdCount !== 1 ? 's' : ''})` : '— none reported'}
|
|
||||||
</div>
|
|
||||||
{sel.bmdPorts.length === 0 && (
|
|
||||||
<div style={{ fontSize: 11.5, color: "var(--text-4)", padding: "4px 0" }}>No DeckLink cards detected on this node</div>
|
|
||||||
)}
|
|
||||||
{sel.bmdPorts.length > 0 && (
|
|
||||||
<div style={{
|
|
||||||
padding: "8px 10px", background: "var(--bg-2)", borderRadius: 5,
|
|
||||||
border: "1px solid rgba(91,124,250,0.2)",
|
|
||||||
}}>
|
|
||||||
<div style={{ display: "flex", alignItems: "center", gap: 8, marginBottom: 6 }}>
|
|
||||||
<Icon name="video" size={13} style={{ color: "var(--accent)" }} />
|
|
||||||
<span style={{ fontSize: 12, fontWeight: 600, color: "var(--text-1)" }}>
|
|
||||||
{sel.bmdPorts[0].model || "Blackmagic DeckLink"}
|
|
||||||
</span>
|
|
||||||
<span style={{ marginLeft: "auto", fontSize: 10, fontWeight: 600, padding: "2px 6px", borderRadius: 3, background: "rgba(91,124,250,0.15)", color: "var(--accent)" }}>
|
|
||||||
{sel.bmdCount} PORT{sel.bmdCount !== 1 ? 'S' : ''}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<div style={{ display: "flex", flexWrap: "wrap", gap: 4 }}>
|
|
||||||
{sel.bmdPorts.map((p, i) => (
|
|
||||||
<div key={i} style={{
|
|
||||||
fontSize: 10.5, fontFamily: "var(--font-mono)",
|
|
||||||
padding: "2px 7px", borderRadius: 3,
|
|
||||||
background: p.online !== false ? "rgba(91,250,138,0.1)" : "rgba(255,255,255,0.05)",
|
|
||||||
color: p.online !== false ? "var(--success)" : "var(--text-3)",
|
|
||||||
border: `1px solid ${p.online !== false ? "rgba(91,250,138,0.25)" : "var(--border)"}`,
|
|
||||||
}}>
|
|
||||||
{p.device ? p.device.split('/').pop() : `port ${p.index}`}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div style={{ display: "flex", gap: 6, marginTop: 6 }}>
|
<div style={{ display: "flex", gap: 6, marginTop: 6 }}>
|
||||||
<button className="btn ghost sm" onClick={() => nodeLogsHint(sel)}>Logs</button>
|
<button className="btn ghost sm" onClick={() => nodeLogsHint(sel)}>Logs</button>
|
||||||
<button className="btn ghost sm" onClick={() => drainNode(sel)}>Drain</button>
|
<button className="btn ghost sm" onClick={() => drainNode(sel)}>Drain</button>
|
||||||
|
|
|
||||||
|
|
@ -535,3 +535,77 @@
|
||||||
0%, 100% { box-shadow: 0 0 0 0 rgba(45, 212, 168, 0.6); }
|
0%, 100% { box-shadow: 0 0 0 0 rgba(45, 212, 168, 0.6); }
|
||||||
50% { box-shadow: 0 0 0 6px rgba(45, 212, 168, 0); }
|
50% { box-shadow: 0 0 0 6px rgba(45, 212, 168, 0); }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ============================================================
|
||||||
|
BMD card diagram — rendered inside the Cluster node panel.
|
||||||
|
The SVG is generated by bmd-card.js; styles live here so
|
||||||
|
they inherit the app CSS custom properties at render time.
|
||||||
|
============================================================ */
|
||||||
|
.bmd-card-diagram {
|
||||||
|
width: 100%;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
.bmd-card-svg {
|
||||||
|
width: 100%;
|
||||||
|
height: auto;
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
.bmd-card-body {
|
||||||
|
fill: var(--bg-3, #1e2130);
|
||||||
|
stroke: var(--border, #2d3147);
|
||||||
|
stroke-width: 1;
|
||||||
|
}
|
||||||
|
.bmd-card-bracket {
|
||||||
|
fill: var(--bg-1, #13151f);
|
||||||
|
stroke: var(--border, #2d3147);
|
||||||
|
stroke-width: 1.5;
|
||||||
|
}
|
||||||
|
.bmd-card-trace {
|
||||||
|
stroke: rgba(91, 124, 250, 0.12);
|
||||||
|
stroke-width: 0.5;
|
||||||
|
fill: none;
|
||||||
|
}
|
||||||
|
.bmd-port-group {
|
||||||
|
transition: opacity 0.15s;
|
||||||
|
}
|
||||||
|
.bmd-port-group:hover {
|
||||||
|
opacity: 0.85;
|
||||||
|
}
|
||||||
|
.bmd-port-ring {
|
||||||
|
fill: var(--bg-1, #13151f);
|
||||||
|
stroke: var(--border, #2d3147);
|
||||||
|
stroke-width: 1.5;
|
||||||
|
}
|
||||||
|
.bmd-port-pin {
|
||||||
|
fill: var(--text-4, #4a4f61);
|
||||||
|
}
|
||||||
|
.bmd-port-label {
|
||||||
|
fill: var(--text-1, #e8eaf6);
|
||||||
|
font-size: 10px;
|
||||||
|
font-weight: 600;
|
||||||
|
font-family: var(--font-mono, monospace);
|
||||||
|
}
|
||||||
|
.bmd-port-sublabel {
|
||||||
|
fill: var(--text-3, #7a8194);
|
||||||
|
font-size: 8.5px;
|
||||||
|
font-family: var(--font-mono, monospace);
|
||||||
|
}
|
||||||
|
.bmd-card-model {
|
||||||
|
fill: var(--text-4, #4a4f61);
|
||||||
|
font-size: 9px;
|
||||||
|
font-weight: 700;
|
||||||
|
letter-spacing: 0.08em;
|
||||||
|
font-family: var(--font-mono, monospace);
|
||||||
|
}
|
||||||
|
/* Signal presence dot overlaid on each BNC connector */
|
||||||
|
.bmd-port-signal {
|
||||||
|
opacity: 0.95;
|
||||||
|
filter: drop-shadow(0 0 2px currentColor);
|
||||||
|
}
|
||||||
|
.bmd-port-signal--pulse {
|
||||||
|
animation: bmdPortPulse 1.4s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
@keyframes bmdPortPulse {
|
||||||
|
0%, 100% { opacity: 0.95; r: 4; }
|
||||||
|
50% { opacity: 0.6; r: 5; }
|
||||||
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue