collabSupport/public/av-store-dashboard.html
jmcqueen 351f89a9a4 Initial commit: CollabFinder Webex bot
Multi-integration Webex chat/HTTP bot that unifies phone, AV, and
network status for retail store support. Consolidates data from
Webex Calling, Meraki, Workspace ONE (MDM), Atlas AMP, RED digital
signage, and OptiSigns into rich per-store status commands.

Key surfaces:
- /phonestatus, /avstatus — per-store phone & AV device reports with
  clickable Meraki deep-links and per-port detail.
- /webexhost — check/assign Webex Meetings host licenses via the
  Service App; adaptive-card confirmation flow, HTTP-API-gated.
- /offboarduser — full Webex Admin offboarding (auth revoke, device
  wipe, license removal); adaptive-card confirmation.
- /jirapoll — on-demand trigger for the hourly Jira poller.
- /bulkavstatuscsv — bulk store CSV export with concurrency limits.

Automation:
- Hourly Jira poller (node-cron) with an X.AI (Grok) ticket classifier
  that categorizes unassigned tickets as phone/av/skip, extracts store
  numbers from free-text, and enriches Jira with the same detailed
  markdown the chat commands emit (converted to Jira ADF, preserves
  bold + Meraki links). Idempotent via a `bot-enriched` Jira label.

