Topology: show what needs a look, quiet everything else
Lint / JS (eslint) (push) Successful in 17s
Lint / Python (flake8) (push) Successful in 2m2s
Lint / Notify on failure (push) Skipped
Lint / Deploy (push) Skipped
Lint / Python (flake8) (pull_request) Successful in 1m47s
Lint / JS (eslint) (pull_request) Successful in 7s
Lint / Notify on failure (pull_request) Skipped
Lint / Deploy (pull_request) Skipped
Security / Python Security (bandit) (push) Successful in 1m57s
Security / Python Security (bandit) (pull_request) Successful in 1m54s
Test / Python Tests (pytest) (push) Successful in 2m26s
Test / Python Tests (pytest) (pull_request) Successful in 2m27s
Lint / JS (eslint) (push) Successful in 17s
Lint / Python (flake8) (push) Successful in 2m2s
Lint / Notify on failure (push) Skipped
Lint / Deploy (push) Skipped
Lint / Python (flake8) (pull_request) Successful in 1m47s
Lint / JS (eslint) (pull_request) Successful in 7s
Lint / Notify on failure (pull_request) Skipped
Lint / Deploy (pull_request) Skipped
Security / Python Security (bandit) (push) Successful in 1m57s
Security / Python Security (bandit) (pull_request) Successful in 1m54s
Test / Python Tests (pytest) (push) Successful in 2m26s
Test / Python Tests (pytest) (pull_request) Successful in 2m27s
- A bar above the diagram lists what needs attention: devices down, degraded or alerting, cables down or last seen elsewhere, mislabelled ports, cables with errors. Click one to zoom to it; a missing device is one entry, with where it was last seen. All clear says so. - Problem cables glow and are always drawn, whatever the filters; problem cards get a red or amber border. Healthy cables are drawn quietly. - Each server's slower second cable (its 1G management link) is hidden by default while healthy: "Server 1G links" shows them. That was most of the clutter. "10G only" is gone, since this covers it. - Power gear is off by default until the power map (part 2) gives the rail something to show. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -185,14 +185,18 @@ The rack front, drawn from the rack units set on cutsheet items, top first.
|
||||
The whole network as the switches see it now, top down: the internet, the modem, the gateway, then
|
||||
each switch by how many hops it is from the gateway, the gear and unknown devices below them, and the
|
||||
servers along the bottom. Power gear (PDU, UPS, power strip) sits in a rail on the right.
|
||||
- **What needs a look** is listed above the diagram: devices down, degraded or with active alerts,
|
||||
cables down or last seen elsewhere, ports named after the wrong device, and cables with errors.
|
||||
Click one to jump to it. With nothing to report it says all clear. Problem cables are drawn in full
|
||||
colour with a glow and always shown, whatever the filters; healthy cables are drawn quietly.
|
||||
- **A card per device**, all the same height so the whole network reads at a glance: name, state
|
||||
(coloured edge and dot), and a picture: UniFi gear shows its real faceplate (the same drawings as the
|
||||
port inspector and rack page), cropped to the ports; servers show the **Front drawing** of their
|
||||
cutsheet item and a socket per physical NIC, so every cable lands on the NIC it is plugged into
|
||||
(NICs marked unused are dimmed). The internet, modem and gateway sit side by side along the top.
|
||||
- **Cables run like cable management**: each leaves the card edge right below its port (a short tail
|
||||
shows the port), drops into a tray above the row it goes to, runs along its own lane (10G on the upper lanes), and drops straight into its socket,
|
||||
so cables never lie on top of each other and only cross at right angles. Devices are placed clear of
|
||||
shows the port), drops into a tray above the row it goes to, runs along its own lane (10G on the
|
||||
upper lanes), and drops straight into its socket, so cables never lie on top of each other and only cross at right angles. Devices are placed clear of
|
||||
the cables passing down through their row, and each sits under its fastest cable.
|
||||
- **One line per physical cable**, leaving the exact port: a switch-to-switch cable is reported by both
|
||||
switches and drawn once. A server with two links (10G and 1G) has two cables. Colour is the cable
|
||||
@@ -212,8 +216,10 @@ servers along the bottom. Power gear (PDU, UPS, power strip) sits in a rail on t
|
||||
both ends, speed, cable type and module, traffic each way and how busy it is, errors and drops, and
|
||||
its cutsheet record. Hovering a device traces its path to the internet.
|
||||
- Drag to pan, scroll or pinch to zoom, **Fit** (or `0`) to see everything, `+` and `-` to zoom. Show
|
||||
unknown devices grouped, one by one or not at all, and show or hide power gear, traffic, cables slower than 10G, and everything without an
|
||||
alert. The filters are remembered per browser. Refreshes every 30 seconds without moving the view.
|
||||
unknown devices grouped, one by one or not at all. **Server 1G links** (off by default) adds each
|
||||
server's slower second cable, its management link; **Power gear** (off by default) adds the PDU, UPS
|
||||
and power strip. Traffic labels and dimming everything without an alert can be switched too. The
|
||||
filters are remembered per browser. Refreshes every 30 seconds without moving the view.
|
||||
- API: `GET /api/topology` returns `{nodes, links, warnings, updated}` (see `topology.py`).
|
||||
|
||||
### Health check (`/health`)
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 148 KiB After Width: | Height: | Size: 163 KiB |
+23
-1
@@ -1556,7 +1556,6 @@ body.fp-marks-off .fp-svg .fp-mark { display: none; }
|
||||
|
||||
/* Filters and the traced path */
|
||||
.tp-no-traffic .tp-rate, .tp-no-traffic .tp-flow { display: none !important; }
|
||||
.tp-only-10g .tp-slow { display: none; }
|
||||
.tp-alerts-only .tp-node:not(.tp-has-alert) { opacity: .25; }
|
||||
.tp-alerts-only .tp-link:not(.tp-touches-alert) { opacity: .2; }
|
||||
.tp-tracing .tp-node:not(.tp-hot), .tp-tracing .tp-link:not(.tp-hot) { opacity: .18; transition: opacity .15s; }
|
||||
@@ -1614,3 +1613,26 @@ body.fp-marks-off .fp-svg .fp-mark { display: none; }
|
||||
.tp-kind-group .tp-node-link { cursor: pointer; }
|
||||
.tp-pigtail { stroke-width: 5 !important; stroke-dasharray: none !important; }
|
||||
[data-theme="light"] .tp-nic-name { fill: #4b5562; }
|
||||
|
||||
/* Healthy cables are quiet; problems are loud */
|
||||
.tp-links .tp-link:not(.tp-problem):not(.tp-hot):not(.tp-selected) { opacity: .6; }
|
||||
.tp-halo { stroke: rgba(224,49,43,.28); stroke-width: 46; fill: none; stroke-linecap: round; }
|
||||
.tp-problem-mismatch .tp-halo, .tp-problem-errors .tp-halo { stroke: rgba(255,179,0,.28); }
|
||||
.tp-node-problem .tp-card { stroke-width: 5; }
|
||||
.tp-node-problem.tp-st-degraded .tp-card { stroke: #ffb300; }
|
||||
.tp-node-problem.tp-st-down .tp-card { stroke: #e0312b; }
|
||||
.tp-summary { display: flex; flex-wrap: wrap; align-items: center; gap: 6px 10px; margin: 0 0 10px; padding: 8px 12px;
|
||||
border: 1px solid var(--border-color); background: var(--bg2); font-size: .8em; }
|
||||
.tp-summary:empty { display: none; }
|
||||
.tp-summary-head { font-weight: 700; letter-spacing: .04em; }
|
||||
.tp-summary-ok { border-color: var(--green-muted); }
|
||||
.tp-summary-ok .tp-summary-head { color: var(--green); }
|
||||
.tp-summary-text { color: var(--text-dim); }
|
||||
.tp-summary-bad { border-color: var(--red); }
|
||||
.tp-summary-bad .tp-summary-head { color: var(--red); }
|
||||
.tp-summary-warn { border-color: var(--amber); }
|
||||
.tp-summary-warn .tp-summary-head { color: var(--amber); }
|
||||
.tp-problem-chip { font: inherit; font-size: .95em; padding: 3px 9px; border: 1px solid; background: transparent; cursor: pointer; text-align: left; }
|
||||
.tp-chip-bad { color: var(--red); border-color: var(--red); background: var(--red-dim); }
|
||||
.tp-chip-warn { color: var(--amber); border-color: var(--amber); background: var(--amber-dim); }
|
||||
.tp-problem-chip:hover, .tp-problem-chip:focus-visible { filter: brightness(1.25); outline: none; }
|
||||
|
||||
+131
-7
@@ -8,7 +8,7 @@ const PER_MM = 1100 / 482.6;
|
||||
const NODE_GAP = 90;
|
||||
const REFRESH_MS = 30000;
|
||||
const FILTER_KEY = 'gandalf.topology.filters';
|
||||
const DEFAULT_FILTERS = { stubs: 'grouped', power: true, traffic: true, tenG: false, alerts: false };
|
||||
const DEFAULT_FILTERS = { stubs: 'grouped', power: false, traffic: true, mgmt: false, alerts: false };
|
||||
const STUB_MODES = ['grouped', 'all', 'none'];
|
||||
|
||||
const state = {
|
||||
@@ -25,6 +25,7 @@ function loadFilters() {
|
||||
try {
|
||||
const saved = Object.assign({}, DEFAULT_FILTERS, JSON.parse(localStorage.getItem(FILTER_KEY) || '{}'));
|
||||
if (!STUB_MODES.includes(saved.stubs)) saved.stubs = saved.stubs === false ? 'none' : 'grouped';
|
||||
delete saved.tenG;
|
||||
return saved;
|
||||
} catch (_) {
|
||||
return Object.assign({}, DEFAULT_FILTERS);
|
||||
@@ -62,6 +63,66 @@ function nodeHref(node) {
|
||||
return deviceHref(node.name);
|
||||
}
|
||||
|
||||
// ── Problems ────────────────────────────────────────────────────────────
|
||||
// Anything that needs a look: drawn in full colour, always shown whatever the filters, and listed
|
||||
// above the diagram. Healthy cables are drawn quietly.
|
||||
function linkProblem(link) {
|
||||
if (link.bundle) return '';
|
||||
if (link.ghost) return 'ghost';
|
||||
if (!link.up) return 'down';
|
||||
if (link.name_mismatch) return 'mismatch';
|
||||
if ((link.errors || 0) > 0) return 'errors';
|
||||
return '';
|
||||
}
|
||||
|
||||
function nodeProblem(node) {
|
||||
if (['stub', 'group', 'internet'].includes(node.kind) && !node.alert_count) return node.kind === 'internet' && node.status === 'down' ? 'down' : '';
|
||||
if (node.status === 'down' || node.status === 'degraded') return node.status;
|
||||
return node.alert_count ? 'alert' : '';
|
||||
}
|
||||
|
||||
function endText(e, byId) {
|
||||
const n = byId[e.node];
|
||||
const name = n ? n.name : e.node;
|
||||
if (e.port_idx != null) return `${name} port ${e.port_idx}`;
|
||||
return e.iface ? `${name} ${e.iface}` : name;
|
||||
}
|
||||
|
||||
function problemsOf(data) {
|
||||
const byId = Object.fromEntries(data.nodes.map(n => [n.id, n]));
|
||||
const out = [];
|
||||
data.nodes.forEach(n => {
|
||||
const p = nodeProblem(n);
|
||||
if (!p) return;
|
||||
const bits = [];
|
||||
if (n.status === 'down' || n.status === 'degraded') bits.push(n.status);
|
||||
if (n.alert_count) bits.push(`${n.alert_count} alert${n.alert_count > 1 ? 's' : ''}`);
|
||||
out.push({ kind: 'node', id: n.id, severity: n.status === 'down' || n.severity === 'critical' ? 'bad' : 'warn',
|
||||
text: `${n.name}: ${bits.join(', ')}` });
|
||||
});
|
||||
data.links.forEach(l => {
|
||||
const p = linkProblem(l);
|
||||
if (!p) return;
|
||||
// A missing device is one problem, not two: say where it was last seen on its own entry
|
||||
const listed = out.find(o => o.kind === 'node' && o.id === l.b.node);
|
||||
if (p === 'ghost' && listed) {
|
||||
listed.text += `, last seen on ${endText(l.a, byId)}`;
|
||||
return;
|
||||
}
|
||||
const host = byId[l.b.node] && byId[l.b.node].kind === 'stub' ? `${byId[l.b.node].name} on ${endText(l.a, byId)}` : null;
|
||||
let text;
|
||||
if (p === 'ghost') text = `${byId[l.b.node] ? byId[l.b.node].name : l.b.node} not seen, last on ${endText(l.a, byId)}`;
|
||||
else if (p === 'down') text = `Cable down: ${host || `${endText(l.a, byId)} to ${endText(l.b, byId)}`}`;
|
||||
else if (p === 'mismatch') {
|
||||
const m = l.name_mismatch;
|
||||
const end = m.end === 'b' ? l.b : l.a;
|
||||
text = `${endText(end, byId)} is named after ${m.named}, but ${m.actual} is plugged in`;
|
||||
} else text = `Errors on ${endText(l.a, byId)} to ${endText(l.b, byId)} (${l.errors}/s)`;
|
||||
out.push({ kind: 'link', id: l.id, severity: p === 'mismatch' || p === 'errors' ? 'warn' : 'bad', text });
|
||||
});
|
||||
return out;
|
||||
}
|
||||
|
||||
function speedClass(mbps) {
|
||||
if (mbps >= 10000) return 'tp-s-10g';
|
||||
if (mbps >= 1000) return 'tp-s-1g';
|
||||
@@ -393,8 +454,19 @@ const CORNER = 26;
|
||||
// the viewer opens that switch's group (or shows them all)
|
||||
function visibleGraph(data, filters, expanded) {
|
||||
const mode = filters.stubs;
|
||||
const all = Object.fromEntries(data.nodes.map(n => [n.id, n]));
|
||||
let nodes = data.nodes.filter(n => (mode !== 'none' || n.kind !== 'stub') && (filters.power || !n.power));
|
||||
let links = data.links.slice();
|
||||
if (!filters.mgmt) {
|
||||
// A server's slower second link (its 1G management cable) is hidden while it is healthy and
|
||||
// the server has a faster one; a problem cable is always drawn
|
||||
links = links.filter(l => {
|
||||
const host = [l.a, l.b].find(e => (all[e.node] || {}).kind === 'host');
|
||||
if (!host || linkProblem(l)) return true;
|
||||
const faster = data.links.some(o => o !== l && [o.a.node, o.b.node].includes(host.node) && (o.speed_mbps || 0) > (l.speed_mbps || 0));
|
||||
return !faster;
|
||||
});
|
||||
}
|
||||
if (mode === 'grouped') {
|
||||
const bySwitch = {};
|
||||
nodes.filter(n => n.kind === 'stub').forEach(n => (bySwitch[n.switch] = bySwitch[n.switch] || []).push(n));
|
||||
@@ -488,7 +560,7 @@ function layout(data, filters, expanded) {
|
||||
const sizes = {};
|
||||
nodes.forEach(n => {
|
||||
const byIface = {};
|
||||
links.forEach(l => [l.a, l.b].forEach(e => { if (e.node === n.id && e.iface) byIface[e.iface] = l; }));
|
||||
data.links.forEach(l => [l.a, l.b].forEach(e => { if (e.node === n.id && e.iface) byIface[e.iface] = l; }));
|
||||
const d = cardFor(n, byIface);
|
||||
drawings[n.id] = d;
|
||||
sizes[n.id] = { w: d.w, h: d.h };
|
||||
@@ -668,7 +740,8 @@ function renderLink(link, lay) {
|
||||
if (link.ghost) cls.push('tp-ghost');
|
||||
else if (!link.up) cls.push('tp-down');
|
||||
if (link.wan) cls.push('tp-wan');
|
||||
if (!link.wan && link.speed_mbps < 10000) cls.push('tp-slow');
|
||||
const problem = linkProblem(link);
|
||||
if (problem) cls.push('tp-problem', `tp-problem-${problem}`);
|
||||
const busy = (link.a_to_b_bps || 0) + (link.b_to_a_bps || 0);
|
||||
const alerting = [link.a.node, link.b.node].some(id => (lay.byId[id] || {}).alert_count);
|
||||
if (alerting) cls.push('tp-touches-alert');
|
||||
@@ -701,7 +774,7 @@ function renderLink(link, lay) {
|
||||
// The cable runs behind the devices; its plugs, traffic and warning sit on top
|
||||
under: `<g ${attrs} tabindex="0" role="button" aria-label="${esc(linkTitle(link, lay.byId))}">
|
||||
<title>${esc(linkTitle(link, lay.byId))}</title>
|
||||
<path class="tp-casing" d="${d}"/><path class="tp-core" d="${d}"/>${flow}<path class="tp-hit" d="${d}"/>
|
||||
${problem ? `<path class="tp-halo" d="${d}"/>` : ''}<path class="tp-casing" d="${d}"/><path class="tp-core" d="${d}"/>${flow}<path class="tp-hit" d="${d}"/>
|
||||
</g>`,
|
||||
over: `<g ${attrs} aria-hidden="true">${plugs}${label}${warn}</g>`,
|
||||
};
|
||||
@@ -721,7 +794,7 @@ function renderNode(node, lay) {
|
||||
? `<g class="tp-node-link" tabindex="0" role="button" data-expand="${esc(node.switch)}" aria-label="${esc(`${node.name} on ${node.switch}: show them`)}">`
|
||||
: (href ? `<a class="tp-node-link" href="${esc(href)}" aria-label="${esc(label)}">` : `<g class="tp-node-link" tabindex="0" aria-label="${esc(label)}">`);
|
||||
const close = open.startsWith('<a') ? '</a>' : '</g>';
|
||||
return `<g class="tp-node tp-kind-${esc(node.kind)} tp-st-${esc(node.status)}${node.alert_count ? ' tp-has-alert' : ''}" data-node="${esc(node.id)}" transform="translate(${p.x} ${p.y})">
|
||||
return `<g class="tp-node tp-kind-${esc(node.kind)} tp-st-${esc(node.status)}${node.alert_count ? ' tp-has-alert' : ''}${nodeProblem(node) ? ' tp-node-problem' : ''}" data-node="${esc(node.id)}" transform="translate(${p.x} ${p.y})">
|
||||
${open}<title>${esc(label)}</title>
|
||||
${d.svg}
|
||||
${ring}
|
||||
@@ -740,7 +813,6 @@ function applyView() {
|
||||
function applyFilterClasses() {
|
||||
const canvas = document.getElementById('tp-canvas');
|
||||
canvas.classList.toggle('tp-no-traffic', !state.filters.traffic);
|
||||
canvas.classList.toggle('tp-only-10g', state.filters.tenG);
|
||||
canvas.classList.toggle('tp-alerts-only', state.filters.alerts);
|
||||
document.querySelectorAll('[data-filter]').forEach(el => {
|
||||
if (el.tagName === 'SELECT') el.value = state.filters[el.dataset.filter];
|
||||
@@ -1041,6 +1113,12 @@ function initEvents() {
|
||||
svg.addEventListener('mouseleave', () => highlight(null));
|
||||
svg.addEventListener('focusout', e => { if (!svg.contains(e.relatedTarget)) highlight(null); });
|
||||
|
||||
document.getElementById('tp-summary').addEventListener('click', e => {
|
||||
const chip = e.target.closest('.tp-problem-chip');
|
||||
if (!chip) return;
|
||||
if (chip.dataset.focusNode) focusOn('node', chip.dataset.focusNode);
|
||||
else focusOn('link', chip.dataset.focusLink);
|
||||
});
|
||||
document.getElementById('tp-panel').addEventListener('click', e => {
|
||||
if (e.target.closest('[data-action="close-panel"]')) hidePanel();
|
||||
});
|
||||
@@ -1050,7 +1128,7 @@ function initEvents() {
|
||||
state.filters[el.dataset.filter] = el.tagName === 'SELECT' ? el.value : el.checked;
|
||||
saveFilters();
|
||||
if (el.dataset.filter === 'stubs') state.expanded.clear();
|
||||
if (el.dataset.filter === 'stubs' || el.dataset.filter === 'power') render();
|
||||
if (['stubs', 'power', 'mgmt'].includes(el.dataset.filter)) render();
|
||||
else applyFilterClasses();
|
||||
});
|
||||
});
|
||||
@@ -1067,6 +1145,51 @@ function initEvents() {
|
||||
});
|
||||
}
|
||||
|
||||
// ── Problem summary ─────────────────────────────────────────────────────
|
||||
function renderSummary() {
|
||||
const box = document.getElementById('tp-summary');
|
||||
const problems = problemsOf(state.data);
|
||||
const devices = state.data.nodes.filter(n => !['stub', 'internet'].includes(n.kind)).length;
|
||||
const cables = state.data.links.length;
|
||||
if (!problems.length) {
|
||||
box.className = 'tp-summary tp-summary-ok';
|
||||
box.innerHTML = `<span class="tp-summary-head">✓ All clear</span><span class="tp-summary-text">${devices} devices and ${cables} cables, nothing down or mislabelled</span>`;
|
||||
return;
|
||||
}
|
||||
box.className = `tp-summary ${problems.some(p => p.severity === 'bad') ? 'tp-summary-bad' : 'tp-summary-warn'}`;
|
||||
box.innerHTML = `<span class="tp-summary-head">⚠ ${problems.length} to look at</span>
|
||||
${problems.map(p => `<button type="button" class="tp-problem-chip tp-chip-${p.severity}" data-focus-${p.kind}="${esc(p.id)}">${esc(p.text)}</button>`).join('')}`;
|
||||
}
|
||||
|
||||
// Centre the view on a device or cable and show what is wrong with it
|
||||
function focusOn(kind, id) {
|
||||
const lay = state.layout;
|
||||
const canvas = document.getElementById('tp-canvas');
|
||||
let x, y;
|
||||
if (kind === 'node') {
|
||||
const p = lay.pos[id];
|
||||
if (!p) return;
|
||||
x = p.x + p.w / 2;
|
||||
y = p.y + lay.drawings[id].h / 2;
|
||||
} else {
|
||||
const r = lay.routes[id];
|
||||
if (!r) return;
|
||||
x = r.mid.x;
|
||||
y = r.mid.y;
|
||||
}
|
||||
const k = Math.max(state.view.k, 0.55);
|
||||
state.view = { k, tx: canvas.clientWidth / 2 - x * k, ty: canvas.clientHeight / 2 - y * k };
|
||||
applyView();
|
||||
if (kind === 'link') {
|
||||
const link = state.data.links.find(l => l.id === id);
|
||||
if (link) showPanel(link);
|
||||
} else {
|
||||
hidePanel();
|
||||
highlight(id);
|
||||
}
|
||||
canvas.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
|
||||
}
|
||||
|
||||
// ── Data ────────────────────────────────────────────────────────────────
|
||||
async function refresh() {
|
||||
try {
|
||||
@@ -1075,6 +1198,7 @@ async function refresh() {
|
||||
// The layout follows from the devices and cables alone, so a redraw with the same ones keeps
|
||||
// everything where it was; the view (pan and zoom) is kept either way
|
||||
render();
|
||||
renderSummary();
|
||||
const warn = document.getElementById('tp-warnings');
|
||||
warn.hidden = !(data.warnings && data.warnings.length);
|
||||
warn.innerHTML = (data.warnings || []).map(w => `<div>${esc(w)}</div>`).join('');
|
||||
|
||||
@@ -19,7 +19,7 @@
|
||||
</select></label>
|
||||
<label class="tp-filter"><input type="checkbox" data-filter="power"> Power gear</label>
|
||||
<label class="tp-filter"><input type="checkbox" data-filter="traffic"> Traffic</label>
|
||||
<label class="tp-filter"><input type="checkbox" data-filter="tenG"> 10G only</label>
|
||||
<label class="tp-filter" title="Each server's slower second cable. A problem cable is always shown."><input type="checkbox" data-filter="mgmt"> Server 1G links</label>
|
||||
<label class="tp-filter"><input type="checkbox" data-filter="alerts"> Alerts only</label>
|
||||
</div>
|
||||
<div class="tp-zoom" role="group" aria-label="Zoom">
|
||||
@@ -31,6 +31,7 @@
|
||||
</div>
|
||||
|
||||
<div class="tp-warnings" id="tp-warnings" role="status" hidden></div>
|
||||
<div class="tp-summary" id="tp-summary" aria-live="polite"></div>
|
||||
|
||||
<div class="tp-canvas" id="tp-canvas" tabindex="0" aria-label="Network topology diagram. Use plus, minus and zero to zoom, and Tab to move between devices and cables.">
|
||||
<svg class="tp-svg" id="tp-svg" xmlns="http://www.w3.org/2000/svg" role="group" aria-label="Network topology">
|
||||
|
||||
Reference in New Issue
Block a user