Architecture:
- Node.js 20+, ESM, Express 5, webex-node-bot-framework.
- Layered integrations (integrations/*), services (services/*),
  commands (commands/*), utils (utils/*).
- Shared markdown renderers (services/renderers/*) feed both chat
  handlers and the Jira poller so the two surfaces stay in sync.
- Hand-rolled markdown-to-ADF converter (utils/markdownToAdf.js) —
  no new npm dependency.
- Node built-in test runner (`node --test tests/*.test.js`), 30 tests
  covering the converter, renderers, and poller ADF assembly.

Docker + docker-compose deployment. Config via .env
(see .env.example for the full option surface).
2026-07-01 16:55:03 -04:00

913 lines
No EOL
48 KiB
HTML

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>AV Store Dashboard</title>
<link rel="icon" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'%3E%3Ctext y='0.9em' font-size='90'%3E%F0%9F%93%A1%3C/text%3E%3C/svg%3E">
<script src="https://cdn.tailwindcss.com"></script>
<!-- NOTE: Tailwind via CDN is for rapid dev (see browser warning). For production use PostCSS/CLI per https://tailwindcss.com/docs/installation -->
<!-- Mermaid for dynamic topology diagrams (loaded from the rich /av/devices/build response) -->
<script src="https://cdn.jsdelivr.net/npm/mermaid@10/dist/mermaid.min.js"></script>
<style>
.section-card {
background-color: #1f2937;
border-radius: 16px;
padding: 24px;
border: 1px solid #374151;
}
/* .device-card styles removed (cards replaced by topology nodes + modals on click) */
/* Status coloring for Mermaid topology nodes (applied post-render to g.node) */
g.node.offline rect,
g.node.offline polygon,
g.node.offline ellipse,
g.node.offline .nodeLabel,
g.node.offline foreignObject {
fill: #450a0a !important;
stroke: #ef4444 !important;
stroke-width: 3px !important;
}
g.node.problem rect,
g.node.problem polygon,
g.node.problem ellipse,
g.node.problem .nodeLabel,
g.node.problem foreignObject {
fill: #431407 !important;
stroke: #f59e0b !important;
stroke-width: 3px !important;
}
/* Keep AV leaf base green but allow offline/problem overrides above */
g.node.av rect, g.node.av polygon { fill: #052e16 !important; stroke: #4ade80 !important; }
</style>
</head>
<body class="bg-gray-950 text-gray-200 p-8">
<div class="max-w-7xl mx-auto">
<h1 class="text-4xl font-bold mb-2">AV Store Dashboard</h1>
<p class="text-gray-400 mb-8">Enter a store number to load the AV topology (Meraki linkLayer infra pruned to only nodes with AV attachments + AV device nodes as connected leaves). Click any node (AV or infra switch/AP) in the diagram for details. Node colors: <span class="text-red-400">red = offline</span>, <span class="text-amber-400">orange = problem/alerting</span>. No separate card list at top — everything is in the interactive topology.</p>
<!-- Store Input -->
<div class="max-w-md mb-12">
<div class="flex gap-3">
<input id="storeInput"
type="text"
placeholder="Enter store number (e.g. 2477)"
class="flex-1 bg-gray-900 border border-gray-700 rounded-2xl px-6 py-4 text-lg focus:outline-none focus:border-emerald-500">
<button onclick="loadStore()"
class="bg-emerald-600 hover:bg-emerald-500 px-10 py-4 rounded-2xl font-semibold text-lg transition">
Load Store
</button>
</div>
</div>
<!-- Summary / legend area (compact, since cards removed; main view is now the topology below) -->
<div id="loadSummary" class="mb-4 text-sm text-gray-400"></div>
<!-- Topology Section (primary view): AV nodes are leaves; click for modals. Top card grid removed. -->
<div id="topologySection" class="mt-4 hidden">
<div class="flex items-center justify-between mb-4">
<h2 id="topologyTitle" class="text-2xl font-semibold text-emerald-400">AV Topology (Meraki linkLayer + AV nodes)</h2>
<span class="text-xs text-gray-500">Click any node (infra or AV) for modal • red=offline, orange=problem • infra pruned to relevant only</span>
</div>
<div class="section-card">
<div id="mermaid-topology" class="mermaid w-full overflow-x-auto bg-gray-950 p-4 rounded-2xl min-h-[200px]"></div>
</div>
<p class="text-[10px] text-gray-500 mt-2">Topology is now the main view (top cards removed). AV nodes appear as leaves in the diagram; click them to open rich modals with full details. Only switches/APs with attached AVs are shown.</p>
</div>
</div>
<script type="module">
import { showDeviceModal } from './templates/av-device-modal.js';
// One-time mermaid init (for topology diagrams derived from build response)
if (typeof mermaid !== 'undefined') {
mermaid.initialize({
startOnLoad: false,
theme: 'dark',
securityLevel: 'loose',
flowchart: { useMaxWidth: true, htmlLabels: true, curve: 'basis' }
});
}
window.loadStore = async function() {
const storeNum = document.getElementById('storeInput').value.trim();
if (!storeNum) {
alert("Please enter a store number");
return;
}
const summaryEl = document.getElementById('loadSummary');
if (summaryEl) summaryEl.innerHTML = `<span class="text-gray-400">Loading store ${storeNum}...</span>`;
// Support subpath deployments (e.g. /CollabSupport/ behind NGINX reverse proxy).
// The static HTML may be served as /CollabSupport/av-store-dashboard.html.
// In that case, the data fetch must use the same prefix so the proxy routes it
// to the backend's /av/devices/build/... route. On bare paths (localhost) we use
// the root-relative path.
let apiPrefix = '';
const path = window.location.pathname || '';
const collabMatch = path.match(/^(.*\/CollabSupport)/i);
if (collabMatch) {
apiPrefix = collabMatch[1];
}
try {
const apiUrl = `${apiPrefix}/av/devices/build/${storeNum}`;
const response = await fetch(apiUrl);
if (!response.ok) throw new Error(`HTTP ${response.status} (tried ${apiUrl})`);
const data = await response.json();
const devices = data.devices || [];
// Compute set of infra serials (switches/APs/stacks) that have at least one AV device attached,
// based on the enriched Meraki client data (recentDeviceSerial, switchSerial, apSerial).
// This is used both for an accurate title and (inside the viz) to prune unrelated infra nodes.
const relevantInfraSerials = new Set();
(devices || []).forEach(dev => {
const c = dev.meraki?.client || dev.meraki || {};
if (c.recentDeviceSerial) relevantInfraSerials.add(String(c.recentDeviceSerial));
if (c.switchSerial) relevantInfraSerials.add(String(c.switchSerial));
if (c.apSerial) relevantInfraSerials.add(String(c.apSerial));
});
if (devices.length === 0) {
if (summaryEl) summaryEl.innerHTML = `<span class="text-amber-400">No devices found for store ${storeNum}.</span>`;
// Still attempt to show any topology if present (unlikely)
} else {
if (summaryEl) {
const avCount = devices.length;
summaryEl.innerHTML = `Store ${storeNum}: <strong>${avCount}</strong> AV device${avCount===1?'':'s'} • Click <strong>any node</strong> (AV leaves or infra switches/APs) in the diagram. <span class="text-red-400">Red nodes = offline</span>, <span class="text-amber-400">orange = problem</span>.`;
}
}
// Topology: now the primary (and only) interactive view.
// AV nodes are leaves inside the Mermaid diagram; clicks on them (and matching) open the rich modals.
// Infra pruned to only switches/APs with AV attachments. The previous top card grid has been removed.
const topology = data.merakiTopology || { nodes: [], links: [] };
const topoSection = document.getElementById('topologySection');
const titleEl = document.getElementById('topologyTitle');
// Expose full response early so click handlers (infra fallbacks etc.) always see it
window.currentAvStoreData = data;
window.currentTopology = topology;
// Helpful per-load samples for the exact store 2477 (or any containing 02477 / VW02) so we can see what
// status signals are present for devices that should be red (offline) vs the Meraki client vs RED/Atlas.
// Now also logs MDM signals (primary for VW players) since some have no Meraki client at all.
(devices || []).forEach(d => {
if (/02477|VW02/i.test(String(d.identifier || d.name || ''))) {
const c = d.meraki?.client || d.meraki || {};
const redConn = d.red && (d.red.Connectivity || d.red.connectivity);
const atlas0 = Array.isArray(d.atlasData) ? d.atlasData[0] : (d.atlasData || d.atlas);
const astat = atlas0 && (atlas0.status || (atlas0.state && atlas0.state.status));
const mdm = d.mdmData || d.mdm || {};
const mdmLast = mdm.LastSeen || mdm.lastSeen || mdm.LastSystemSampleTime || (mdm.mdmDataSummary && mdm.mdmDataSummary.lastSeen) || d.lastSeen || '';
const enroll = mdm.EnrollmentStatus || mdm.enrollmentStatus || mdm.Enrollment || '';
console.log('%c[av-store-dashboard] 2477-device signal sample:', 'color:#eab308', d.identifier,
'merakiClientStatus=', c.status, 'merakiLastSeen=', c.lastSeen, 'hasMerakiClient=', !!d.meraki?.client,
'redConnectivity=', redConn,
'atlasStatus=', astat,
'mdmLastSeen=', mdmLast, 'mdmEnrollment=', enroll,
'topLastSeen=', d.lastSeen);
}
});
// Debug: always log what we got so we can see if real Meraki topology data is present
console.log('%c[av-store-dashboard] Full build response keys:', 'color: #10b981', Object.keys(data || {}));
if (topology && (topology.nodes || topology.links)) {
console.log('%c[av-store-dashboard] merakiTopology received:', 'color: #10b981', {
nodesCount: topology.nodes?.length || 0,
linksCount: topology.links?.length || 0,
hasErrors: !!(topology.errors && topology.errors.length),
firstNode: topology.nodes?.[0],
firstLink: topology.links?.[0]
});
// Enhanced dump for exact structure diagnosis (Meraki linkLayer uses "ends" on links, "device"/"derivedId" on nodes)
if (topology.nodes?.[0]) {
console.log('%c[av-store-dashboard] firstNode (full):', 'color:#10b981', topology.nodes[0]);
try { console.dir(topology.nodes[0], { depth: 2 }); } catch (_) {}
}
if (topology.links?.[0]) {
console.log('%c[av-store-dashboard] firstLink (full):', 'color:#10b981', topology.links[0]);
try { console.dir(topology.links[0], { depth: 2 }); } catch (_) {}
}
} else {
console.warn('%c[av-store-dashboard] NO merakiTopology (or falsy) in response from /av/devices/build', 'color: orange');
}
if (topoSection) {
// reset note to default
const note = topoSection.querySelector('p');
if (note) note.textContent = 'Meraki linkLayer (pruned to relevant infra only) + AV leaves. Click ANY node for modal (switch/AP get infra modal; AV get full details). Red fill=offline, orange=problem/alerting state (computed from Meraki status + source health).';
let usedDerived = false;
if (topology.nodes && topology.nodes.length > 0) {
// Real Meraki linkLayer (pruned) + AV device nodes as leaves in the diagram (top cards removed).
// Modals are now opened by clicking the AV nodes inside the topology.
// Only infra nodes that actually have AV attachments (per relevantInfraSerials) are shown.
const avCount = devices.length;
const origInfra = topology.nodes.length;
let displayInfra = origInfra;
let infraDesc = 'infra';
if (relevantInfraSerials.size > 0) {
const matching = topology.nodes.filter(n => {
const ser = ((n.device && n.device.serial) || '').toString();
return relevantInfraSerials.has(ser);
});
displayInfra = matching.length;
infraDesc = (displayInfra < origInfra) ? 'relevant infra (only those with AVs)' : 'infra';
}
if (titleEl) titleEl.textContent = `AV Topology (Meraki linkLayer ${infraDesc} + AV attachments • ${displayInfra} infra, ${topology.links?.length || 0} links, ${avCount} AV devices)`;
topoSection.classList.remove('hidden');
renderTopologyMermaid(topology, devices);
} else if (devices.length > 0) {
// Derive a focused AV topology from the already-enriched device meraki attachments (fallback only)
const derived = deriveSimpleAvTopology(devices);
if (derived && derived.nodes.length > 0) {
usedDerived = true;
if (titleEl) titleEl.textContent = `AV Topology (derived from device connections • ${derived.nodes.length} nodes) — red=offline, orange=problem`;
if (note) note.textContent = 'Derived client-side from enriched AV device Meraki connection info (recentDeviceSerial/port/ssid etc). The Meraki linkLayer returned no nodes.';
window.currentTopology = derived; // so clicks + status apply can resolve tNode parents uniformly
topoSection.classList.remove('hidden');
renderTopologyMermaid(derived, devices);
} else {
topoSection.classList.add('hidden');
}
} else {
topoSection.classList.add('hidden');
}
}
} catch (err) {
if (summaryEl) summaryEl.innerHTML = `<span class="text-red-400">Error loading store ${storeNum}: ${err.message}</span>`;
const topoSection = document.getElementById('topologySection');
if (topoSection) topoSection.classList.add('hidden');
console.error('Dashboard load failed:', err);
}
};
// Optional: Auto-load store 2477 when the page opens
window.onload = () => {
document.getElementById('storeInput').value = "2477";
// loadStore(); // Uncomment if you want it to load automatically on page load
};
// ====================== TOPOLOGY RENDERING (for av-store-dashboard) ======================
// Uses the merakiTopology (linkLayer) already fetched+returned by the rich build path in avEnrichmentCore.
// Infra nodes are pruned client-side to *only* switches/APs that have at least one AV attached
// (using the same meraki.client recent* data). The AV devices are rendered as leaf nodes inside the
// diagram (the previous top cards have been removed). Clicking AV nodes in the topology opens the
// rich modals. Fallback derive path still supported when no real linkLayer.
// Modals are now exclusively attached via clicks on topology nodes.
function buildMermaidFromTopology(topology, avDevices = []) {
let nodes = (topology.nodes || []).slice(); // copy so we can prune
const links = topology.links || [];
if (nodes.length === 0) {
return 'flowchart LR\n empty["No topology nodes returned"]';
}
// Use LR layout so that AV device nodes extend visually to the *right* of their parent switch or AP.
let def = 'flowchart LR\n';
// === Prune to relevant infra only ===
// Remove switches, APs, stacks, etc. that have *no* AV devices connected (per the enriched client attachments
// already present in the build response). This keeps the diagram focused on the actual AV topology.
// We only prune when the incoming nodes look like real linkLayer data (not the synthetic derive fallback).
const firstForPrune = nodes[0] || {};
const looksLikeRealForPrune = !!(firstForPrune.derivedId || firstForPrune.mac || firstForPrune.type || (firstForPrune.device && (firstForPrune.device.serial || firstForPrune.device.name)));
const relevantSerials = new Set();
(avDevices || []).forEach(dev => {
const c = dev.meraki?.client || dev.meraki || {};
if (c.recentDeviceSerial) relevantSerials.add(String(c.recentDeviceSerial));
if (c.switchSerial) relevantSerials.add(String(c.switchSerial));
if (c.apSerial) relevantSerials.add(String(c.apSerial));
});
if (looksLikeRealForPrune && relevantSerials.size > 0) {
const before = nodes.length;
nodes = nodes.filter(node => {
const d = node.device || {};
const ser = (d.serial || '').toString();
// Only keep infra nodes whose serial is a parent for >=1 of our AV devices.
return ser && relevantSerials.has(ser);
});
if (nodes.length !== before) {
console.log('%c[av-store-dashboard] Pruned topology to relevant infra only:', 'color:#10b981', `${nodes.length}/${before} nodes (dropped switches/APs with no AV attachments)`);
}
}
if (nodes.length === 0) {
return 'flowchart LR\n empty["No relevant infra nodes with AV connections"]';
}
// Real Meraki linkLayer structure (from /topology/linkLayer):
// nodes[]: { derivedId, mac, type:"device"|"discovered"|"stack"|"unknown", root, device:{serial,name,model,...}, discovered:{lldp,cdp}, stack }
// links[]: { ends: [ {node:{derivedId,type}, device:{serial,name}, discovered:{lldp:{portId,portDescription}, cdp:{portId}} }, {same for end 2} ], lastReportedAt }
// No source/target; links use ends[]. Keys for resolution: derivedId, device.serial, mac.
//
// When this is a *real* linkLayer (not the synthetic derive), we will augment after the infra links
// by adding the AV devices from avDevices as additional leaf nodes + edges using their
// meraki.client recentDeviceSerial/switchSerial/apSerial + port info. This wires the AV nodes
// directly into the (pruned) topology pointing at the correct real infra parents. (Modals via clicks.)
const keyToId = {};
const serialToId = {};
const macToId = {};
const indexToId = {};
// Sanitize text for use inside Mermaid ["label"] or |"label"| to avoid quote/bracket/newline breakage.
// We intentionally keep a few safe HTML tags (<br/>, <small>) for formatting.
function sanitizeForMermaid(str) {
return String(str || '')
.replace(/"/g, "'")
.replace(/[|\[\]]/g, "'") // | would break edge |"label"| and [] can confuse node ids/labels
.replace(/[\r\n\t]/g, ' ')
.replace(/[\u0000-\u001F\u007F]/g, '');
}
nodes.forEach((node, idx) => {
const d = node.device || {};
const disc = node.discovered || {};
const stk = node.stack || {};
const serial = (d.serial || '').toString();
const mac = (node.mac || d.mac || '').toString();
const derived = (node.derivedId || mac || serial || `n${idx}`).toString();
// Stable, unique, safe id (index prefix avoids collisions)
const base = (serial || derived || mac || `node${idx}`).replace(/[^A-Za-z0-9_]/g, '_').slice(0, 24);
const safeId = `n${idx}_${base}`;
indexToId[idx] = safeId;
if (derived) keyToId[derived] = safeId;
if (serial) serialToId[serial] = safeId;
if (mac) macToId[mac] = safeId;
if (node.derivedId) keyToId[node.derivedId] = safeId;
const rawName = (d.name || disc.lldp?.systemName || stk.name || mac || serial || `node${idx}`).toString();
const rawModel = (d.model || node.type || '').toString();
const rawProd = (d.productType || '').toString();
const name = sanitizeForMermaid(rawName);
const model = sanitizeForMermaid(rawModel);
const prod = sanitizeForMermaid(rawProd);
// Count AV devices that report this as their recent connection (enriched from our /build devices)
// Use raw values for matching so sanitization (e.g. " -> ') doesn't affect counts.
const connected = (avDevices || []).filter(dev => {
const c = dev.meraki?.client || dev.meraki || {};
return (serial && (c.recentDeviceSerial === serial || c.switchSerial === serial || c.apSerial === serial)) ||
(rawName && (c.recentDeviceName === rawName || c.apName === rawName));
});
const extra = connected.length ? ` <small>(${connected.length} AV)</small>` : '';
const rawLabel = `${name}<br/><small>${model || prod || rawModel || ''}${serial ? ' · ' + serial : ''}</small>${extra}`;
const label = sanitizeForMermaid(rawLabel); // final pass (keeps our <br/><small> because we don't strip < > here)
const cls = node.type === 'stack' ? 'stack' :
/MS|switch/i.test(model || prod || name) ? 'switch' :
/MR|AP|wireless|camera/i.test(model || prod || name) ? 'ap' : 'device';
def += ` ${safeId}["${label}"]:::${cls}\n`;
});
// Links: resolve via ends[0]/ends[1] using derivedId or device.serial (primary real Meraki shape)
// Also support legacy/synthetic source/target (for deriveSimpleAvTopology fallback path)
links.forEach((link, i) => {
const ends = Array.isArray(link.ends) ? link.ends : [];
let srcId = null;
let tgtId = null;
let portLabel = '';
if (ends.length >= 2) {
const resolveEnd = (e) => {
if (!e) return null;
const nd = e.node || {};
const dv = e.device || {};
const cand = nd.derivedId || dv.serial || (e.discovered && e.discovered.lldp && e.discovered.lldp.chassisId) || '';
if (cand && keyToId[cand]) return keyToId[cand];
if (dv.serial && serialToId[dv.serial]) return serialToId[dv.serial];
if (nd.derivedId && keyToId[nd.derivedId]) return keyToId[nd.derivedId];
return null;
};
srcId = resolveEnd(ends[0]);
tgtId = resolveEnd(ends[1]);
// numeric index fallbacks (if Meraki ever returns indices too) or raw derived
if (!srcId) {
const s0 = (ends[0] && ends[0].node && ends[0].node.derivedId) || '';
const n0 = Number(s0);
if (!isNaN(n0) && indexToId[n0] != null) srcId = indexToId[n0];
}
if (!tgtId) {
const s1 = (ends[1] && ends[1].node && ends[1].node.derivedId) || '';
const n1 = Number(s1);
if (!isNaN(n1) && indexToId[n1] != null) tgtId = indexToId[n1];
}
// Port label from either end's LLDP/CDP info (real shape)
const getPort = (e) => {
const disc = e && e.discovered || {};
return disc.lldp?.portId || disc.cdp?.portId || disc.lldp?.portDescription || disc.cdp?.nativeVlan || '';
};
const p0 = getPort(ends[0]);
const p1 = getPort(ends[1]);
if (p0 || p1) {
portLabel = (p0 && p1 && p0 !== p1) ? `${p0}${p1}` : (p0 || p1 || 'link');
}
} else {
// Fallback for synthetic derive links: { source: idOrSerial, target: idOrSerial, port: {...} }
let srcRaw = (link.source ?? link.from ?? link.src ?? '').toString();
let tgtRaw = (link.target ?? link.to ?? link.dst ?? '').toString();
const srcNum = Number(srcRaw);
const tgtNum = Number(tgtRaw);
if (!isNaN(srcNum) && indexToId[srcNum] != null) srcId = indexToId[srcNum];
else srcId = keyToId[srcRaw] || serialToId[srcRaw] || macToId[srcRaw] || (srcRaw ? srcRaw.replace(/[^A-Za-z0-9_]/g, '_') : null);
if (!isNaN(tgtNum) && indexToId[tgtNum] != null) tgtId = indexToId[tgtNum];
else tgtId = keyToId[tgtRaw] || serialToId[tgtRaw] || macToId[tgtRaw] || (tgtRaw ? tgtRaw.replace(/[^A-Za-z0-9_]/g, '_') : null);
const p = link.port || link.ports || {};
const sport = p.sourcePort || p.port || '';
if (sport) portLabel = sport;
}
if (!srcId) srcId = `s${i}`;
if (!tgtId) tgtId = `t${i}`;
// Guard: only emit the link if *both* ends resolved to real kept infra nodes (from the pruned set).
// This drops any original linkLayer edges that touched a now-removed unrelated switch/AP.
const isRealSrc = srcId && !String(srcId).startsWith('s');
const isRealTgt = tgtId && !String(tgtId).startsWith('t');
if (isRealSrc && isRealTgt) {
const safePort = sanitizeForMermaid(portLabel);
const edge = safePort ? ` -->|"${safePort}"| ` : ' --> ';
def += ` ${srcId}${edge}${tgtId}\n`;
}
// (AV attachment edges are added later in augmentation and only target kept parents anyway.)
});
// === Augment (pruned) real linkLayer with AV device nodes ===
// (The former top card grid has been removed; these AV leaves in the diagram are now the
// primary way to see/click the devices.)
// Only for authentic Meraki linkLayer input (synthetic derive already embeds its AVs).
// We add each AV as a leaf node explicitly linked to its *correct* parent switch/AP
// (resolved via recentDeviceSerial / switchSerial / apSerial against the serialToId map built
// only from the *relevant* infra nodes that survived pruning above).
// Clicking these AV nodes (see attachMermaidNodeClicks) opens the rich modals.
const firstNode = nodes[0] || {};
const isRealLinkLayer = !!(firstNode.derivedId || firstNode.mac || firstNode.type || (firstNode.device && (firstNode.device.serial || firstNode.device.name)));
const avList = avDevices || [];
if (isRealLinkLayer && avList.length > 0) {
let attached = 0;
avList.forEach((dev, i) => {
const c = dev.meraki?.client || dev.meraki || {};
const parentSerial = (c.recentDeviceSerial || c.switchSerial || c.apSerial || '').toString().trim();
const port = (c.switchport || c.portNumber || c.port || '').toString().trim();
const conn = (dev.meraki?.connectionType || c.recentDeviceConnection || 'Wired').toString();
const isWireless = conn.toLowerCase().includes('wireless');
const ip = c.ip || dev.ip || '—';
const avName = sanitizeForMermaid(dev.identifier || `AV-${i}`);
const avSource = sanitizeForMermaid(dev.source || 'AV');
const safeBase = (dev.identifier || `av${i}`).replace(/[^A-Za-z0-9_]/g, '_').slice(0, 24);
const avSafe = `av${i}_${safeBase}`;
let avLabel = `${avName}<br/><small>${ip}${avSource} ${isWireless ? '📶' : '🔌'}</small>`;
if (!parentSerial) {
avLabel = `${avName} ⚠<br/><small>no recent Meraki parent</small>`;
}
avLabel = sanitizeForMermaid(avLabel);
def += ` ${avSafe}["${avLabel}"]:::av\n`;
if (parentSerial) {
let parentId = serialToId[parentSerial] || keyToId[parentSerial] || macToId[parentSerial];
if (parentId) {
const edgeLabel = sanitizeForMermaid(port || (isWireless ? 'wireless' : 'wired'));
def += ` ${parentId} -->|"${edgeLabel}"| ${avSafe}\n`;
attached++;
} else {
// Parent serial from AV enrichment not present in this linkLayer snapshot (rare with relevant prefilter).
// We still show the AV node (with ⚠ already in label) but do not emit invalid edge syntax.
// Just leave it as an isolated node in the diagram so Mermaid stays valid.
}
}
// Note: never emit dangling `node -.- "text"` or similar — that is invalid Mermaid and causes syntax errors.
});
// Helpful log so we can see in console how many AVs got wired into the diagram
console.log('%c[av-store-dashboard] Augmented real topology with AV leaves:', 'color:#10b981', `${attached}/${avList.length} AV devices attached to infra nodes`);
}
// Styles matching our UI (added stack + device + av for the AV leaf nodes in diagram)
def += `
classDef switch fill:#1f2937,stroke:#4ade80,stroke-width:3px,color:#fff,rx:10,ry:10
classDef ap fill:#312e81,stroke:#6366f1,stroke-width:3px,color:#fff,rx:10,ry:10
classDef stack fill:#334155,stroke:#64748b,stroke-width:2px,color:#fff,rx:8,ry:8
classDef device fill:#064e3b,stroke:#10b981,stroke-width:2px,color:#fff,rx:8,ry:8
classDef av fill:#052e16,stroke:#4ade80,stroke-width:2px,color:#a3e635,rx:6,ry:6
`;
return def;
}
async function renderTopologyMermaid(topology, avDevices = []) {
const container = document.getElementById('mermaid-topology');
if (!container) return;
const def = buildMermaidFromTopology(topology, avDevices);
window.lastMermaidDef = def; // always expose for easy debug: copy(window.lastMermaidDef) or console.log it
container.innerHTML = def;
try {
// Re-render safe
container.removeAttribute('data-processed');
if (typeof mermaid !== 'undefined' && mermaid.run) {
await mermaid.run({ nodes: [container], suppressErrors: true });
}
// Wire clicks on rendered nodes to our modal where we can match an AV device
attachMermaidNodeClicks(container, avDevices);
} catch (e) {
console.warn('Mermaid topology render failed, falling back to source', e);
// Log the exact generated source so we can diagnose syntax issues (paste into https://mermaid.live)
console.error('[av-store-dashboard] Mermaid syntax error. Full generated diagram def (also in window.lastMermaidDef):\n' + def);
container.innerHTML = `<pre class="text-[10px] text-gray-400 overflow-auto max-h-80">${def.replace(/</g,'&lt;')}</pre>`;
}
}
function attachMermaidNodeClicks(container, avDevices) {
// After mermaid renders, nodes are svg g.node elements (or .nodeLabel etc.)
const svg = container.querySelector('svg');
if (!svg) return;
const topo = window.currentTopology || {};
const topoNodes = topo.nodes || [];
const storeData = window.currentAvStoreData || {};
const merakiDevs = (storeData.merakiDevices || storeData.merakiDeviceDetails || []);
const networkInfo = storeData.network || null;
// Apply dynamic status colors (offline=red, problem=orange) to rendered nodes
applyNodeStatusClasses(container, avDevices || [], topoNodes, merakiDevs);
// Try both .node and title-based (mermaid puts node id in <title>)
svg.querySelectorAll('g.node, .node').forEach(nodeEl => {
nodeEl.style.cursor = 'pointer';
nodeEl.addEventListener('click', (ev) => {
ev.stopPropagation();
// Robust extraction that works for classic Mermaid labels (tspans) and htmlLabels:true (foreignObject + our <br/><small> content).
let label = nodeEl.querySelector('title')?.textContent || '';
const fo = nodeEl.querySelector('foreignObject');
if (fo) {
const foText = (fo.textContent || fo.innerText || '').trim();
if (foText) label = foText;
}
if (!label) {
const tspans = nodeEl.querySelectorAll('tspan');
label = Array.from(tspans).map(t => t.textContent || '').join(' ').trim();
}
if (!label) {
label = (nodeEl.textContent || nodeEl.innerText || '').trim();
}
const group = nodeEl.closest('g[id]') || nodeEl;
const groupId = (group.id || '').trim();
if (!label && groupId) label = groupId;
const up = label.toUpperCase();
const upNorm = up.replace(/[^A-Z0-9]/g, '');
const nodeClasses = ((nodeEl.getAttribute && nodeEl.getAttribute('class')) || '') + ' ' +
(nodeEl.classList ? Array.from(nodeEl.classList).join(' ') : '');
const hasAvClass = /\bav\b/i.test(nodeClasses);
const groupLooksAv = /[-_]av|flowchart-av|^av/i.test(groupId.toLowerCase());
const labelLooksAv = /AV|MSC|VW|LED|AMP/i.test(label);
// Note: avoid treating infra nodes with "(N AV)" badge as AV just because of the word "AV"
const isAvNode = hasAvClass || groupLooksAv || (labelLooksAv && !/SW|MS|MR|switch|AP|stack/i.test(label));
const debugHit = /02477R|VW02|SW02477/i.test(label + ' ' + groupId);
if (debugHit) {
console.log('%c[av-store-dashboard] DEBUG 2477-node CLICK raw:', 'color:#f59e0b', { label, groupId, upNorm, hasAvClass, isAvNode });
}
console.debug('[av-store-dashboard] topology node click:', { label, groupId, upNorm });
// === INFRA FIRST (by serial/name match against topoNodes + merakiDevs) ===
// This is the robust path for switches like SW02477R. Their labels contain their own serial (e.g. SW02477R)
// plus the "(N AV)" badge. We must resolve them as infra *before* running any AV find that has
// "ser includes" logic (which would match attached AVs whose recentDeviceSerial appears in the label).
let tNode = null;
for (const n of topoNodes) {
const d = n.device || {};
const ser = (d.serial || '').toUpperCase();
const serNorm = ser.replace(/[^A-Z0-9]/g, '');
const nm = (d.name || '').toUpperCase();
const nmNorm = nm.replace(/[^A-Z0-9]/g, '');
const der = (n.derivedId || '').toUpperCase();
const derNorm = der.replace(/[^A-Z0-9]/g, '');
if (ser && (up.includes(ser) || upNorm.includes(serNorm))) { tNode = n; break; }
if (nm && (up.includes(nm) || upNorm.includes(nmNorm))) { tNode = n; break; }
if (der && (up.includes(der) || upNorm.includes(derNorm))) { tNode = n; break; }
}
let info = null;
if (!tNode) {
info = (merakiDevs || []).find(m => {
const mSer = (m.serial || '').toUpperCase();
const mSerNorm = mSer.replace(/[^A-Z0-9]/g, '');
const mNm = (m.name || '').toUpperCase();
const mNmNorm = mNm.replace(/[^A-Z0-9]/g, '');
return (mSer && (up.includes(mSer) || upNorm.includes(mSerNorm))) ||
(mNm && (up.includes(mNm) || upNorm.includes(mNmNorm)));
}) || null;
}
if (tNode || info) {
const serial = (tNode && (tNode.device && tNode.device.serial)) || (info && info.serial) || '';
const name = (tNode && (tNode.device && tNode.device.name)) || (info && info.name) || (serial || label);
const connectedAVs = (avDevices || []).filter(dev => {
const c = dev.meraki?.client || dev.meraki || {};
return (serial && (c.recentDeviceSerial === serial || c.switchSerial === serial || c.apSerial === serial)) ||
(name && (c.recentDeviceName === name || c.apName === name));
});
let deviceUrl = '';
if (networkInfo && networkInfo.url && serial) {
const baseMatch = networkInfo.url.match(/^(https?:\/\/[^/]+)/);
const base = baseMatch ? baseMatch[1] : 'https://dashboard.meraki.com';
const networkShort = networkInfo.url.match(/dashboard\.meraki\.com\/([^/]+)/i)?.[1] || '';
const dashboardNode = networkInfo.url.match(/\/n\/([^/]+)/i)?.[1] || '';
if (networkShort && dashboardNode) {
deviceUrl = `${base}/${networkShort}/n/${dashboardNode}/manage/nodes/${serial}/overview`;
}
}
const payload = {
isInfra: true,
tNode: tNode || null,
meraki: info || (tNode && tNode.device ? { client: null, device: tNode.device } : null),
connectedAVs,
deviceUrl,
status: (tNode && tNode.device && tNode.device.status) || (info && info.status) || null
};
const displayId = serial || name || label || 'infra';
showDeviceModal(displayId, 'switch', payload);
return;
}
// === AV leaf (only if no infra serial matched the label) ===
// Use id primarily; ser/nm (parent info) only as secondary and only for labels that look AV-ish.
const match = (avDevices || []).find(d => {
const id = (d.identifier || '').toUpperCase();
const idNorm = id.replace(/[^A-Z0-9]/g, '');
const c = d.meraki?.client || d.meraki || {};
const ser = (c.recentDeviceSerial || '').toString().toUpperCase().replace(/[^A-Z0-9]/g, '');
const nm = (c.recentDeviceName || '').toString().toUpperCase().replace(/[^A-Z0-9]/g, '');
const idMatch = id && (up.includes(id) || upNorm.includes(idNorm));
if (idMatch) return true;
const parentish = (ser && upNorm.includes(ser)) || (nm && upNorm.includes(nm));
if (parentish && /US\d|VW|MSC|AMP|LED/i.test(up)) return true;
return false;
});
if (match) {
const conn = match.meraki?.connectionType || (match.meraki?.client?.recentDeviceConnection === 'Wireless' ? 'Wireless' : 'Wired');
showDeviceModal(match.identifier, conn.toLowerCase(), match);
return;
}
// Unknown / last resort
const displayLabel = label || groupId || 'unknown';
alert('Topology node: ' + displayLabel + '\n(Click AV leaves for full details; infra nodes should resolve to switch modal.)');
});
});
}
function getStatusClass(item, isAV = false) {
if (!item) return '';
if (isAV) {
const m = item.meraki?.client || item.meraki || {};
let st = String(m.status || m.Status || m.connectionStatus || '').toLowerCase().trim();
if (!st && item.status) st = String(item.status).toLowerCase().trim();
// Robust recency derivation from Meraki client lastSeen when no explicit status (common for some records)
if (!st && m.lastSeen) {
const d = new Date(m.lastSeen);
if (!isNaN(d.getTime())) {
const ageMins = (Date.now() - d.getTime()) / 60000;
if (ageMins > 30) st = 'offline'; // treat >30min stale as offline for viz
else if (ageMins < 5) st = 'online';
}
}
if (st === 'offline' || st === 'off' || st === 'dormant' || st === 'disconnected') return 'offline';
// Explicit offline from RED (for devices with weak/no Meraki client, e.g. those showing the ⚠ no-parent)
if (item.red) {
const rc = String(item.red.Connectivity || item.red.connectivity || '').toLowerCase();
if (rc === 'offline' || rc === 'off' || rc.includes('offline')) return 'offline';
}
const atlas0 = Array.isArray(item.atlasData) ? item.atlasData[0] : (item.atlasData || item.atlas || null);
if (atlas0) {
const astat = String(atlas0.status || (atlas0.state && atlas0.state.status) || atlas0.AvailabilityStatus || '').toLowerCase();
if (astat === 'offline' || astat.includes('offline') || astat === 'off') return 'offline';
if (astat && !/online/i.test(astat)) return 'problem';
}
// RED non-online but not explicitly offline -> problem (app layer issue while connected)
if (item.red && item.red.Connectivity && !/online/i.test(item.red.Connectivity)) return 'problem';
if (st && !/online/i.test(st)) return 'problem';
if (!st) {
// MDM-based AV players (most VW/LED/MSC) that have no Meraki client (or no recent parent → ⚠ label)
// derive offline from stale MDM LastSeen. This is the main source of truth for "no network connection".
// Covers VW02 etc. that are completely offline from network perspective.
const mdm = item.mdmData || item.mdm || item;
let mdmLast = mdm.LastSeen || mdm.lastSeen || mdm.LastSystemSampleTime || '';
if (!mdmLast && mdm.mdmDataSummary) mdmLast = mdm.mdmDataSummary.lastSeen || '';
if (!mdmLast && item.lastSeen) mdmLast = item.lastSeen;
if (mdmLast) {
const d = new Date(mdmLast);
if (!isNaN(d.getTime())) {
const ageMins = (Date.now() - d.getTime()) / 60000;
if (ageMins > 30) return 'offline';
}
}
// Enrollment status as strong offline signal
const enroll = String(mdm.EnrollmentStatus || mdm.enrollmentStatus || mdm.Enrollment || '').toLowerCase();
if (enroll && (enroll.includes('unenroll') || enroll.includes('not') || enroll === 'false' || enroll.includes('pending'))) {
return 'offline';
}
}
return '';
}
// Infra / switch / AP / general Meraki device
const stRaw = (item.status || (item.device && item.device.status) || (item.meraki && item.meraki.status) || '').toString().toLowerCase();
if (stRaw === 'offline' || stRaw === 'off' || stRaw === 'dormant' || stRaw === 'disconnected') return 'offline';
if (stRaw === 'alerting' || stRaw.includes('alert') || stRaw === 'warning') return 'problem';
// Also treat plain non-online as problem for visibility (some Meraki report "dormant" etc.)
if (stRaw && !/online/i.test(stRaw)) return 'problem';
return '';
}
function applyNodeStatusClasses(container, avDevices, topoNodes, merakiDevs) {
const svg = container.querySelector('svg');
if (!svg) return;
const avList = avDevices || [];
const tNodes = topoNodes || [];
const mDevs = merakiDevs || [];
svg.querySelectorAll('g.node, .node').forEach(nodeEl => {
// Extract same way as clicks for lookup
let label = nodeEl.querySelector('title')?.textContent || '';
const fo = nodeEl.querySelector('foreignObject');
if (fo) {
const foText = (fo.textContent || fo.innerText || '').trim();
if (foText) label = foText;
}
if (!label) {
const tspans = nodeEl.querySelectorAll('tspan');
label = Array.from(tspans).map(t => t.textContent || '').join(' ').trim();
}
if (!label) label = (nodeEl.textContent || nodeEl.innerText || '').trim();
const group = nodeEl.closest('g[id]') || nodeEl;
const groupId = (group.id || '').trim();
if (!label && groupId) label = groupId;
const up = label.toUpperCase();
const upNorm = up.replace(/[^A-Z0-9]/g, '');
const nodeClasses = ((nodeEl.getAttribute && nodeEl.getAttribute('class')) || '') + ' ' +
(nodeEl.classList ? Array.from(nodeEl.classList).join(' ') : '');
const hasAvClass = /\bav\b/i.test(nodeClasses);
const groupLooksAv = /[-_]av|flowchart-av|^av/i.test(groupId.toLowerCase());
const labelLooksAv = /AV|MSC|VW|LED|AMP/i.test(label);
const isAvNode = hasAvClass || groupLooksAv || (labelLooksAv && !/SW|MS|MR|switch|AP|stack/i.test(label));
const debugHit = /02477R|VW02|SW02477/i.test(label + ' ' + groupId);
if (debugHit) {
console.log('%c[av-store-dashboard] DEBUG 2477-node STATUS apply raw:', 'color:#f59e0b', { label, groupId, upNorm, hasAvClass, isAvNode });
}
let statusCls = '';
// INFRA FIRST (same priority as click handler) using serial/name against topo + meraki list
let tNode = null;
for (const n of tNodes) {
const d = n.device || {};
const ser = (d.serial || '').toUpperCase();
const serNorm = ser.replace(/[^A-Z0-9]/g, '');
const nm = (d.name || '').toUpperCase();
const nmNorm = nm.replace(/[^A-Z0-9]/g, '');
const der = (n.derivedId || '').toUpperCase();
const derNorm = der.replace(/[^A-Z0-9]/g, '');
if (ser && (up.includes(ser) || upNorm.includes(serNorm))) { tNode = n; break; }
if (nm && (up.includes(nm) || upNorm.includes(nmNorm))) { tNode = n; break; }
if (der && (up.includes(der) || upNorm.includes(derNorm))) { tNode = n; break; }
}
let info = null;
if (!tNode) {
info = mDevs.find(m => {
const mSer = (m.serial || '').toUpperCase();
const mSerNorm = mSer.replace(/[^A-Z0-9]/g, '');
const mNm = (m.name || '').toUpperCase();
const mNmNorm = mNm.replace(/[^A-Z0-9]/g, '');
return (mSer && (up.includes(mSer) || upNorm.includes(mSerNorm))) ||
(mNm && (up.includes(mNm) || upNorm.includes(mNmNorm)));
}) || null;
}
if (tNode || info) {
const itemForStatus = tNode || info || {};
statusCls = getStatusClass(itemForStatus, false);
} else {
// AV leaf - stricter match (id first; parent ser/nm only when label looks like an AV device)
const match = avList.find(d => {
const id = (d.identifier || '').toUpperCase();
const idNorm = id.replace(/[^A-Z0-9]/g, '');
const c = d.meraki?.client || d.meraki || {};
const ser = (c.recentDeviceSerial || '').toString().toUpperCase().replace(/[^A-Z0-9]/g, '');
const nm = (c.recentDeviceName || '').toString().toUpperCase().replace(/[^A-Z0-9]/g, '');
const idMatch = id && (up.includes(id) || upNorm.includes(idNorm));
if (idMatch) return true;
const parentish = (ser && upNorm.includes(ser)) || (nm && upNorm.includes(nm));
if (parentish && /US\d|VW|MSC|AMP|LED/i.test(up)) return true;
return false;
});
statusCls = getStatusClass(match, true);
if (!statusCls) {
// Safety for AV leaves that have the "no recent Meraki parent" ⚠ (no network connection in this snapshot)
// but getStatusClass didn't return offline from MDM/RED/etc. (e.g. missing lastSeen fields).
// User expectation: such devices with no network presence should be red.
if (/⚠|no recent Meraki parent/i.test(label)) {
statusCls = 'offline';
}
}
}
if (statusCls) {
nodeEl.classList.add(statusCls);
// Directly force fill/stroke on the shape(s) Mermaid generated inside this node.
// This is more reliable than CSS overrides because Mermaid sets inline styles + classDefs on rects etc.
nodeEl.querySelectorAll('rect, polygon, ellipse, path, .node-rect').forEach(shape => {
if (statusCls === 'offline') {
shape.style.setProperty('fill', '#450a0a', 'important');
shape.style.setProperty('stroke', '#ef4444', 'important');
shape.style.setProperty('stroke-width', '3px', 'important');
} else if (statusCls === 'problem') {
shape.style.setProperty('fill', '#431407', 'important');
shape.style.setProperty('stroke', '#f59e0b', 'important');
shape.style.setProperty('stroke-width', '3px', 'important');
}
});
}
});
}
// Fallback "derive" a simple topology purely from the AV-enriched devices (their meraki.client recentDevice* + ports).
// Used only when the real Meraki linkLayer returned zero nodes. The main path (real nodes present)
// integrates the AV devices into the authentic (pruned) linkLayer graph instead of using these stubs.
// (Top cards removed; AVs are now only in the diagram.)
function deriveSimpleAvTopology(avDevices = []) {
const nodes = [];
const links = [];
const seenParents = new Map(); // serial/name -> safe id
(avDevices || []).forEach((dev, i) => {
const c = dev.meraki?.client || dev.meraki || {};
const conn = dev.meraki?.connectionType || c.recentDeviceConnection || 'Wired';
const parentSerial = c.recentDeviceSerial || c.switchSerial || c.apSerial || '';
const parentName = c.recentDeviceName || c.apName || (parentSerial ? `parent-${parentSerial.slice(-6)}` : 'unknown-parent');
const devId = (dev.identifier || `dev${i}`).replace(/[^A-Za-z0-9_]/g, '_');
// parent node (switch or AP)
let pId = 'unknown';
if (parentSerial || parentName) {
const key = parentSerial || parentName;
if (!seenParents.has(key)) {
const safe = (parentSerial || parentName).replace(/[^A-Za-z0-9_]/g, '_');
seenParents.set(key, safe);
// Use the 'safe' (same as pId in links) as serial so legacy source/target resolution in buildMermaid finds it
nodes.push({ device: { serial: safe, name: parentName, model: conn === 'Wireless' ? 'AP (derived)' : 'Switch (derived)', _origSerial: key } });
}
pId = seenParents.get(key);
}
// device node (use sanitized id as serial so links resolve)
nodes.push({ device: { serial: devId, name: dev.identifier, model: dev.source || 'AV' } });
// link
const port = c.switchport || c.portNumber || c.port || '';
const label = port ? `p${port}` : (conn === 'Wireless' ? 'wireless' : 'wired');
links.push({ source: pId, target: devId, port: { sourcePort: label } });
});
// de-dupe nodes by serial-ish
const uniqNodes = [];
const seen = new Set();
nodes.forEach(n => {
const s = (n.device?.serial || n.device?.name || '').toString();
if (!seen.has(s)) { seen.add(s); uniqNodes.push(n); }
});
return { nodes: uniqNodes, links };
}
</script>
</body>
</html>