diff --git a/.github/ISSUE_TEMPLATE/tool-request.md b/.github/ISSUE_TEMPLATE/tool-request.md new file mode 100644 index 0000000..21713a6 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/tool-request.md @@ -0,0 +1,22 @@ +--- +name: Tool request +about: Suggest a report or tool for the toolbox +title: "Tool request: " +labels: tool-request +--- + +## What should it report? + + + +## Level + + + +## Mist data it would need, if you know + + + +## Anything existing to start from? + + diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 0000000..8df756e --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,13 @@ +## What this adds or changes + + + +## Checklist + +- [ ] The file is `mist-toolbox/community/.js`, named after the tool's `id` +- [ ] I installed it with **Manage tools → Install** and ran it against a real org +- [ ] It only reads (GET) and calls Mist only through `ctx` +- [ ] Its header comment lists every Mist endpoint it reads +- [ ] It has a row in `mist-toolbox/community/README.md` +- [ ] No real data in the file, screenshots or this PR: no org/site IDs, tokens, hostnames, MACs, IPs or customer names +- [ ] I agree that this contribution is released under the repository's MIT licence diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..c5bfda5 --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,23 @@ +name: test + +on: + push: + branches: [main] + pull_request: + +permissions: + contents: read + +jobs: + test: + runs-on: ubuntu-latest + defaults: + run: + working-directory: mist-toolbox + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: 20 + # No npm dependencies, by design: the suite runs on Node's built-in test runner. + - run: npm test diff --git a/ADDING_TOOLS.md b/ADDING_TOOLS.md index b21a28c..79e73f5 100644 --- a/ADDING_TOOLS.md +++ b/ADDING_TOOLS.md @@ -113,3 +113,9 @@ helper, the form field types, the `scope: "site"` option and the result format. starting point is [`tool-template.js`](mist-toolbox/docs/tool-template.js). Without the **Manage tools** button: copy the file into `mist-toolbox/tools/`, run `npm run scan` (or add its filename to `tools/tools.json`), and reload the extension. + +## Share it + +If your tool could help other Mist admins, contribute it to the +[community tools](mist-toolbox/community/README.md): add the file to `mist-toolbox/community/` +and open a pull request. [CONTRIBUTING.md](CONTRIBUTING.md) has the steps and the checklist. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..0cfe177 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,61 @@ +# Contributing a tool + +The toolbox grows by people sharing the reports they wrote for themselves. Contributed tools live +in [`mist-toolbox/community/`](mist-toolbox/community/README.md). They are reviewed and tested +like any other code, but they are not built in: people install the ones they want with +**Manage tools → Install**. + +## 1. Write the tool + +A tool is one JavaScript file that default-exports a tool object. You don't have to write it by +hand: + +- **With an AI assistant**: **Manage tools → Copy AI prompt**, paste it into any assistant, then + add your script or a description of the report you want. The full walkthrough is in + [ADDING_TOOLS.md](ADDING_TOOLS.md). +- **By hand**: start from [`tool-template.js`](mist-toolbox/docs/tool-template.js). The reference + is the [authoring guide](mist-toolbox/docs/TOOL_GUIDE.md). + +## 2. Try it in your own toolbox + +Install it with **Manage tools → Install** and run it against your org. The installer runs the +same checks CI runs, so a tool that installs cleanly will usually pass. + +## 3. Open a pull request + +1. Fork this repository. +2. Add your file as `mist-toolbox/community/.js`. The filename must match the tool's + `id`. For example, `id: "ap-uptime"` goes in `ap-uptime.js`. +3. Add one row for it to the table in + [`mist-toolbox/community/README.md`](mist-toolbox/community/README.md), linking the file as + `[ap-uptime.js](ap-uptime.js)`. +4. Optional: run `npm test` in `mist-toolbox/`. It needs Node 20 or later and no `npm install`. +5. Open the pull request and fill in the checklist. + +One tool per pull request, please. If you're changing an existing community tool, say what +changed and why. + +## The rules + +CI enforces most of these, and a reviewer checks the rest: + +| Rule | Why | +|---|---| +| Mist calls only through `ctx.getAll`, `ctx.searchAll`, `ctx.mistGet` | They only talk to the region the user picked | +| Read-only: GET only, no POST/PUT/DELETE | The toolbox never changes anyone's network | +| No `fetch`, `XMLHttpRequest`, `WebSocket` or other network access | The token must not leave the Mist API | +| No browser storage, cookies, `console.*`, `eval` or dynamic `import()` | Nothing is persisted; the extension's security policy blocks the rest | +| Imports only from `../lib/`, `../engine/` or `../mist.js` | There is no npm in the browser | +| A header comment listing every Mist endpoint the tool reads | Reviewers and users can see what it touches | +| **No real data**: no org or site IDs, tokens, hostnames, MACs, IPs or customer names, in the file or in the PR | The repository is public | + +## Becoming a built-in tool + +A community tool that many people use can be promoted to `mist-toolbox/tools/`. When that +happens, a maintainer adds tests (see `mist-toolbox/tests/` for examples) and the tool ships in +the next release. + +## Licence + +This repository is MIT-licensed (see [LICENSE](LICENSE)). By opening a pull request you agree +that your contribution is released under the same licence. diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..6bcd766 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 InterconnectedSystems + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md index ae1c5c3..2bcb2aa 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,7 @@ tool you can install with one click. Unzip it, then in Chrome or Edge: **Extensions → Developer mode → Load unpacked →** pick the unzipped `mist-toolbox` folder. Full steps: **[INSTALL.md](INSTALL.md)**. -**[Install guide](INSTALL.md)** · **[Add your own tools](ADDING_TOOLS.md)** · [All releases](https://github.com/InterconnectedSystems/mist-toolbox/releases/latest) · [Extension README](mist-toolbox/README.md) +**[Install guide](INSTALL.md)** · **[Add your own tools](ADDING_TOOLS.md)** · [Community tools](mist-toolbox/community/README.md) · [All releases](https://github.com/InterconnectedSystems/mist-toolbox/releases/latest) · [Extension README](mist-toolbox/README.md) ![The tool menu](mist-toolbox/docs/screenshots/02-home.png) @@ -27,6 +27,10 @@ unzipped `mist-toolbox` folder. Full steps: **[INSTALL.md](INSTALL.md)**. - **Root-cause why one client keeps disconnecting**: RF and SNR, 802.11 reason codes, DHCP after roam, RRM channel changes, DFS radar on the AP it was on, and Teams/Zoom call quality, correlated into a verdict. *(Disconnect Console)* +- **Inspect one client's radio link**: RSSI, SNR and noise floor graded against design + thresholds, PHY rate against what the link could do, retries, channel load and non-Wi-Fi + interference, overlapping AP radios, Wi-Fi generation and roaming, as a colour-coded + dashboard. *(Client Wi-Fi PHY Inspector)* ### Switching - **Audit switch software**: every switch's model, serial, firmware, status, IP, uptime and @@ -39,6 +43,10 @@ unzipped `mist-toolbox` folder. Full steps: **[INSTALL.md](INSTALL.md)**. - **Map your IP space**: every IRB/VLAN interface address with subnet, mask, broadcast and usable range, with duplicate and overlapping networks flagged across sites. *(IP Blocks / IRB Report)* +- **Check power supply redundancy**: every switch's PSUs, per Virtual Chassis member, with + failed supplies and switches running on a single supply flagged. *(Switch PSU Status)* +- **Compare additional CLI**: every "additional CLI commands" block at template, switch-rule, + site and device level, side by side, with a de-duplicated list of commands. *(Switch Additional CLI)* ### Operations - **See what's alarming**: every alarm over the past day or week, by site and by type, with @@ -78,6 +86,14 @@ assistant the tool format, the helpers, the security rules and a full working ex The full walkthrough with screenshots is in **[ADDING_TOOLS.md](ADDING_TOOLS.md)**. +## Community tools + +Written a tool others could use? Share it. Contributed tools live in +[`mist-toolbox/community/`](mist-toolbox/community/README.md): browse the list, download one +and install it with **Manage tools → Install**. Every contribution is checked in CI against the +same rules the installer enforces. To add yours, see **[CONTRIBUTING.md](CONTRIBUTING.md)**. To +ask for a tool, [open a tool request](https://github.com/InterconnectedSystems/mist-toolbox/issues/new?template=tool-request.md). + ## Install Chrome or Edge, on Windows, macOS or Linux: @@ -108,7 +124,8 @@ Every screenshot with a caption: [`mist-toolbox/docs/screenshots/`](mist-toolbox ## What's in this repository -- [`mist-toolbox/`](mist-toolbox/) is the extension: tools, shared libraries, docs and 198 tests +- [`mist-toolbox/`](mist-toolbox/) is the extension: tools, shared libraries, docs and 220 tests (`npm test`). - The original scripts (`mist_*.py`, `site-wifi-clients.py`, `pre-post-check-gui.py`, - `mist_switch_report.js`, `site-alarms.js`) are kept for reference and for comparing output. + `mist_switch_report.js`, `site-alarms.js`, `client-wifi-phy.js`, `switch-psu-status.js`, + `switch-additional-cli.js`) are kept for reference and for comparing output. diff --git a/client-wifi-phy.js b/client-wifi-phy.js new file mode 100644 index 0000000..10c8bd4 --- /dev/null +++ b/client-wifi-phy.js @@ -0,0 +1,1035 @@ +// Client Wi-Fi PHY Inspector: every physical-layer facet of one wireless +// client's connection, graded against published thresholds and drawn as a +// colour-coded dashboard, plus an .xlsx of the raw numbers. +// +// Data (all GET, site found automatically when "All sites in the org" is ticked): +// /orgs/{org}/clients/search?mac= which site saw the client last +// /sites/{site}/stats/clients/{mac} live RSSI, SNR, rates, retries, band, proto +// /sites/{site}/clients/search?mac= last-known record if it is offline +// /sites/{site}/clients/{mac}/events joins, roams, deauths + 802.11 reason codes +// /sites/{site}/clients/sessions/search?mac= per-AP sessions (roaming timeline) +// /sites/{site}/stats/devices?type=ap serving radio: channel, width, power, +// noise floor, utilisation; site channel map +// /const/insight_metrics, then +// /sites/{site}/insights/client/{mac}/{metric} time series (path form; the ?metrics= form 404s) +// +// Grading thresholds (edit THRESHOLDS below to suit your own design targets): +// RSSI >= -65 good | -65..-70 warning | -70..-75 serious | < -75 critical (dBm) +// -67 dBm is the common voice/video design target; the toolbox's +// Disconnect Console treats < -75 dBm as a coverage-hole signature. +// SNR >= 25 good | 20..25 warning | 15..20 serious | < 15 critical (dB) +// Noise <= -90 good | -90..-85 warning | -85..-80 serious | > -80 critical (dBm) +// Retries, channel utilisation, non-Wi-Fi share, PHY-rate efficiency: see THRESHOLDS. + +import { deviceRadioMacs } from "../engine/normalize.js"; +import { bandGroup, describeReason, isNegative, normalizeMac, num, utilPct } from "../engine/util.js"; + +// ---- Thresholds and palette --------------------------------------------- + +// cuts = [good-from, warning-from, serious-from]; hb = higher is better. +const THRESHOLDS = { + rssi: { cuts: [-65, -70, -75], hb: true, min: -95, max: -30, unit: "dBm" }, + snr: { cuts: [25, 20, 15], hb: true, min: 0, max: 50, unit: "dB" }, + noise: { cuts: [-90, -85, -80], hb: false, min: -100, max: -60, unit: "dBm" }, + retry: { cuts: [10, 20, 30], hb: false, min: 0, max: 50, unit: "%" }, + util: { cuts: [50, 70, 85], hb: false, min: 0, max: 100, unit: "%" }, + nonwifi: { cuts: [10, 20, 35], hb: false, min: 0, max: 60, unit: "%" }, + eff: { cuts: [60, 40, 20], hb: true, min: 0, max: 100, unit: "%" }, + cochannel: { cuts: [1, 3, 6], hb: false, min: 0, max: 10, unit: "APs" }, + load: { cuts: [30, 50, 75], hb: false, min: 0, max: 100, unit: "clients" }, +}; + +// Status palette (fixed, never reused for series) and dark categorical slots. +const STATUS = { + critical: { color: "#d03b3b", icon: "✕", label: "Critical" }, + serious: { color: "#ec835a", icon: "▲", label: "Serious" }, + warning: { color: "#fab219", icon: "!", label: "Warning" }, + good: { color: "#0ca30c", icon: "✓", label: "Good" }, + unknown: { color: "#6e7480", icon: "–", label: "No data" }, +}; +const RANK = { critical: 0, serious: 1, warning: 2, good: 3, unknown: 4 }; +const SERIES = ["#3987e5", "#d95926", "#199e70", "#c98500", "#d55181", "#008300", "#9085e9", "#e66767"]; +const BAND = { + 24: { label: "2.4 GHz", color: SERIES[1] }, + 5: { label: "5 GHz", color: SERIES[0] }, + 6: { label: "6 GHz", color: SERIES[2] }, + unk: { label: "Unknown band", color: "#6e7480" }, +}; + +// Highest single-stream PHY rate (Mbps) by generation and channel width: +// HE MCS11 / EHT MCS13 at 0.8 us GI, VHT MCS9 / HT MCS7 at short GI. +const PER_STREAM_MAX = { + be: { 20: 172.1, 40: 344.1, 80: 720.6, 160: 1441.2, 320: 2882.4 }, + ax: { 20: 143.4, 40: 286.8, 80: 600.5, 160: 1201 }, + ac: { 20: 86.7, 40: 200, 80: 433.3, 160: 866.7 }, + n: { 20: 72.2, 40: 150 }, +}; +const GENERATION = { + be: { label: "Wi-Fi 7 (802.11be)", sev: "good" }, + ax: { label: "Wi-Fi 6/6E (802.11ax)", sev: "good" }, + ac: { label: "Wi-Fi 5 (802.11ac)", sev: "good" }, + n: { label: "Wi-Fi 4 (802.11n)", sev: "warning" }, + a: { label: "802.11a (legacy)", sev: "serious" }, + g: { label: "802.11g (legacy)", sev: "serious" }, + b: { label: "802.11b (legacy)", sev: "critical" }, +}; +const ASSUMED_STREAMS = 2; + +// 20 MHz channel plans. A wider channel occupies an aligned block of these. +const PLAN_5 = [36, 40, 44, 48, 52, 56, 60, 64, 100, 104, 108, 112, 116, 120, 124, 128, 132, 136, 140, 144, + 149, 153, 157, 161, 165, 169, 173, 177]; +const SEGMENTS_5 = [[36, 64], [100, 144], [149, 177]]; +const DFS_5 = [52, 144]; + +/** [lowest, highest] 20 MHz channel a radio occupies, from its primary channel and width. */ +function span(band, ch, width) { + const n = Math.max(1, Math.round((width || 20) / 20)); + if (band === "24") return [Math.max(1, ch - 2), Math.min(14, ch + (n > 1 ? 6 : 2))]; + if (band === "5") { + const seg = SEGMENTS_5.find(([lo, hi]) => ch >= lo && ch <= hi); + if (!seg) return [ch, ch]; + const chans = PLAN_5.filter((c) => c >= seg[0] && c <= seg[1]); + const p = chans.indexOf(ch); + if (p < 0) return [ch, ch]; + const start = Math.floor(p / n) * n; + return [chans[start], chans[Math.min(chans.length - 1, start + n - 1)]]; + } + if (band === "6") { + const start = Math.floor((ch - 1) / 4 / n) * n; + return [1 + start * 4, 1 + (start + n - 1) * 4]; + } + return [ch, ch]; +} +const overlaps = (a, b) => a[0] <= b[1] && b[0] <= a[1]; + +function channelPlan(band, occ) { + const hi = Math.max(0, ...occ.map((o) => o.span[1])); + const lo = Math.min(...occ.map((o) => o.span[0])); + if (band === "24") return Array.from({ length: hi > 13 ? 14 : 13 }, (_, i) => i + 1); + if (band === "5") return PLAN_5.filter((c) => c <= Math.max(165, hi)); + if (band === "6") { + const all = Array.from({ length: 59 }, (_, i) => 1 + i * 4); + return all.filter((c) => c >= lo - 16 && c <= hi + 16); + } + return []; +} + +// ---- Small helpers ------------------------------------------------------- + +const list = (v) => (Array.isArray(v) ? v : v == null || v === "" ? [] : [v]); +const first = (v) => (Array.isArray(v) ? v.find((x) => x != null && x !== "") : v); +const epoch = (t) => { const n = num(t); return n === null ? null : n > 1e12 ? n / 1000 : n; }; +const pick = (...vals) => { for (const v of vals) { const f = first(v); if (f != null && f !== "") return f; } return null; }; +const pct = (part, whole) => (whole > 0 ? (part / whole) * 100 : null); +const round = (v, d = 1) => (v == null ? null : Math.round(v * 10 ** d) / 10 ** d); +const results = (res) => (Array.isArray(res) ? res : list(res?.results ?? res?.data)); +const worst = (sevs) => sevs.reduce((w, s) => (RANK[s] < RANK[w] ? s : w), "unknown"); + +// Axis ticks on round steps (1, 2, 2.5, 5 x 10^n) inside [lo, hi]. +function niceTicks(lo, hi, count = 5) { + const raw = (hi - lo) / (count - 1) || 1; + const mag = 10 ** Math.floor(Math.log10(raw)); + const step = [1, 2, 2.5, 5, 10].map((m) => m * mag).find((s) => s >= raw); + const out = []; + for (let v = Math.ceil(lo / step) * step; v <= hi + step * 1e-6; v += step) out.push(Math.round(v * 1e6) / 1e6); + return out; +} + +function grade(v, t) { + if (v == null || Number.isNaN(v)) return "unknown"; + const [a, b, c] = t.cuts; + if (t.hb) return v >= a ? "good" : v >= b ? "warning" : v >= c ? "serious" : "critical"; + return v <= a ? "good" : v <= b ? "warning" : v <= c ? "serious" : "critical"; +} + +function zones(t) { + const [a, b, c] = t.cuts; + return t.hb + ? [{ from: t.min, to: c, sev: "critical" }, { from: c, to: b, sev: "serious" }, + { from: b, to: a, sev: "warning" }, { from: a, to: t.max, sev: "good" }] + : [{ from: t.min, to: a, sev: "good" }, { from: a, to: b, sev: "warning" }, + { from: b, to: c, sev: "serious" }, { from: c, to: t.max, sev: "critical" }]; +} + +function protoKey(p) { + const s = String(p || "").toLowerCase().replace(/^802\.11/, ""); + for (const k of ["be", "ax", "ac", "n", "a", "g", "b"]) if (s === k || s.endsWith(k)) return k; + return ""; +} + +function phyCeiling(proto, width, streams) { + if (proto === "a" || proto === "g") return 54; + if (proto === "b") return 11; + const table = PER_STREAM_MAX[proto]; + if (!table) return null; + const w = table[width] ? width : Math.max(...Object.keys(table).map(Number).filter((x) => x <= (width || 20)), 20); + return table[w] ? table[w] * Math.min(Math.max(streams, 1), 4) : null; +} + +// Insight responses vary by metric: {rt:[...], results:[...]}, {rt, :[...]}, +// or results:[{timestamp, ...}]. Pull every numeric series out of whatever came back. +function parseSeries(res, metric) { + const out = []; + if (!res || typeof res !== "object") return out; + const toPts = (ts, vals) => ts.map((t, i) => [epoch(t), num(vals[i])]).filter(([t, v]) => t !== null && v !== null); + const rt = Array.isArray(res.rt) ? res.rt : null; + if (rt) { + for (const [k, arr] of Object.entries(res)) { + if (k === "rt" || !Array.isArray(arr) || arr.length !== rt.length) continue; + const base = k === "results" ? metric : `${metric}.${k}`; + if (arr.some((x) => x && typeof x === "object")) { + const keys = new Set(); + arr.forEach((o) => o && typeof o === "object" && Object.keys(o).forEach((kk) => keys.add(kk))); + for (const kk of keys) { + const pts = toPts(rt, arr.map((o) => o?.[kk])); + const name = `${base}.${kk}`; + if (pts.length) out.push({ name, pts }); + } + } else { + const pts = toPts(rt, arr); + const name = base; + if (pts.length) out.push({ name, pts }); + } + } + } else if (Array.isArray(res.results)) { + const keys = new Set(); + res.results.forEach((o) => o && typeof o === "object" && Object.keys(o).forEach((k) => keys.add(k))); + keys.delete("timestamp"); + for (const k of keys) { + const pts = res.results.map((o) => [epoch(o?.timestamp), num(o?.[k])]).filter(([t, v]) => t !== null && v !== null); + const name = k === "value" ? metric : `${metric}.${k}`; + if (pts.length) out.push({ name, pts }); + } + } + return out; +} + +// tx_rate + rx_rate share a chart (same unit, one axis); rssi and snr get their own. +function family(name) { + const n = name.toLowerCase(); + if (/rssi/.test(n)) return "rssi"; + if (/snr/.test(n)) return "snr"; + if (/retr/.test(n)) return "retries"; + if (/bps/.test(n)) return "bps"; + if (/rate|mcs/.test(n)) return "rate"; + if (/bytes/.test(n)) return "bytes"; + if (/pkts|packets/.test(n)) return "pkts"; + return n.replace(/(^|[._])(tx|rx)(?=[._]|$)/g, "$1").replace(/[._]+/g, "_").replace(/^_|_$/g, "") || n; +} +const FAMILY_META = { + rssi: { title: "Signal strength (RSSI)", unit: "dBm", t: THRESHOLDS.rssi }, + snr: { title: "Signal-to-noise ratio (SNR)", unit: "dB", t: THRESHOLDS.snr }, + rate: { title: "PHY data rate", unit: "Mbps" }, + bps: { title: "Throughput", unit: "bps" }, + retries: { title: "Retries", unit: "" }, + pkts: { title: "Packets", unit: "" }, + bytes: { title: "Bytes", unit: "bytes" }, +}; +const FAMILY_ORDER = ["rssi", "snr", "rate", "bps", "retries", "pkts", "bytes"]; + +// ---- The tool -------------------------------------------------------------- + +export default { + id: "client-wifi-phy", + name: "Client Wi-Fi PHY Inspector", + description: + "Examines every physical-layer facet of one wireless client: RSSI, SNR, noise floor, PHY rate " + + "against its ceiling, retries, band, Wi-Fi generation, channel load and interference, co-channel " + + "APs and roaming, with colour-coded charts that highlight what is wrong.", + tag: "Mist API", + level: "Site · Client", + notice: "Shows client identity (hostname, IP, username). Treat the export as personal data.", + needs: { mistToken: true, org: true }, + scope: "site", + params: [ + { + id: "mac", + label: "Client MAC", + placeholder: "aa:bb:cc:dd:ee:ff", + hint: "Any format. With \"All sites in the org\" ticked, the tool finds the client's site.", + }, + { + id: "duration", + type: "select", + label: "Look back", + options: [ + { value: "1d", label: "Past 1 day" }, + { value: "7d", label: "Past 7 days" }, + ], + default: "1d", + }, + ], + + async run(ctx) { + const mac = normalizeMac(ctx.params.mac); + const duration = ctx.params.duration === "7d" ? "7d" : "1d"; + const durS = duration === "7d" ? 7 * 86400 : 86400; + const now = Date.now() / 1000; + const scope = await ctx.targetSites(); + const siteName = (id) => scope.orgSites.find((s) => s.id === id)?.name || id; + + // ---- 1. Which site? ----------------------------------------------------- + let siteId = scope.all ? null : scope.sites[0].id; + if (!siteId) { + ctx.status("Finding the client…"); + let hits = []; + try { + hits = results(await ctx.searchAll(`/orgs/${ctx.orgId}/clients/search`, { mac, duration, limit: 100 })); + } catch (e) { + ctx.log(`Org-wide client search unavailable (${e.message}); checking each site`, "info"); + let done = 0; + const per = await ctx.pool(ctx.POOL_LIMIT, scope.sites.map((s) => async () => { + if (ctx.signal.aborted) return null; + try { const r = await ctx.mistGet(`/sites/${s.id}/stats/clients/${mac}`); return r ? { ...r, site_id: s.id } : null; } + catch { return null; } finally { ctx.progress(++done, scope.sites.length, s.name); } + })); + hits = per.filter(Boolean); + } + hits = hits.filter((h) => h?.site_id && scope.orgSites.some((s) => s.id === h.site_id)) + .sort((a, b) => (epoch(pick(b.last_seen, b.timestamp)) || 0) - (epoch(pick(a.last_seen, a.timestamp)) || 0)); + if (!hits.length) throw new Error(`Client ${formatMac(mac)} was not seen at any site in the ${duration === "7d" ? "past 7 days" : "past day"}.`); + siteId = hits[0].site_id; + ctx.log(`Client found at ${siteName(siteId)}`, "ok"); + } + if (ctx.signal.aborted) throw new Error("Cancelled."); + + // ---- 2. Collect ----------------------------------------------------------- + ctx.status(`Reading ${formatMac(mac)} at ${siteName(siteId)}…`); + const soft = async (label, fn) => { + try { return await fn(); } catch (e) { ctx.log(`${label}: ${e.message}`, "info"); return null; } + }; + const [stats, search, events, sessions, aps, catalog] = await Promise.all([ + soft("Live stats", () => ctx.mistGet(`/sites/${siteId}/stats/clients/${mac}`)), + soft("Client history", () => ctx.searchAll(`/sites/${siteId}/clients/search`, { mac, duration, limit: 100 })), + soft("Client events", () => ctx.mistGet(`/sites/${siteId}/clients/${mac}/events`, { duration, limit: 1000 })), + soft("Sessions", () => ctx.searchAll(`/sites/${siteId}/clients/sessions/search`, { mac, duration, limit: 100 })), + soft("AP stats", () => ctx.getAll(`/sites/${siteId}/stats/devices`, { type: "ap" })), + soft("Insight metric list", () => ctx.mistGet("/const/insight_metrics")), + ]); + ctx.progress(1, 3, "Client data"); + + const hist = results(search).sort((a, b) => (epoch(pick(b.last_seen, b.timestamp)) || 0) - (epoch(pick(a.last_seen, a.timestamp)) || 0)); + const last = hist[0] || {}; + const live = stats && typeof stats === "object" && !Array.isArray(stats) && Object.keys(stats).length ? stats : null; + const c = live || {}; + + // ---- 3. Insight time series --------------------------------------------- + const PHY_RE = /rssi|snr|rate|bps|retr|mcs|stream|noise|util|pkts|bytes|power/i; + let metrics = []; + if (catalog && typeof catalog === "object") { + const entries = Array.isArray(catalog) + ? catalog.map((m) => [m?.key || m?.name || m?.metric, m]) + : Object.entries(catalog); + metrics = entries + .filter(([k, m]) => k && (!m?.scopes || list(m.scopes).map(String).includes("client"))) + .filter(([, m]) => !m?.type || /time/i.test(String(m.type))) + .map(([k]) => String(k)); + const phy = metrics.filter((m) => PHY_RE.test(m)); + metrics = (phy.length ? phy : metrics).slice(0, 14); + } + if (!metrics.length) { + metrics = ["rssi", "snr", "tx_rate", "rx_rate", "tx_bps", "rx_bps", "tx_retries", "rx_retries", "tx_bytes", "rx_bytes"]; + } + ctx.status("Reading client time series…"); + const interval = duration === "7d" ? 3600 : 600; + let missing = 0; + const seriesSets = await ctx.pool(ctx.POOL_LIMIT, metrics.map((m) => async () => { + if (ctx.signal.aborted) return []; + try { + const res = await ctx.mistGet(`/sites/${siteId}/insights/client/${mac}/${encodeURIComponent(m)}`, { duration, interval }); + return parseSeries(res, m); + } catch { missing += 1; return []; } + })); + if (ctx.signal.aborted) throw new Error("Cancelled."); + const series = seriesSets.flat().filter((s) => s.pts.length >= 2); + if (missing) ctx.log(`${missing} of ${metrics.length} insight metric(s) returned nothing for this client`, "info"); + ctx.progress(2, 3, "Time series"); + + // RSSI/SNR fallback: per-record readings in events and history. + const harvest = (rows, field) => rows.map((r) => [epoch(pick(r.timestamp, r.last_seen)), num(r?.[field])]) + .filter(([t, v]) => t !== null && v !== null && t >= now - durS); + const evRows = results(events); + for (const f of ["rssi", "snr"]) { + if (!series.some((s) => family(s.name) === f)) { + const pts = [...harvest(evRows, f), ...harvest(hist, f)].sort((a, b) => a[0] - b[0]); + if (pts.length >= 2) series.push({ name: `${f} (event readings)`, pts }); + } + } + + // ---- 4. Shape ------------------------------------------------------------- + const apList = (aps || []).filter((d) => !d.type || d.type === "ap"); + const hex = (m) => String(m || "").toLowerCase().replace(/[^0-9a-f]/g, ""); + // Mist names the client's AP by base MAC or by a radio BSSID; match either. + const apKeys = apList.map((a) => ({ a, macs: new Set([hex(a.mac), ...deviceRadioMacs(a)]) })); + const findAp = (m) => { + const h = hex(m); + if (h.length !== 12) return null; + const exact = apKeys.find((k) => k.macs.has(h)); + if (exact) return exact.a; + // A BSSID is the AP's base MAC plus a small offset in the low bits. APs from + // one batch share most of their MAC, so take the closest base MAC at or + // below the BSSID, within 32 addresses. + const low = (x) => parseInt(x.slice(6), 16); + let best = null; + for (const k of apKeys) { + const m = hex(k.a.mac); + if (m.slice(0, 6) !== h.slice(0, 6)) continue; + const diff = low(h) - low(m); + if (diff >= 0 && diff < 32 && (!best || diff < best.diff)) best = { a: k.a, diff }; + } + return best ? best.a : null; + }; + const apCandidates = [c.ap_mac, c.ap, c.bssid, last.last_ap, last.ap, last.bssid].map(first).filter(Boolean); + const servingAp = apCandidates.map(findAp).find(Boolean) || null; + const apMac = servingAp ? hex(servingAp.mac) : hex(apCandidates[0]); + if (!servingAp && apCandidates.length) ctx.log(`Serving AP ${formatMac(apCandidates[0])} not found in this site's AP stats`, "info"); + const apName = (m) => findAp(m)?.name || (m ? formatMac(m) : ""); + + const band = bandGroup(pick(c.band, last.band, last.last_band)); + const radio = servingAp?.radio_stat?.[`band_${band}`] || null; + const proto = protoKey(pick(c.proto, c.protocol, last.proto, last.last_proto)); + const streamsKnown = num(pick(c.num_streams, c.streams, c.nss)); + const streams = streamsKnown || ASSUMED_STREAMS; + + const rssi = num(pick(c.rssi, last.rssi, last.last_rssi)); + const snr = num(pick(c.snr, last.snr, last.last_snr)); + const noise = num(pick(radio?.noise_floor, c.noise_floor)); + const txRate = num(c.tx_rate); + const rxRate = num(c.rx_rate); + const bestRate = Math.max(txRate || 0, rxRate || 0) || null; + + // Channel width: reported, or else the narrowest width whose ceiling covers + // the rate the client actually achieved (a 573 Mbps HE link is at least 80 MHz). + let width = num(pick(c.channel_width, c.bandwidth, radio?.bandwidth)) || (band === "24" ? 20 : null); + const widthInferred = !width; + if (!width) width = [20, 40, 80, 160, 320].find((w) => (phyCeiling(proto, w, streams) || 0) >= (bestRate || 0)) || 20; + const ceiling = phyCeiling(proto, width, streams); + const eff = bestRate && ceiling ? Math.min(100, pct(bestRate, ceiling)) : null; + const widthLabel = `${width} MHz${widthInferred ? " (inferred)" : ""}`; + const txRetries = num(c.tx_retries); const rxRetries = num(c.rx_retries); + const txPkts = num(c.tx_pkts); const rxPkts = num(c.rx_pkts); + const txRetryPct = txRetries != null && txPkts ? pct(txRetries, txPkts + txRetries) : null; + const rxRetryPct = rxRetries != null && rxPkts ? pct(rxRetries, rxPkts + rxRetries) : null; + const retryPct = [txRetryPct, rxRetryPct].filter((v) => v != null).reduce((m, v) => Math.max(m, v), -1); + const u = (k) => (radio && radio[k] != null ? utilPct(radio[k]) : null); + const util = { + all: u("util_all"), tx: u("util_tx"), inBss: u("util_rx_in_bss"), otherBss: u("util_rx_other_bss"), + unknownWifi: u("util_unknown_wifi"), nonWifi: u("util_non_wifi"), + }; + const channel = num(pick(c.channel, radio?.channel, last.channel)); + const radioClients = num(radio?.num_clients); + + // Channel occupancy: every AP radio in the client's band, as the 20 MHz channels + // it spans. Co-channel means any overlap with the serving radio, not just the + // same primary channel (an 80 MHz radio on 36 also occupies 40, 44 and 48). + const occ = apList.map((a) => { + const r = a.radio_stat?.[`band_${band}`]; + const ch = num(r?.channel); + if (ch == null) return null; + const w = num(r?.bandwidth) || 20; + return { + name: a.name || formatMac(a.mac), mac: hex(a.mac), ch, width: w, span: span(band, ch, w), + clients: num(r?.num_clients), util: r?.util_all != null ? utilPct(r.util_all) : null, serving: a === servingAp, + }; + }).filter(Boolean); + if (channel != null && !occ.some((o) => o.serving)) { + occ.push({ name: servingAp?.name || (apMac ? formatMac(apMac) : "Serving AP"), mac: apMac, ch: channel, + width: width || 20, span: span(band, channel, width || 20), clients: radioClients, util: util.all, serving: true }); + } + const servingOcc = occ.find((o) => o.serving) || null; + const coChannel = servingOcc ? occ.filter((o) => !o.serving && overlaps(o.span, servingOcc.span)) : []; + const samePrimary = servingOcc ? coChannel.filter((o) => o.ch === servingOcc.ch).length : 0; + + const evs = evRows.map((e) => { + const reason = pick(e.reason_code, e.reason); + return { + t: epoch(e.timestamp) || 0, type: String(e.type || "unknown"), text: String(e.text || ""), + ap: e.ap || "", band: bandGroup(e.band), channel: e.channel ?? "", reason, + reasonText: describeReason(reason) || "", negative: isNegative(e.type || "", e.text || ""), + }; + }).sort((a, b) => a.t - b.t); + const negEvents = evs.filter((e) => e.negative); + const reasonCounts = new Map(); + for (const e of negEvents) { + const k = e.reasonText ? `Reason ${e.reasonText}` : e.type.replace(/^CLIENT_|^MARVIS_EVENT_CLIENT_/, "").replace(/_/g, " ").toLowerCase(); + reasonCounts.set(k, (reasonCounts.get(k) || 0) + 1); + } + const authFails = negEvents.filter((e) => ["2", "15", "23"].includes(String(e.reason)) || /4-way|handshake|auth.*fail/i.test(`${e.type} ${e.text}`)); + + const sess = results(sessions).map((s) => ({ + ap: ((m) => (findAp(m) ? hex(findAp(m).mac) : hex(m)))(pick(s.ap, s.ap_mac, s.bssid)), band: bandGroup(s.band), ssid: String(pick(s.ssid) || ""), + start: epoch(s.connect), end: epoch(s.disconnect), dur: num(s.duration), + })).filter((s) => s.start).sort((a, b) => a.start - b.start); + let pingPong = 0; + for (let i = 2; i < sess.length; i++) { + if (sess[i].ap === sess[i - 2].ap && sess[i].ap !== sess[i - 1].ap && sess[i].start - sess[i - 2].start <= 300) pingPong += 1; + } + const roams = sess.filter((s, i) => i > 0 && s.ap !== sess[i - 1].ap).length; + const shortSess = sess.filter((s) => (s.dur ?? ((s.end || now) - s.start)) < 60).length; + const spanH = sess.length ? Math.max(1, ((sess.at(-1).end || now) - sess[0].start) / 3600) : 1; + const bandsUsed = new Set(sess.map((s) => s.band).filter((b) => b !== "unk")); + + const rssiPts = series.filter((s) => family(s.name) === "rssi").flatMap((s) => s.pts.map((p) => p[1])); + const weakShare = rssiPts.length ? pct(rssiPts.filter((v) => v < THRESHOLDS.rssi.cuts[1]).length, rssiPts.length) : null; + + // ---- 5. Findings ------------------------------------------------------------ + const findings = []; + const add = (sev, facet, title, evidence, advice) => findings.push({ sev, facet, title, evidence, advice }); + const lastKnown = live ? "" : " (last known; client is not connected now)"; + + const gR = grade(rssi, THRESHOLDS.rssi); + if (gR === "unknown") add("unknown", "Signal", "No RSSI reported", "Mist returned no RSSI for this client.", ""); + else add(gR, "Signal", gR === "good" ? "Strong signal" : gR === "warning" ? "Signal below the -65 dBm comfort zone" : gR === "serious" ? "Weak signal for voice and video" : "Coverage hole: signal below -75 dBm", + `RSSI ${rssi} dBm${lastKnown}.`, + gR === "good" ? "" : "Below -67 dBm real-time media starts to suffer; below -75 dBm expect retries, low MCS and sticky-client behaviour. Check AP placement and power, and whether the client is holding on to a distant AP."); + if (weakShare != null && weakShare >= 20 && RANK[gR] >= RANK.warning) { + add(weakShare >= 50 ? "serious" : "warning", "Signal", "Signal is often weak over the period", + `${round(weakShare, 0)}% of RSSI samples were below ${THRESHOLDS.rssi.cuts[1]} dBm even though the current value is ${rssi ?? "unknown"} dBm.`, + "Intermittent weak signal points at movement through a coverage gap or a client that roams late."); + } + + const gS = grade(snr, THRESHOLDS.snr); + if (gS !== "unknown") { + const interference = RANK[gR] >= RANK.warning && RANK[gS] <= RANK.serious; + add(gS, "SNR", gS === "good" ? "Clean signal-to-noise" : interference ? "Interference: SNR is low while RSSI is usable" : "Low signal-to-noise ratio", + `SNR ${snr} dB with RSSI ${rssi ?? "?"} dBm${noise != null ? ` and a ${noise} dBm noise floor` : ""}${lastKnown}.`, + gS === "good" ? "" : interference + ? "Distance is not the problem. Look for co-channel APs, non-Wi-Fi sources (see channel utilisation) or a noisy channel, and consider a channel change." + : "Below 25 dB higher MCS rates become unreachable; below 15 dB expect packet loss."); + } + + const gN = grade(noise, THRESHOLDS.noise); + if (gN !== "unknown") add(gN, "Noise floor", gN === "good" ? "Quiet channel" : "Raised noise floor on the serving radio", + `Noise floor ${noise} dBm on ${BAND[band].label} channel ${channel ?? "?"} of ${servingAp?.name || "the serving AP"}.`, + gN === "good" ? "" : "Every dB of noise above about -90 dBm is a dB of SNR lost. Hunt for non-Wi-Fi interferers (microwaves, wireless video, Bluetooth) or move channel."); + + const gE = grade(eff, THRESHOLDS.eff); + if (gE !== "unknown") add(gE, "PHY rate", gE === "good" ? "PHY rate near its ceiling" : "PHY rate far below what the link could do", + `Best rate ${round(bestRate, 0)} Mbps of a ${round(ceiling, 0)} Mbps ceiling (${round(eff, 0)}%) for ${GENERATION[proto]?.label || proto}, ${widthLabel}, ${streams} stream(s)${streamsKnown ? "" : " assumed"}.`, + gE === "good" ? "" : "Low MCS follows low SNR, retries or a client that is power-saving or idle. If SNR is good, check retries and channel utilisation."); + + const gT = grade(retryPct >= 0 ? retryPct : null, THRESHOLDS.retry); + if (gT !== "unknown") add(gT, "Retries", gT === "good" ? "Few retransmissions" : "High retransmission rate", + `${round(retryPct, 1)}% of frames were retries (tx ${round(txRetryPct, 1) ?? "?"}%, rx ${round(rxRetryPct, 1) ?? "?"}%).`, + gT === "good" ? "" : "Retries above about 10% waste airtime and add latency. Common causes: low SNR, hidden nodes, co-channel contention, or an overly aggressive rate."); + + const gU = grade(util.all, THRESHOLDS.util); + if (gU !== "unknown") add(gU, "Channel load", gU === "good" ? "Channel has airtime to spare" : "Busy channel", + `${util.all}% channel utilisation on the serving radio (tx ${util.tx ?? "?"}%, rx in-BSS ${util.inBss ?? "?"}%, other BSS ${util.otherBss ?? "?"}%, non-Wi-Fi ${util.nonWifi ?? "?"}%).`, + gU === "good" ? "" : "Above about 50% busy, contention and latency rise quickly. Split load across channels, narrow the channel width, or remove low-rate legacy clients."); + + const gW = grade(util.nonWifi, THRESHOLDS.nonwifi); + if (gW !== "unknown" && gW !== "good") add(gW, "Interference", "Non-Wi-Fi energy on the channel", + `${util.nonWifi}% of airtime is non-Wi-Fi energy.`, + "Something that is not Wi-Fi is transmitting on this channel. Check for microwave ovens, cordless phones, wireless cameras or Bluetooth, especially on 2.4 GHz."); + + if (servingOcc) { + const gC = grade(coChannel.length, THRESHOLDS.cochannel); + const s = servingOcc; + add(gC, "Co-channel", gC === "good" ? "Little co-channel overlap at this site" : "Many APs overlap the serving channel", + `The serving radio is on ${BAND[band].label} channel ${s.ch} at ${s.width} MHz (channels ${s.span[0]}–${s.span[1]}). ` + + `${coChannel.length} other AP radio(s) at this site overlap it, ${samePrimary} on the same primary channel` + + `${coChannel.length ? `: ${coChannel.slice(0, 6).map((o) => `${o.name} (ch ${o.ch}/${o.width})`).join(", ")}${coChannel.length > 6 ? "…" : ""}` : ""}.`, + gC === "good" ? "" : "Overlapping radios that hear each other share airtime, and wide channels overlap more neighbours. " + + "Review the RRM channel plan, consider narrower channels, and check transmit power."); + } + if (radioClients != null) { + const gL = grade(radioClients, THRESHOLDS.load); + if (gL !== "good") add(gL, "Radio load", "Crowded serving radio", `${radioClients} clients on the serving ${BAND[band].label} radio.`, + "Heavy client counts on one radio mean less airtime each. Check band steering and neighbouring AP coverage."); + } + + if (band === "24") { + const dual = c.dual_band === true || bandsUsed.has("5") || bandsUsed.has("6"); + add(dual ? "serious" : "warning", "Band", "Connected on 2.4 GHz", + `The client is on 2.4 GHz${dual ? " although it has used 5/6 GHz in this period" : ""}.`, + "2.4 GHz has three usable channels, more interference and lower rates. Enable band steering or raise the 2.4 GHz minimum RSSI so capable clients prefer 5 or 6 GHz."); + if (width && width > 20) add("serious", "Band", "40 MHz channel on 2.4 GHz", `The serving 2.4 GHz radio uses ${width} MHz.`, + "40 MHz on 2.4 GHz overlaps two of the three non-overlapping channels. Use 20 MHz."); + } else if (band !== "unk") { + add("good", "Band", `Connected on ${BAND[band].label}`, `${BAND[band].label}, channel ${channel ?? "?"}, ${widthLabel}.`, ""); + } + + if (proto) { + const g = GENERATION[proto]; + add(g.sev, "Wi-Fi generation", g.sev === "good" ? g.label : `Older PHY: ${g.label}`, `Negotiated protocol ${g.label}.`, + g.sev === "good" ? "" : "Older PHYs cap the rate and take more airtime per byte, slowing the whole cell. Check the client driver, or whether the SSID/radio allows newer modes."); + } + + if (sess.length) { + const gP = pingPong >= 4 ? "serious" : pingPong >= 1 ? "warning" : "good"; + add(gP, "Roaming", pingPong ? "Ping-pong roaming" : `${roams} roam(s) across ${new Set(sess.map((s) => s.ap)).size} AP(s)`, + `${sess.length} session(s), ${roams} roam(s) (${round(roams / spanH, 1)}/hour), ${pingPong} A→B→A bounce(s) within 5 minutes, ${shortSess} session(s) under a minute.`, + pingPong ? "Bouncing between two APs means overlapping cells of similar strength. Reduce power on one AP or tune roaming thresholds." : ""); + } + if (evs.length) { + const n = negEvents.length; + const gD = n > 30 ? "critical" : n > 10 ? "serious" : n > 3 ? "warning" : "good"; + const top = [...reasonCounts].sort((a, b) => b[1] - a[1]).slice(0, 3).map(([k, v]) => `${k} ×${v}`).join(", "); + add(gD, "Disconnects", n ? `${n} disconnect or failure event(s)` : "No disconnects or failures", + `${evs.length} event(s) in the period, ${n} negative${top ? `. Most common: ${top}` : ""}.`, + n ? "Reason 4 (inactivity) with weak RSSI suggests a sticky client; reasons 3 and 8 are the client leaving; 2, 15 and 23 are authentication failures." : ""); + if (authFails.length) add(authFails.length > 5 ? "serious" : "warning", "Disconnects", "Authentication or 4-way handshake failures", + `${authFails.length} failure(s) with reason 2, 15 or 23, or a handshake timeout.`, + "Usually a PSK mismatch, an expired 802.1X credential, or key exchange lost to poor RF. If RF is clean, check the RADIUS logs."); + } + findings.sort((a, b) => RANK[a.sev] - RANK[b.sev]); + const overall = worst(findings.map((f) => f.sev).filter((s) => s !== "unknown")); + const issues = findings.filter((f) => RANK[f.sev] <= RANK.warning); + + // ---- 6. Dashboard ----------------------------------------------------------- + const identity = { + mac: formatMac(mac), + hostname: pick(c.hostname, last.hostname, last.last_hostname) || "", + username: pick(c.username, last.username, last.last_username) || "", + ip: pick(c.ip, last.ip, last.last_ip) || "", + device: [pick(c.manufacture, last.mfg, last.manufacture), pick(c.os, last.os, last.last_os), pick(c.model, last.model, last.last_model)].filter(Boolean).join(" · "), + ssid: pick(c.ssid, last.ssid, last.last_ssid) || "", + ap: servingAp?.name || (apMac ? formatMac(apMac) : ""), + site: siteName(siteId), + live: !!live, + lastSeen: epoch(pick(c.last_seen, last.last_seen, last.timestamp)), + }; + const tiles = [ + { label: "RSSI", value: rssi, unit: "dBm", t: THRESHOLDS.rssi, sev: gR }, + { label: "SNR", value: snr, unit: "dB", t: THRESHOLDS.snr, sev: gS }, + { label: "Noise floor", value: noise, unit: "dBm", t: THRESHOLDS.noise, sev: gN }, + { label: "PHY rate vs ceiling", value: eff == null ? null : round(eff, 0), unit: "%", t: THRESHOLDS.eff, sev: gE, + note: bestRate ? `${round(bestRate, 0)} / ${round(ceiling, 0)} Mbps` : "" }, + { label: "Retries", value: retryPct >= 0 ? round(retryPct, 1) : null, unit: "%", t: THRESHOLDS.retry, sev: gT }, + { label: "Channel utilisation", value: util.all, unit: "%", t: THRESHOLDS.util, sev: gU }, + { label: "Non-Wi-Fi airtime", value: util.nonWifi, unit: "%", t: THRESHOLDS.nonwifi, sev: grade(util.nonWifi, THRESHOLDS.nonwifi) }, + { label: "Overlapping AP radios", value: servingOcc ? coChannel.length : null, unit: "", t: THRESHOLDS.cochannel, + sev: servingOcc ? grade(coChannel.length, THRESHOLDS.cochannel) : "unknown", + note: servingOcc ? `ch ${servingOcc.ch} · ${servingOcc.width} MHz · ${samePrimary} same primary` : "" }, + ]; + const facts = [ + ["Band", BAND[band].label, band === "24" ? "serious" : band === "unk" ? "unknown" : "good"], + ["Channel", channel != null ? `${channel} · ${widthLabel}` : "", channel != null ? "good" : "unknown"], + ["Generation", GENERATION[proto]?.label || "", GENERATION[proto]?.sev || "unknown"], + ["Spatial streams", streamsKnown ? String(streamsKnown) : `${ASSUMED_STREAMS} (assumed)`, streamsKnown ? "good" : "unknown"], + ["AP transmit power", radio?.power != null ? `${radio.power} dBm` : "", radio?.power != null ? "good" : "unknown"], + ["Clients on radio", radioClients != null ? String(radioClients) : "", radioClients != null ? grade(radioClients, THRESHOLDS.load) : "unknown"], + ]; + if (ctx.mount) { + drawDashboard(ctx, { + identity, overall, issues, findings, tiles, facts, series, util, band, channel, occ, servingOcc, + sess, evs, negEvents, reasonCounts, apName, t0: now - durS, t1: now, duration, + }); + } + ctx.progress(3, 3, "Done"); + + // ---- 7. Workbook -------------------------------------------------------------- + const fmtT = (t) => (t ? ctx.fmtTime(t) : ""); + const findingRows = findings.map((f) => ({ ...f, sev: STATUS[f.sev].label, + __style: f.sev === "critical" ? "red" : f.sev === "serious" || f.sev === "warning" ? "yellow" : f.sev === "good" ? "green" : undefined })); + const phyRows = [ + ["Client MAC", identity.mac], ["Hostname", identity.hostname], ["Username", identity.username], ["IP", identity.ip], + ["Device", identity.device], ["Site", identity.site], ["SSID", identity.ssid], ["AP", identity.ap], + ["Connected now", live ? "Yes" : "No"], ["Last seen", fmtT(identity.lastSeen)], + ["Band", BAND[band].label], ["Channel", channel ?? ""], ["Channel width (MHz)", widthInferred ? `${width} (inferred from rate)` : width], + ["Protocol", GENERATION[proto]?.label || ""], ["Spatial streams", streamsKnown ?? `${ASSUMED_STREAMS} (assumed)`], + ["RSSI (dBm)", rssi ?? ""], ["SNR (dB)", snr ?? ""], ["Noise floor (dBm)", noise ?? ""], + ["TX rate (Mbps)", txRate ?? ""], ["RX rate (Mbps)", rxRate ?? ""], ["PHY ceiling (Mbps)", round(ceiling, 1) ?? ""], + ["Rate efficiency (%)", round(eff, 1) ?? ""], ["TX retries", txRetries ?? ""], ["RX retries", rxRetries ?? ""], + ["TX packets", txPkts ?? ""], ["RX packets", rxPkts ?? ""], ["Retry rate (%)", retryPct >= 0 ? round(retryPct, 1) : ""], + ["Channel utilisation (%)", util.all ?? ""], [" Transmit (%)", util.tx ?? ""], [" Receive in-BSS (%)", util.inBss ?? ""], + [" Receive other BSS (%)", util.otherBss ?? ""], [" Unknown Wi-Fi (%)", util.unknownWifi ?? ""], [" Non-Wi-Fi (%)", util.nonWifi ?? ""], + ["AP transmit power (dBm)", radio?.power ?? ""], ["Clients on radio", radioClients ?? ""], + ["Overlapping AP radios at site", servingOcc ? coChannel.length : ""], + [" Same primary channel", servingOcc ? samePrimary : ""], + ]; + const tsRows = series.flatMap((s) => s.pts.map(([t, v]) => ({ metric: s.name, time: fmtT(t), value: v }))); + const sessRows = sess.map((s) => ({ ap: apName(s.ap), band: BAND[s.band].label, ssid: s.ssid, start: fmtT(s.start), + end: fmtT(s.end), dur: s.dur ?? (s.end ? round(s.end - s.start, 0) : "") })); + const evRowsOut = [...evs].reverse().map((e) => ({ time: fmtT(e.t), type: e.type, text: e.text, ap: apName(e.ap), + band: BAND[e.band].label, channel: e.channel, reason: e.reasonText, negative: e.negative ? "Yes" : "", + __style: e.negative ? "red" : undefined })); + const relation = (o) => (o.serving ? "Serving" : servingOcc && overlaps(o.span, servingOcc.span) ? "Overlaps serving" : "Clear"); + const chRows = [...occ].sort((a, b) => a.span[0] - b.span[0] || a.ch - b.ch || a.name.localeCompare(b.name)).map((o) => ({ + ap: o.name, ch: o.ch, width: o.width, span: `${o.span[0]}–${o.span[1]}`, clients: o.clients ?? "", util: o.util ?? "", + rel: relation(o), __style: o.serving ? "blue" : relation(o) === "Clear" ? undefined : "yellow" })); + const info = [ + ["Org", ctx.orgName], ["Site", identity.site], ["Client", identity.mac], ["Look back", duration], + ["Overall", STATUS[overall].label], ["Insight metrics tried", metrics.join(", ")], + ["RSSI grading (dBm)", ">= -65 good, -65..-70 warning, -70..-75 serious, < -75 critical"], + ["SNR grading (dB)", ">= 25 good, 20..25 warning, 15..20 serious, < 15 critical"], + ["Noise floor grading (dBm)", "<= -90 good, -90..-85 warning, -85..-80 serious, > -80 critical"], + ["Retry grading (%)", "<= 10 good, 10..20 warning, 20..30 serious, > 30 critical"], + ["Channel utilisation grading (%)", "<= 50 good, 50..70 warning, 70..85 serious, > 85 critical"], + ["PHY ceiling", "Top MCS for the generation and channel width times spatial streams (2 assumed when Mist does not report it)"], + ["Generated", ctx.epochToUtc(Date.now())], + ]; + const sheets = [ + ctx.xlsx.sheet("Findings", [ + { header: "Severity", key: "sev", width: 10 }, { header: "Facet", key: "facet", width: 16 }, + { header: "Finding", key: "title", width: 40 }, { header: "Evidence", key: "evidence", width: 70, wrap: true }, + { header: "What to check", key: "advice", width: 70, wrap: true }, + ], findingRows, { tabColor: "C00000" }), + ctx.xlsx.sheet("PHY Snapshot", [{ header: "Field", width: 26 }, { header: "Value", width: 40 }], phyRows, + { autofilter: false, tabColor: "1F4E78" }), + ctx.xlsx.sheet("Time Series", [{ header: "Metric", key: "metric", width: 24 }, { header: "Time", key: "time", width: 20 }, + { header: "Value", key: "value", width: 14 }], tsRows, { tabColor: "548235" }), + ctx.xlsx.sheet("Sessions", [{ header: "AP", key: "ap", width: 24 }, { header: "Band", key: "band", width: 10 }, + { header: "SSID", key: "ssid", width: 20 }, { header: "Connected", key: "start", width: 20 }, + { header: "Disconnected", key: "end", width: 20 }, { header: "Duration (s)", key: "dur", width: 12 }], sessRows, { tabColor: "7030A0" }), + ctx.xlsx.sheet("Events", [{ header: "Time", key: "time", width: 20 }, { header: "Type", key: "type", width: 30 }, + { header: "Text", key: "text", width: 40, wrap: true }, { header: "AP", key: "ap", width: 22 }, + { header: "Band", key: "band", width: 10 }, { header: "Channel", key: "channel", width: 8 }, + { header: "802.11 Reason", key: "reason", width: 40 }, { header: "Negative", key: "negative", width: 9 }], evRowsOut, { tabColor: "C55A11" }), + ctx.xlsx.sheet("Channel Map", [{ header: "AP", key: "ap", width: 26 }, + { header: `${BAND[band].label} primary channel`, key: "ch", width: 12 }, { header: "Width (MHz)", key: "width", width: 10 }, + { header: "Occupies channels", key: "span", width: 14 }, { header: "Clients", key: "clients", width: 8 }, + { header: "Utilisation (%)", key: "util", width: 12 }, { header: "Relation to serving radio", key: "rel", width: 22 }], + chRows, { tabColor: "2E75B6" }), + ctx.xlsx.sheet("Info", [{ header: "Field" }, { header: "Value", wrap: true }], info, { autofilter: false, freeze: null }), + ]; + + return { + summary: `${identity.hostname || identity.mac} at ${identity.site}: ${STATUS[overall].label}. ` + + `${issues.length} issue(s) across ${findings.length} check(s)${live ? "" : " (client offline, last-known values)"}`, + filename: ctx.stampedName(`mist_client_phy_${mac}`, ctx.orgName, "xlsx"), + sheets, + }; + }, +}; + +function formatMac(m) { + const h = String(m || "").toLowerCase().replace(/[^0-9a-f]/g, ""); + return h.length === 12 ? h.match(/../g).join(":") : String(m || ""); +} + +// --------------------------------------------------------------------------- +// Dashboard. Dark-surface steps of the reference palette: status colours for +// grades (always with icon + label), categorical slots for bands and series. + +function drawDashboard(ctx, d) { + const E = ctx.esc; + const W = 760; + const charts = new Map(); + let chartSeq = 0; + const fmtShort = (t) => { + const dt = new Date(t * 1000); + return d.duration === "7d" + ? dt.toLocaleDateString(undefined, { month: "short", day: "numeric" }) + : dt.toLocaleTimeString(undefined, { hour: "2-digit", minute: "2-digit" }); + }; + const fmtVal = (v, unit) => { + if (v == null) return "–"; + if (unit === "bps") return v >= 1e6 ? `${round(v / 1e6, 1)} Mbps` : v >= 1e3 ? `${round(v / 1e3, 1)} kbps` : `${round(v, 0)} bps`; + if (unit === "bytes") return ctx.fmtBytes(v); + return `${round(v, 1)}${unit ? ` ${unit}` : ""}`; + }; + const badge = (sev, text) => `` + + `${STATUS[sev].icon}${E(text ?? STATUS[sev].label)}`; + + // Bullet meter: threshold zones, a marker at the value. + const meter = (tile) => { + const t = tile.t; + const span = t.max - t.min; + const zs = zones(t).map((z) => ``).join(""); + const v = tile.value == null ? null : Math.min(t.max, Math.max(t.min, tile.value)); + const pos = v == null ? null : ((v - t.min) / span) * 100; + return `
${zs}${pos == null ? "" : ``}
` + + `
${t.min}${t.max} ${E(t.unit)}
`; + }; + + // Line chart with threshold zones, failure markers, crosshair tooltip. + const lineChart = ({ title, unit, lines, t, markers = [] }) => { + const id = `phyc${++chartSeq}`; + const H = 210, L = 52, R = 14, T = 12, B = 26; + const all = lines.flatMap((l) => l.pts.map((p) => p[1])); + let lo = t ? t.min : Math.min(...all); + let hi = t ? t.max : Math.max(...all); + if (t) { lo = Math.min(lo, ...all); hi = Math.max(hi, ...all); } + if (!t) { if (lo >= 0) lo = 0; const padY = (hi - lo) * 0.08 || 1; hi += padY; } + const sx = (x) => L + ((x - d.t0) / (d.t1 - d.t0)) * (W - L - R); + const sy = (y) => T + (1 - (y - lo) / (hi - lo || 1)) * (H - T - B); + const zoneRects = t ? zones(t).map((z) => { + const y1 = sy(Math.min(hi, z.to)); const y2 = sy(Math.max(lo, z.from)); + return y2 > y1 ? `` : ""; + }).join("") : ""; + const ticks = niceTicks(lo, hi); + const grid = ticks.map((v) => `` + + `${E(fmtVal(v, unit === "bps" || unit === "bytes" ? unit : ""))}`).join(""); + const xt = Array.from({ length: 5 }, (_, i) => d.t0 + ((d.t1 - d.t0) * i) / 4) + .map((x, i) => `${E(fmtShort(x))}`).join(""); + const paths = lines.map((l) => { + const pts = l.pts.filter((p) => p[0] >= d.t0 - 60); + const dpath = pts.map((p, i) => `${i ? "L" : "M"}${sx(p[0]).toFixed(1)},${sy(p[1]).toFixed(1)}`).join(""); + const dots = pts.length <= 48 ? pts.map((p) => ``).join("") : ""; + return `${dots}`; + }).join(""); + const marks = markers.filter((m) => m.t >= d.t0).map((m) => + ``).join(""); + charts.set(id, { lines, sx, sy, unit, L, R, T, B, H, markers }); + const legend = lines.length > 1 ? `
${lines.map((l) => + `${E(l.name)}`).join("")}
` : ""; + return `
${E(title)}${lines.length === 1 ? ` ${E(lines[0].name)}` : ""}
${legend} +
+ ${zoneRects}${grid}${xt}${marks}${paths} +
+ ${markers.length ? `
Dashed red lines: disconnect / failure events
` : ""}
`; + }; + + // ---- Sections ---------------------------------------------------------------- + const id = d.identity; + const counts = ["critical", "serious", "warning", "good"].map((s) => [s, d.findings.filter((f) => f.sev === s).length]).filter(([, n]) => n); + const header = `
+
Wi-Fi PHY health
${badge(d.overall, STATUS[d.overall].label)}
+
${counts.map(([s, n]) => badge(s, `${n} ${STATUS[s].label.toLowerCase()}`)).join("")}
+
${[["Client", `${id.hostname ? `${id.hostname} · ` : ""}${id.mac}`], ["Device", id.device], ["Site", id.site], + ["SSID / AP", [id.ssid, id.ap].filter(Boolean).join(" · ")], ["IP / user", [id.ip, id.username].filter(Boolean).join(" · ")], + ["State", id.live ? "Connected now" : `Offline · last seen ${id.lastSeen ? ctx.fmtTime(id.lastSeen) : "?"}`]] + .filter(([, v]) => v).map(([k, v]) => `
${E(k)}
${E(v)}
`).join("")}
`; + + const tiles = `

Scorecard

${d.tiles.map((t) => + `
+
${E(t.label)}
${E(t.value == null ? "–" : `${t.value}`)}${E(t.value == null ? "" : t.unit)}
+ ${badge(t.sev)}${t.note ? `
${E(t.note)}
` : ""}${meter(t)}
`).join("")}
+
${d.facts.map(([k, v, s]) => `
${E(k)}${E(v || "–")}
`).join("")}
`; + + const findingsHtml = `

Findings

${d.issues.length ? "" : `

No issues found. Every graded facet is healthy.

`} +
${d.findings.filter((f) => f.sev !== "good" && f.sev !== "unknown").map((f) => + `
${badge(f.sev)}
${E(f.title)} ${E(f.facet)} +

${E(f.evidence)}

${f.advice ? `

${E(f.advice)}

` : ""}
`).join("")}
+ ${d.findings.some((f) => f.sev === "good") ? `
Healthy checks (${d.findings.filter((f) => f.sev === "good").length}) +
    ${d.findings.filter((f) => f.sev === "good").map((f) => `
  • ${badge("good", f.facet)} ${E(f.title)}: ${E(f.evidence)}
  • `).join("")}
` : ""}
`; + + // Time series grouped by family; tx/rx share a chart, never two y-axes. + const groups = new Map(); + for (const s of d.series) groups.set(family(s.name), [...(groups.get(family(s.name)) || []), s]); + const famOrder = [...groups.keys()].sort((a, b) => (FAMILY_ORDER.indexOf(a) + 1 || 99) - (FAMILY_ORDER.indexOf(b) + 1 || 99)); + const markers = d.negEvents.map((e) => ({ t: e.t, label: e.reasonText || e.type })); + const tsHtml = famOrder.length + ? `

Over time

${famOrder.map((f) => { + const meta = FAMILY_META[f] || { title: f.replace(/_/g, " "), unit: "" }; + const lines = groups.get(f).slice(0, 8).map((s, i) => ({ name: s.name, pts: s.pts, color: SERIES[i] })); + return lineChart({ title: meta.title, unit: meta.unit, lines, t: meta.t, markers: ["rssi", "snr"].includes(f) ? markers : [] }); + }).join("")}
` + : `

Over time

Mist returned no time series for this client. The scorecard uses live or last-known values.

`; + + // Airtime breakdown of the serving radio. + const u = d.util; + const parts = [ + ["Transmit", u.tx, SERIES[0]], ["Receive, other BSS", u.otherBss, SERIES[1]], ["Receive, this BSS", u.inBss, SERIES[2]], + ["Unknown Wi-Fi", u.unknownWifi, SERIES[3]], ["Non-Wi-Fi", u.nonWifi, SERIES[4]], + ].filter(([, v]) => v != null && v > 0); + const used = parts.reduce((s, [, v]) => s + v, 0); + const airtime = u.all == null && !parts.length ? "" : `
Serving radio airtime + ${E(BAND[d.band].label)} ch ${E(d.channel ?? "?")} · ${E(u.all ?? round(used, 0))}% busy
+
${parts.map(([k, v, col]) => ``).join("")} +
+
${parts.map(([k, v, col]) => `${E(k)} ${E(v)}%`).join("")} + Idle
`; + + // Channel occupancy: the band's whole 20 MHz channel plan on the x-axis, every AP + // radio as a bar across the channels it occupies (width included), packed into + // rows. Serving radio blue; radios overlapping it flagged; DFS range shaded. + const plan = channelPlan(d.band, d.occ); + const chanHtml = d.occ.length && plan.length ? (() => { + const L = 8, R = 8, top = 26, RH = 22, MAXROWS = 24; + const cw = (W - L - R) / plan.length; + const col = (ch) => { const i = plan.findIndex((p) => p >= ch); return i < 0 ? plan.length - 1 : i; }; + const so = d.servingOcc; + const rel = (o) => (o.serving ? "serving" : so && overlaps(o.span, so.span) ? "overlap" : "clear"); + const STYLE = { + serving: { fill: SERIES[0], ink: "#ffffff" }, + overlap: { fill: STATUS.serious.color, ink: "#0b0d11" }, + clear: { fill: "var(--phy-neutral)", ink: "var(--phy-fg)" }, + }; + const order = { serving: 0, overlap: 1, clear: 2 }; + const items = [...d.occ].sort((a, b) => order[rel(a)] - order[rel(b)] || a.span[0] - b.span[0] || a.name.localeCompare(b.name)); + const rows = []; + let hidden = 0; + const placed = []; + for (const o of items) { + const i0 = col(o.span[0]); const i1 = Math.max(i0, col(o.span[1])); + let r = rows.findIndex((row) => row.every(([a, b]) => i1 < a || i0 > b)); + if (r < 0) { if (rows.length >= MAXROWS) { hidden += 1; continue; } rows.push([]); r = rows.length - 1; } + rows[r].push([i0, i1]); + placed.push({ o, i0, i1, r }); + } + const plotH = rows.length * RH; + const H = top + plotH + 44; + const cover = plan.map((ch) => d.occ.filter((o) => ch >= o.span[0] && ch <= o.span[1]).length); + const step = Math.max(1, Math.ceil(26 / cw)); + const inServing = (ch) => so && ch >= so.span[0] && ch <= so.span[1]; + const dfs = d.band === "5" ? [col(DFS_5[0]), col(DFS_5[1])] : null; + const tipFor = (o) => `${o.name}${o.serving ? " (serving)" : ""}\nPrimary channel ${o.ch} · ${o.width} MHz` + + `\nOccupies ${o.span[0] === o.span[1] ? `channel ${o.span[0]}` : `channels ${o.span[0]}–${o.span[1]}`}` + + `${o.clients != null ? `\n${o.clients} client(s)` : ""}${o.util != null ? ` · ${o.util}% busy` : ""}` + + `${rel(o) === "overlap" ? `\n▲ Shares airtime with the serving radio` : ""}`; + const svg = ` + ${dfs ? ` + DFS` : ""} + ${so ? `serving` : ""} + ${plan.map((_, i) => ``).join("")} + + ${placed.map(({ o, i0, i1, r }) => { + const x = L + i0 * cw + 2; const w = (i1 - i0 + 1) * cw - 4; const y = top + r * RH + 3; const s = STYLE[rel(o)]; + const chars = Math.floor((w - 10) / 6.4); + const label = chars >= 4 ? (o.name.length > chars ? `${o.name.slice(0, chars - 1)}…` : o.name) : ""; + return `${label ? `${E(label)}` : ""}`; + }).join("")} + + ${plan.map((ch, i) => (i % step && !inServing(ch) ? "" : `${ch}`)).join("")} + ${plan.map((ch, i) => `${cover[i] || "·"}`).join("")} + `; + const nOver = d.occ.filter((o) => rel(o) === "overlap").length; + return `
${E(BAND[d.band].label)} channel occupancy at this site + ${so ? `serving ch ${E(so.ch)} · ${E(so.width)} MHz (${E(so.span[0])}–${E(so.span[1])}) · ${E(nOver)} overlapping radio(s)` : "serving radio unknown"}
+
Serving AP radio + ${STATUS.serious.icon} Overlaps the serving channel + Other AP radios + ${dfs ? `DFS channels` : ""}
+ ${svg} +
Bars span every 20 MHz channel a radio occupies (primary channel plus width). Bottom row: AP radios on each channel.${hidden ? ` ${hidden} radio(s) not drawn.` : ""}
`; + })() : ""; + + // Roaming swimlanes: one row per AP, sessions coloured by band, failures on top. + const apOrder = [...new Set(d.sess.map((s) => s.ap))].slice(0, 12); + const RH = 22, LW = 150, TW = W - LW - 14, top = 24; + const tx = (t) => LW + ((Math.max(d.t0, Math.min(d.t1, t)) - d.t0) / (d.t1 - d.t0)) * TW; + const laneH = top + apOrder.length * RH + 24; + const roamHtml = d.sess.length ? `
Roaming timeline ${E(d.sess.length)} session(s) on ${E(apOrder.length)} AP(s)
+
${["24", "5", "6"].filter((b) => d.sess.some((s) => s.band === b)).map((b) => + `${BAND[b].label}`).join("")}${STATUS.critical.icon} Disconnect / failure
+ + ${d.negEvents.filter((e) => e.t >= d.t0).map((e) => ``).join("")} + ${apOrder.map((ap, i) => `${E(d.apName(ap).slice(0, 22))} + `).join("")} + ${d.sess.filter((s) => apOrder.includes(s.ap)).map((s) => { + const x1 = tx(s.start); const x2 = Math.max(x1 + 3, tx(s.end || d.t1)); + const i = apOrder.indexOf(s.ap); + return ``; + }).join("")} + ${Array.from({ length: 5 }, (_, i) => d.t0 + ((d.t1 - d.t0) * i) / 4).map((x, i) => + `${E(fmtShort(x))}`).join("")} +
` : ""; + + // Disconnect / failure causes. + const reasons = [...d.reasonCounts].sort((a, b) => b[1] - a[1]).slice(0, 10); + const maxR = Math.max(1, ...reasons.map(([, n]) => n)); + const reasonHtml = reasons.length ? `
Disconnect and failure causes + ${E(d.negEvents.length)} of ${E(d.evs.length)} events
${reasons.map(([k, n]) => + `
${E(k)}${n}
`).join("")}
` : ""; + + ctx.mount.innerHTML = ` + +
${header}${tiles}${findingsHtml}${tsHtml} +

Radio and roaming

${airtime}${chanHtml}${roamHtml}${reasonHtml} + ${airtime || chanHtml || roamHtml || reasonHtml ? "" : `

No radio, session or event data was returned.

`}
+
`; + + // ---- Hover layer -------------------------------------------------------------- + const root = ctx.mount.querySelector(".phy"); + const tip = root.querySelector(".phy-tip"); + const show = (text, ev) => { + tip.textContent = text; + tip.style.display = "block"; + const x = Math.min(ev.clientX + 14, window.innerWidth - tip.offsetWidth - 8); + const y = ev.clientY + 16 + tip.offsetHeight > window.innerHeight ? ev.clientY - tip.offsetHeight - 12 : ev.clientY + 16; + tip.style.left = `${x}px`; + tip.style.top = `${y}px`; + }; + const hide = () => { tip.style.display = "none"; }; + root.addEventListener("mousemove", (ev) => { + const wrap = ev.target.closest?.("[data-chart]"); + if (wrap) { + const c = charts.get(wrap.dataset.chart); + const svg = wrap.querySelector("svg"); + const r = svg.getBoundingClientRect(); + const vx = ((ev.clientX - r.left) / r.width) * W; + if (vx < c.L || vx > W - c.R) { hide(); svg.querySelector(".x").setAttribute("visibility", "hidden"); return; } + const tq = d.t0 + ((vx - c.L) / (W - c.L - c.R)) * (d.t1 - d.t0); + const rows = c.lines.map((l) => { + let best = null; + for (const p of l.pts) if (!best || Math.abs(p[0] - tq) < Math.abs(best[0] - tq)) best = p; + return best ? { name: l.name, p: best } : null; + }).filter(Boolean); + if (!rows.length) return; + const near = rows[0].p[0]; + const x = c.sx(near); + const xl = svg.querySelector(".x"); + xl.setAttribute("x1", x); xl.setAttribute("x2", x); xl.setAttribute("visibility", "visible"); + const ev2 = c.markers.filter((m) => Math.abs(m.t - near) <= (d.duration === "7d" ? 1800 : 300)); + show(`${ctx.fmtTime(near)}\n${rows.map((rw) => `${rw.name}: ${fmtVal(rw.p[1], c.unit)}`).join("\n")}` + + (ev2.length ? `\n✕ ${ev2.length} failure event(s) nearby` : ""), ev); + return; + } + const t = ev.target.closest?.("[data-tip]"); + if (t) show(t.getAttribute("data-tip"), ev); else hide(); + }); + root.addEventListener("mouseleave", () => { + hide(); + root.querySelectorAll(".phy-chart svg .x").forEach((l) => l.setAttribute("visibility", "hidden")); + }); +} diff --git a/mist-toolbox/README.md b/mist-toolbox/README.md index aedfb33..cda8858 100644 --- a/mist-toolbox/README.md +++ b/mist-toolbox/README.md @@ -64,6 +64,9 @@ press **End session**, or whenever the tab closes or reloads. | **Switch Software Report** | `mist_switch_report.js` | Site · Org | Mist token + org | | **Switch Config Export** | `mist_ip_blocks.py --save-configs` | Site · Org | Mist token + org | | **Site Alarms** | `site-alarms.js` (added as written) | Site · Org | Mist token + org | +| **Client Wi-Fi PHY Inspector** | `client-wifi-phy.js` (added as written) | Site · Client | Mist token + org | +| **Switch PSU Status** | `switch-psu-status.js` (added as written) | Site · Org | Mist token + org | +| **Switch Additional CLI** | `switch-additional-cli.js` (added as written) | Site · Org | Mist token + org | | **Wi-Fi Clients Export** | `site-wifi-clients.py` | Site · Org | Mist token + org | | **IP Blocks / IRB Report** | `mist_ip_blocks.py` | Site · Org | Mist token + org | | **Switch Port Inventory** | `mist_switch_port_inventory.py` | Site · Org | Mist token + org | diff --git a/mist-toolbox/community/README.md b/mist-toolbox/community/README.md new file mode 100644 index 0000000..a5a71f2 --- /dev/null +++ b/mist-toolbox/community/README.md @@ -0,0 +1,22 @@ +# Community tools + +Tools contributed by Mist Toolbox users. They are not built in and do not ship in the release +ZIP. To add one to your toolbox, pick it from the table, then: + +1. Open the file on GitHub, click **Raw**, and save it as a `.js` file. +2. In the toolbox, go to **Manage tools → Install** and choose that file. It is checked first, + then it appears in the menu. + +Every tool here passes the same checks the Install panel runs, plus the policy tests on each +pull request: read-only Mist calls through `ctx`, no storage, no direct network access. Those +checks catch mistakes, not deliberate tricks. A tool runs with access to your Mist session, so +read it before you install it. + +Want to share your own? See **[CONTRIBUTING.md](../../CONTRIBUTING.md)**. + +## Tools + + + +| Tool | File | Level | Author | What it does | +|---|---|---|---|---| diff --git a/mist-toolbox/scripts/build-release.sh b/mist-toolbox/scripts/build-release.sh index a339e59..a9f364d 100755 --- a/mist-toolbox/scripts/build-release.sh +++ b/mist-toolbox/scripts/build-release.sh @@ -12,7 +12,7 @@ work="$(mktemp -d)" trap 'rm -rf "$work"' EXIT git -C "$here/.." archive HEAD mist-toolbox | tar -x -C "$work" -rm -rf "$work/mist-toolbox/tests" "$work/mist-toolbox/scripts" \ +rm -rf "$work/mist-toolbox/tests" "$work/mist-toolbox/scripts" "$work/mist-toolbox/community" \ "$work/mist-toolbox/docs/screenshots" "$work/mist-toolbox/package.json" rm -f "$out" (cd "$work" && zip -qr -X "$out" mist-toolbox) diff --git a/mist-toolbox/tests/client-wifi-phy.test.js b/mist-toolbox/tests/client-wifi-phy.test.js new file mode 100644 index 0000000..410d20e --- /dev/null +++ b/mist-toolbox/tests/client-wifi-phy.test.js @@ -0,0 +1,109 @@ +// Client Wi-Fi PHY Inspector, added as written: finding the client's site, +// grading at every threshold edge, offline fallback and the workbook. + +import { strict as assert } from "node:assert"; +import test from "node:test"; + +import tool from "../tools/client-wifi-phy.js"; +import { sheetNamed, stubMist, testCtx } from "./helpers.mjs"; + +const MAC = "aabbccddeeff"; +const SITES = [{ id: "s1", name: "HQ" }, { id: "s2", name: "Branch" }]; +const radio = (o) => ({ band_5: { channel: 36, bandwidth: 20, noise_floor: -95, util_all: 30, num_clients: 5, power: 17, ...o } }); +const APS = [ + { type: "ap", name: "ap-serving", mac: "0a0027000001", radio_stat: radio({ bandwidth: 80 }) }, + { type: "ap", name: "ap-overlap", mac: "0a0027000002", radio_stat: radio({ channel: 44 }) }, + { type: "ap", name: "ap-clear", mac: "0a0027000003", radio_stat: radio({ channel: 149 }) }, +]; +const LIVE = { + mac: MAC, hostname: "laptop-1", ssid: "corp", ap_mac: "0a0027000001", band: "5", channel: 36, + proto: "ax", rssi: -60, snr: 30, tx_rate: 1000, rx_rate: 400, tx_retries: 5, tx_pkts: 995, +}; + +const routes = (site = "s1", o = {}) => ({ + "/orgs/org-1/sites": SITES, + [`/sites/${site}/stats/clients/${MAC}`]: LIVE, + [`/sites/${site}/stats/devices`]: APS, + ...o, +}); + +const run = (params) => tool.run(testCtx({ params: { allSites: false, siteId: "s1", mac: "AA-BB-CC-DD-EE-FF", ...params } })); +const finding = (result, facet) => sheetNamed(result, "Findings").rows.find((r) => r.facet === facet); +const snap = (result, field) => sheetNamed(result, "PHY Snapshot").rows.find((r) => r[0] === field)?.[1]; + +test("a healthy live client: graded, matched to its AP, every sheet present", async () => { + stubMist(routes()); + const result = await run({}); + assert.deepEqual(result.sheets.map((s) => s.name), + ["Findings", "PHY Snapshot", "Time Series", "Sessions", "Events", "Channel Map", "Info"]); + assert.equal(finding(result, "Signal").sev, "Good"); + assert.equal(finding(result, "SNR").sev, "Good"); + assert.equal(finding(result, "Noise floor").sev, "Good"); + assert.equal(finding(result, "PHY rate").sev, "Good", "1000 of a 1201 Mbps HE80 2SS ceiling"); + assert.equal(finding(result, "Retries").sev, "Good"); + assert.equal(snap(result, "AP"), "ap-serving"); + assert.equal(snap(result, "Connected now"), "Yes"); + assert.equal(snap(result, "Overlapping AP radios at site"), 1, "ch 44/20 sits inside 36/80; 149 does not"); + assert.deepEqual(sheetNamed(result, "Channel Map").rows.map((r) => [r.ap, r.span, r.rel]), [ + ["ap-serving", "36–48", "Serving"], ["ap-overlap", "44–44", "Overlaps serving"], ["ap-clear", "149–149", "Clear"], + ]); + assert.match(result.summary, /^laptop-1 at HQ: Good\. 0 issue\(s\)/); + assert.match(result.filename, /^mist_client_phy_aabbccddeeff_Acme_Corp_/); +}); + +test("RSSI, SNR and noise grade correctly on each side of every cut", async () => { + const cases = { + rssi: [[-65, "Good"], [-66, "Warning"], [-70, "Warning"], [-71, "Serious"], [-75, "Serious"], [-76, "Critical"]], + snr: [[25, "Good"], [24, "Warning"], [20, "Warning"], [19, "Serious"], [15, "Serious"], [14, "Critical"]], + noise: [[-90, "Good"], [-89, "Warning"], [-85, "Warning"], [-84, "Serious"], [-80, "Serious"], [-79, "Critical"]], + }; + const facet = { rssi: "Signal", snr: "SNR", noise: "Noise floor" }; + for (const [metric, rows] of Object.entries(cases)) { + for (const [v, want] of rows) { + const live = metric === "noise" ? LIVE : { ...LIVE, [metric]: v }; + const aps = metric === "noise" ? [{ ...APS[0], radio_stat: radio({ bandwidth: 80, noise_floor: v }) }] : APS; + stubMist(routes("s1", { [`/sites/s1/stats/clients/${MAC}`]: live, "/sites/s1/stats/devices": aps })); + assert.equal(finding(await run({}), facet[metric]).sev, want, `${metric} ${v}`); + } + } +}); + +test("all sites: the org search picks the site that saw the client last", async () => { + const calls = stubMist(routes("s2", { + "/orgs/org-1/clients/search": { results: [ + { mac: MAC, site_id: "s1", last_seen: 100 }, { mac: MAC, site_id: "s2", last_seen: 200 }, + ] }, + })); + const result = await run({ allSites: true, siteId: undefined }); + assert.match(result.summary, / at Branch:/); + assert.ok(calls.some((c) => c.startsWith(`/sites/s2/stats/clients/${MAC}`))); +}); + +test("all sites: when the org search fails, each site is asked in turn", async () => { + stubMist(routes("s2", { "/orgs/org-1/clients/search": () => { throw new Error("forbidden"); } })); + const result = await run({ allSites: true, siteId: undefined }); + assert.match(result.summary, / at Branch:/); +}); + +test("a client no site has seen is a clear error", async () => { + stubMist(routes("s1", { "/orgs/org-1/clients/search": { results: [] } })); + await assert.rejects(run({ allSites: true, siteId: undefined }), /not seen at any site in the past day/); + await assert.rejects(run({ mac: "nope" }), /12 hex digits/); +}); + +test("an offline client falls back to its last-known record", async () => { + stubMist(routes("s1", { + [`/sites/s1/stats/clients/${MAC}`]: null, + "/sites/s1/clients/search": { results: [ + { mac: MAC, last_seen: 100, rssi: -50 }, + { mac: MAC, last_seen: 200, rssi: -72, band: "5", last_ap: "0a0027000001", hostname: "phone-9" }, + ] }, + })); + const result = await run({}); + const signal = finding(result, "Signal"); + assert.equal(signal.sev, "Serious"); + assert.match(signal.evidence, /RSSI -72 dBm \(last known/); + assert.equal(snap(result, "Connected now"), "No"); + assert.equal(snap(result, "AP"), "ap-serving"); + assert.match(result.summary, /^phone-9 at HQ: .*\(client offline, last-known values\)$/); +}); diff --git a/mist-toolbox/tests/community.test.js b/mist-toolbox/tests/community.test.js new file mode 100644 index 0000000..169a478 --- /dev/null +++ b/mist-toolbox/tests/community.test.js @@ -0,0 +1,44 @@ +// Contributed tools in community/ must pass the same gate the Install panel +// applies, load as valid tools, and be listed in the community README. CI runs +// this on every pull request, so a contribution fails by name before review. + +import { strict as assert } from "node:assert"; +import { readFile, readdir } from "node:fs/promises"; +import test from "node:test"; +import { dirname, join } from "node:path"; +import { fileURLToPath, pathToFileURL } from "node:url"; + +import { checkToolSource, toolFilename } from "../lib/toolcheck.js"; +import { BUILTIN_TOOLS, validateTool } from "../tools/registry.js"; + +const root = join(dirname(fileURLToPath(import.meta.url)), ".."); +const dir = join(root, "community"); + +const files = (await readdir(dir)).filter((f) => f.endsWith(".js")).sort(); + +async function builtinIds() { + const ids = new Set(); + for (const file of BUILTIN_TOOLS) ids.add((await import(pathToFileURL(join(root, "tools", file)).href)).default.id); + return ids; +} + +test("every community tool passes the Install checks and loads as a valid tool", async () => { + const taken = await builtinIds(); + for (const file of files) { + const src = await readFile(join(dir, file), "utf8"); + const { problems } = checkToolSource(src, file); + assert.deepEqual(problems, [], `community/${file}`); + + const tool = validateTool((await import(pathToFileURL(join(dir, file)).href)).default, file); + assert.equal(file, toolFilename(tool.id), `community/${file}: name the file after its id, ${toolFilename(tool.id)}`); + assert.ok(!taken.has(tool.id), `community/${file}: id "${tool.id}" is already used by another tool`); + taken.add(tool.id); + assert.ok(tool.description.length > 30, `community/${file}: description should actually describe the tool`); + } +}); + +test("every community tool is listed in community/README.md", async () => { + const readme = await readFile(join(dir, "README.md"), "utf8"); + const missing = files.filter((f) => !readme.includes(`(${f})`)); + assert.deepEqual(missing, [], `add a row linking each file, e.g. [${missing[0]}](${missing[0]})`); +}); diff --git a/mist-toolbox/tests/scope.test.js b/mist-toolbox/tests/scope.test.js index 744673c..d1c39ba 100644 --- a/mist-toolbox/tests/scope.test.js +++ b/mist-toolbox/tests/scope.test.js @@ -56,13 +56,16 @@ test("every built-in tool card says what level it works at", async () => { chips[tool.id] = scopeChip(tool); } assert.deepEqual(chips, { + "client-wifi-phy": "Site · Client", "disconnect-console": "Site · Client", "ip-blocks": "Site · Org", "port-inventory": "Site · Org", "ssid-report": "Site · Org", "site-alarms": "Site · Org", // its own checkbox + dropdown count "ssr-pre-post": "SSR Conductor", + "switch-additional-cli": "Site · Org", "switch-configs": "Site · Org", + "switch-psu-status": "Site · Org", "switch-report": "Site · Org", "wifi-clients": "Site · Org", }); diff --git a/mist-toolbox/tests/switch-additional-cli.test.js b/mist-toolbox/tests/switch-additional-cli.test.js new file mode 100644 index 0000000..41b793a --- /dev/null +++ b/mist-toolbox/tests/switch-additional-cli.test.js @@ -0,0 +1,112 @@ +// Switch Additional CLI, added as written: template, site and device levels, +// switch rules, de-duplication, the cell limit, scope and failing sites. + +import { strict as assert } from "node:assert"; +import test from "node:test"; + +import tool from "../tools/switch-additional-cli.js"; +import { sheetNamed, stubMist, testCtx } from "./helpers.mjs"; + +const SITES = [ + { id: "s1", name: "HQ", networktemplate_id: "t1" }, + { id: "s2", name: "Branch", networktemplate_id: "t1" }, +]; +const TEMPLATES = [{ + id: "t1", name: "Campus", + additional_config_cmds: ["set system ntp server 192.0.2.1"], + switch_matching: { rules: [{ name: "access", additional_config_cmds: ["set poe interface all"] }] }, +}]; + +const routes = (o = {}) => ({ + "/orgs/org-1/sites": SITES, + "/orgs/org-1/networktemplates": TEMPLATES, + "/sites/s1/setting": { + additional_config_cmds: ["set snmp location HQ", " ", "set system ntp server 192.0.2.1"], + switch_matching: { rules: [{ name: "core", additional_config_cmds: ["set chassis aggregated-devices"] }] }, + }, + "/sites/s1/devices": [ + { type: "switch", name: "sw-b", mac: "aabbcc000002", model: "EX4100" }, + { type: "switch", name: "sw-a", mac: "aabbcc000001", model: "EX4400", additional_config_cmds: ["set snmp location HQ"] }, + ], + "/sites/s2/setting": {}, + "/sites/s2/devices": [{ type: "switch", name: "br-1", mac: "aabbcc000003" }], + ...o, +}); + +const run = (params) => tool.run(testCtx({ params: { allSites: true, ...params } })); + +test("every level is collected, blanks dropped, rules named", async () => { + stubMist(routes()); + const result = await run({}); + const lines = sheetNamed(result, "CLI Lines").rows.map((r) => [r.level, r.site, r.rule, r.switch, r.command]); + assert.deepEqual(lines, [ + ["Template", "", "", "", "set system ntp server 192.0.2.1"], + ["Template rule", "", "access", "", "set poe interface all"], + ["Site", "HQ", "", "", "set snmp location HQ"], + ["Site", "HQ", "", "", "set system ntp server 192.0.2.1"], + ["Site rule", "HQ", "core", "", "set chassis aggregated-devices"], + ["Device", "HQ", "", "sw-a", "set snmp location HQ"], + ]); + assert.match(result.summary, /^6 command line\(s\), 4 distinct, across 2 site\(s\) and 3 switch\(es\)$/); + assert.match(result.filename, /^mist_switch_cli_all_sites_Acme_Corp_/); + assert.match(result.files[0].name, /^mist_switch_cli_all_sites_Acme_Corp_.*\.txt$/); +}); + +test("By Switch and By Site count each level", async () => { + stubMist(routes()); + const result = await run({}); + const sws = sheetNamed(result, "By Switch").rows; + assert.deepEqual(sws.map((r) => r.switch), ["br-1", "sw-a", "sw-b"], "sites by name, then switches by name"); + const a = sws.find((r) => r.switch === "sw-a"); + assert.deepEqual([a.template, a.tplLines, a.siteLines, a.devLines, a.__style], ["Campus", 2, 3, 1, "yellow"]); + assert.equal(a.mac, "aa:bb:cc:00:00:01"); + const hq = sheetNamed(result, "By Site").rows.find((r) => r.site === "HQ"); + assert.deepEqual([hq.siteLines, hq.siteRuleLines, hq.swWithCli, hq.devLines], [2, 1, 1, 1]); +}); + +test("Unique Commands merges levels and credits template lines to every site using it", async () => { + stubMist(routes()); + const rows = sheetNamed(await run({}), "Unique Commands").rows; + const ntp = rows.find((r) => r.command === "set system ntp server 192.0.2.1"); + assert.equal(ntp.levels, "Template, Site"); + assert.equal(ntp.siteCount, 2); + assert.equal(ntp.sites, "Branch, HQ"); + assert.deepEqual(rows.map((r) => r.siteCount), [2, 2, 1, 1], "most widely used first"); + assert.deepEqual(rows.slice(0, 2).map((r) => r.command), ["set poe interface all", ntp.command], "ties by command"); + assert.equal(rows.find((r) => r.command === "set snmp location HQ").switchCount, 1); + assert.deepEqual(sheetNamed(await run({}), "Templates").rows.map((r) => [r.rule, r.sites]), [["", 2], ["access", 2]]); +}); + +test("a cell past Excel's limit is clipped and marked", async () => { + const huge = Array.from({ length: 2000 }, (_, i) => `set interfaces ge-0/0/${i} description ${"x".repeat(20)}`); + stubMist(routes({ "/sites/s2/setting": { additional_config_cmds: huge } })); + const br = sheetNamed(await run({}), "By Site").rows.find((r) => r.site === "Branch"); + assert.ok(br.siteCli.length < 32100, String(br.siteCli.length)); + assert.ok(br.siteCli.endsWith("…(truncated)")); + assert.equal(br.siteLines, 2000, "the count is not clipped"); +}); + +test("one site: only that site is queried", async () => { + const calls = stubMist(routes()); + const result = await run({ allSites: false, siteId: "s2" }); + assert.deepEqual(sheetNamed(result, "By Switch").rows.map((r) => r.switch), ["br-1"]); + assert.ok(!calls.some((c) => c.startsWith("/sites/s1/"))); + assert.match(result.filename, /^mist_switch_cli_Branch_Acme_Corp_/); +}); + +test("a failing site or missing templates is not fatal", async () => { + stubMist(routes({ + "/sites/s1/setting": () => { throw new Error("boom"); }, + "/orgs/org-1/networktemplates": () => { throw new Error("nope"); }, + })); + const result = await run({}); + assert.deepEqual(sheetNamed(result, "Failed Sites").rows.map((r) => r.site), ["HQ"]); + assert.deepEqual(sheetNamed(result, "Templates").rows, []); + assert.equal(sheetNamed(result, "By Site").rows[0].template, "t1", "falls back to the template id"); + assert.match(result.summary, /1 site\(s\) failed/); +}); + +test("no site picked is a clear error", async () => { + stubMist(routes()); + await assert.rejects(run({ allSites: false }), /Pick a site/); +}); diff --git a/mist-toolbox/tests/switch-psu-status.test.js b/mist-toolbox/tests/switch-psu-status.test.js new file mode 100644 index 0000000..d3e24ab --- /dev/null +++ b/mist-toolbox/tests/switch-psu-status.test.js @@ -0,0 +1,99 @@ +// Switch PSU Status, added as written: PSU classing, per-member health with the +// worst member deciding a VC, scope, and failing sites. + +import { strict as assert } from "node:assert"; +import test from "node:test"; + +import tool from "../tools/switch-psu-status.js"; +import { sheetNamed, stubMist, testCtx } from "./helpers.mjs"; + +const SITES = [{ id: "s1", name: "HQ" }, { id: "s2", name: "Branch" }]; +const psus = (...st) => st.map((status, i) => ({ name: `Power Supply ${i}`, status })); +const sw = (o) => ({ type: "switch", status: "connected", model: "EX4400-48P", ...o }); + +const routes = (o = {}) => ({ + "/orgs/org-1/sites": SITES, + "/sites/s1/stats/devices": [ + sw({ name: "core", mac: "aabbcc000001", module_stat: [{ fpc_idx: 0, psus: psus("ok", "ok") }] }), + sw({ name: "edge", mac: "aabbcc000002", module_stat: [{ fpc_idx: 0, psus: psus("OK", "absent") }] }), + // VC: member 0 redundant, member 1 has a failed supply -> the switch is failed. + sw({ name: "vc", mac: "aabbcc000003", module_stat: [ + { fpc_idx: 1, vc_role: "backup", serial: "B", psus: psus("Online", "Check") }, + { fpc_idx: 0, vc_role: "master", serial: "A", psus: psus("ok", "ok") }, + ] }), + sw({ name: "dark", mac: "aabbcc000004", status: "disconnected", module_stat: [] }), + { type: "ap", name: "not-a-switch" }, + ], + "/sites/s2/stats/devices": [ + sw({ name: "br", mac: "aabbcc000005", module_stat: [{ fpc_idx: 0, psus: psus("ok", "weird") }] }), + ], + ...o, +}); + +const run = (params) => tool.run(testCtx({ params: { allSites: true, includeOffline: true, ...params } })); + +test("all sites: each switch graded, worst VC member wins, failures sort first", async () => { + stubMist(routes()); + const result = await run({}); + const rows = sheetNamed(result, "Switches").rows; + const health = Object.fromEntries(rows.map((r) => [r.name, r.health])); + assert.deepEqual(health, { + core: "Redundant", edge: "Not redundant", vc: "PSU failed", dark: "No PSU data", br: "Not redundant", + }); + assert.equal(rows[0].name, "vc", "failed switches lead the sheet"); + const vc = rows.find((r) => r.name === "vc"); + assert.equal(vc.members, 2); + assert.equal(vc.detail, "FPC 0: Power Supply 0=ok, Power Supply 1=ok\nFPC 1: Power Supply 0=Online, Power Supply 1=Check"); + assert.equal(rows.find((r) => r.name === "edge").absent, 1); + assert.equal(rows.find((r) => r.name === "br").unknown, 1); + assert.ok(!rows.some((r) => r.name === "not-a-switch")); + assert.match(result.summary, /^5 switch\(es\): 1 with a failed PSU, 2 not redundant, 1 redundant$/); + assert.match(result.filename, /^mist_switch_psu_all_sites_Acme_Corp_/); +}); + +test("PSU sheet keeps the raw text and the class", async () => { + stubMist(routes()); + const rows = sheetNamed(await run({}), "PSUs").rows; + const check = rows.find((r) => r.raw === "Check"); + assert.equal(check.cls, "Failed"); + assert.equal(check.member, 1); + assert.equal(check.role, "backup"); + assert.equal(check.__style, "red"); + assert.equal(rows.find((r) => r.raw === "weird").cls, "Unknown"); + assert.equal(rows.find((r) => r.raw === "absent").cls, "Empty slot"); +}); + +test("By Site puts the site with a failure first", async () => { + stubMist(routes()); + const rows = sheetNamed(await run({}), "By Site").rows; + assert.deepEqual(rows.map((r) => r.site), ["HQ", "Branch"]); + assert.deepEqual({ ...rows[0], __style: undefined }, + { site: "HQ", total: 4, failed: 1, single: 1, nodata: 1, redundant: 1, offline: 1, __style: undefined }); +}); + +test("disconnected switches can be left out", async () => { + stubMist(routes()); + const rows = sheetNamed(await run({ includeOffline: false }), "Switches").rows; + assert.ok(!rows.some((r) => r.name === "dark")); +}); + +test("one site: only that site is queried", async () => { + const calls = stubMist(routes()); + const result = await run({ allSites: false, siteId: "s2" }); + assert.deepEqual(sheetNamed(result, "Switches").rows.map((r) => r.name), ["br"]); + assert.ok(!calls.some((c) => c.startsWith("/sites/s1/"))); + assert.match(result.filename, /^mist_switch_psu_Branch_Acme_Corp_/); +}); + +test("a failing site is listed, not fatal", async () => { + stubMist(routes({ "/sites/s2/stats/devices": () => { throw new Error("boom"); } })); + const result = await run({}); + assert.deepEqual(sheetNamed(result, "Failed Sites").rows.map((r) => r.site), ["Branch"]); + assert.equal(sheetNamed(result, "Switches").rows.length, 4); + assert.match(result.summary, /1 site\(s\) failed/); +}); + +test("no site picked is a clear error", async () => { + stubMist(routes()); + await assert.rejects(run({ allSites: false }), /Pick a site/); +}); diff --git a/mist-toolbox/tools/client-wifi-phy.js b/mist-toolbox/tools/client-wifi-phy.js new file mode 100644 index 0000000..10c8bd4 --- /dev/null +++ b/mist-toolbox/tools/client-wifi-phy.js @@ -0,0 +1,1035 @@ +// Client Wi-Fi PHY Inspector: every physical-layer facet of one wireless +// client's connection, graded against published thresholds and drawn as a +// colour-coded dashboard, plus an .xlsx of the raw numbers. +// +// Data (all GET, site found automatically when "All sites in the org" is ticked): +// /orgs/{org}/clients/search?mac= which site saw the client last +// /sites/{site}/stats/clients/{mac} live RSSI, SNR, rates, retries, band, proto +// /sites/{site}/clients/search?mac= last-known record if it is offline +// /sites/{site}/clients/{mac}/events joins, roams, deauths + 802.11 reason codes +// /sites/{site}/clients/sessions/search?mac= per-AP sessions (roaming timeline) +// /sites/{site}/stats/devices?type=ap serving radio: channel, width, power, +// noise floor, utilisation; site channel map +// /const/insight_metrics, then +// /sites/{site}/insights/client/{mac}/{metric} time series (path form; the ?metrics= form 404s) +// +// Grading thresholds (edit THRESHOLDS below to suit your own design targets): +// RSSI >= -65 good | -65..-70 warning | -70..-75 serious | < -75 critical (dBm) +// -67 dBm is the common voice/video design target; the toolbox's +// Disconnect Console treats < -75 dBm as a coverage-hole signature. +// SNR >= 25 good | 20..25 warning | 15..20 serious | < 15 critical (dB) +// Noise <= -90 good | -90..-85 warning | -85..-80 serious | > -80 critical (dBm) +// Retries, channel utilisation, non-Wi-Fi share, PHY-rate efficiency: see THRESHOLDS. + +import { deviceRadioMacs } from "../engine/normalize.js"; +import { bandGroup, describeReason, isNegative, normalizeMac, num, utilPct } from "../engine/util.js"; + +// ---- Thresholds and palette --------------------------------------------- + +// cuts = [good-from, warning-from, serious-from]; hb = higher is better. +const THRESHOLDS = { + rssi: { cuts: [-65, -70, -75], hb: true, min: -95, max: -30, unit: "dBm" }, + snr: { cuts: [25, 20, 15], hb: true, min: 0, max: 50, unit: "dB" }, + noise: { cuts: [-90, -85, -80], hb: false, min: -100, max: -60, unit: "dBm" }, + retry: { cuts: [10, 20, 30], hb: false, min: 0, max: 50, unit: "%" }, + util: { cuts: [50, 70, 85], hb: false, min: 0, max: 100, unit: "%" }, + nonwifi: { cuts: [10, 20, 35], hb: false, min: 0, max: 60, unit: "%" }, + eff: { cuts: [60, 40, 20], hb: true, min: 0, max: 100, unit: "%" }, + cochannel: { cuts: [1, 3, 6], hb: false, min: 0, max: 10, unit: "APs" }, + load: { cuts: [30, 50, 75], hb: false, min: 0, max: 100, unit: "clients" }, +}; + +// Status palette (fixed, never reused for series) and dark categorical slots. +const STATUS = { + critical: { color: "#d03b3b", icon: "✕", label: "Critical" }, + serious: { color: "#ec835a", icon: "▲", label: "Serious" }, + warning: { color: "#fab219", icon: "!", label: "Warning" }, + good: { color: "#0ca30c", icon: "✓", label: "Good" }, + unknown: { color: "#6e7480", icon: "–", label: "No data" }, +}; +const RANK = { critical: 0, serious: 1, warning: 2, good: 3, unknown: 4 }; +const SERIES = ["#3987e5", "#d95926", "#199e70", "#c98500", "#d55181", "#008300", "#9085e9", "#e66767"]; +const BAND = { + 24: { label: "2.4 GHz", color: SERIES[1] }, + 5: { label: "5 GHz", color: SERIES[0] }, + 6: { label: "6 GHz", color: SERIES[2] }, + unk: { label: "Unknown band", color: "#6e7480" }, +}; + +// Highest single-stream PHY rate (Mbps) by generation and channel width: +// HE MCS11 / EHT MCS13 at 0.8 us GI, VHT MCS9 / HT MCS7 at short GI. +const PER_STREAM_MAX = { + be: { 20: 172.1, 40: 344.1, 80: 720.6, 160: 1441.2, 320: 2882.4 }, + ax: { 20: 143.4, 40: 286.8, 80: 600.5, 160: 1201 }, + ac: { 20: 86.7, 40: 200, 80: 433.3, 160: 866.7 }, + n: { 20: 72.2, 40: 150 }, +}; +const GENERATION = { + be: { label: "Wi-Fi 7 (802.11be)", sev: "good" }, + ax: { label: "Wi-Fi 6/6E (802.11ax)", sev: "good" }, + ac: { label: "Wi-Fi 5 (802.11ac)", sev: "good" }, + n: { label: "Wi-Fi 4 (802.11n)", sev: "warning" }, + a: { label: "802.11a (legacy)", sev: "serious" }, + g: { label: "802.11g (legacy)", sev: "serious" }, + b: { label: "802.11b (legacy)", sev: "critical" }, +}; +const ASSUMED_STREAMS = 2; + +// 20 MHz channel plans. A wider channel occupies an aligned block of these. +const PLAN_5 = [36, 40, 44, 48, 52, 56, 60, 64, 100, 104, 108, 112, 116, 120, 124, 128, 132, 136, 140, 144, + 149, 153, 157, 161, 165, 169, 173, 177]; +const SEGMENTS_5 = [[36, 64], [100, 144], [149, 177]]; +const DFS_5 = [52, 144]; + +/** [lowest, highest] 20 MHz channel a radio occupies, from its primary channel and width. */ +function span(band, ch, width) { + const n = Math.max(1, Math.round((width || 20) / 20)); + if (band === "24") return [Math.max(1, ch - 2), Math.min(14, ch + (n > 1 ? 6 : 2))]; + if (band === "5") { + const seg = SEGMENTS_5.find(([lo, hi]) => ch >= lo && ch <= hi); + if (!seg) return [ch, ch]; + const chans = PLAN_5.filter((c) => c >= seg[0] && c <= seg[1]); + const p = chans.indexOf(ch); + if (p < 0) return [ch, ch]; + const start = Math.floor(p / n) * n; + return [chans[start], chans[Math.min(chans.length - 1, start + n - 1)]]; + } + if (band === "6") { + const start = Math.floor((ch - 1) / 4 / n) * n; + return [1 + start * 4, 1 + (start + n - 1) * 4]; + } + return [ch, ch]; +} +const overlaps = (a, b) => a[0] <= b[1] && b[0] <= a[1]; + +function channelPlan(band, occ) { + const hi = Math.max(0, ...occ.map((o) => o.span[1])); + const lo = Math.min(...occ.map((o) => o.span[0])); + if (band === "24") return Array.from({ length: hi > 13 ? 14 : 13 }, (_, i) => i + 1); + if (band === "5") return PLAN_5.filter((c) => c <= Math.max(165, hi)); + if (band === "6") { + const all = Array.from({ length: 59 }, (_, i) => 1 + i * 4); + return all.filter((c) => c >= lo - 16 && c <= hi + 16); + } + return []; +} + +// ---- Small helpers ------------------------------------------------------- + +const list = (v) => (Array.isArray(v) ? v : v == null || v === "" ? [] : [v]); +const first = (v) => (Array.isArray(v) ? v.find((x) => x != null && x !== "") : v); +const epoch = (t) => { const n = num(t); return n === null ? null : n > 1e12 ? n / 1000 : n; }; +const pick = (...vals) => { for (const v of vals) { const f = first(v); if (f != null && f !== "") return f; } return null; }; +const pct = (part, whole) => (whole > 0 ? (part / whole) * 100 : null); +const round = (v, d = 1) => (v == null ? null : Math.round(v * 10 ** d) / 10 ** d); +const results = (res) => (Array.isArray(res) ? res : list(res?.results ?? res?.data)); +const worst = (sevs) => sevs.reduce((w, s) => (RANK[s] < RANK[w] ? s : w), "unknown"); + +// Axis ticks on round steps (1, 2, 2.5, 5 x 10^n) inside [lo, hi]. +function niceTicks(lo, hi, count = 5) { + const raw = (hi - lo) / (count - 1) || 1; + const mag = 10 ** Math.floor(Math.log10(raw)); + const step = [1, 2, 2.5, 5, 10].map((m) => m * mag).find((s) => s >= raw); + const out = []; + for (let v = Math.ceil(lo / step) * step; v <= hi + step * 1e-6; v += step) out.push(Math.round(v * 1e6) / 1e6); + return out; +} + +function grade(v, t) { + if (v == null || Number.isNaN(v)) return "unknown"; + const [a, b, c] = t.cuts; + if (t.hb) return v >= a ? "good" : v >= b ? "warning" : v >= c ? "serious" : "critical"; + return v <= a ? "good" : v <= b ? "warning" : v <= c ? "serious" : "critical"; +} + +function zones(t) { + const [a, b, c] = t.cuts; + return t.hb + ? [{ from: t.min, to: c, sev: "critical" }, { from: c, to: b, sev: "serious" }, + { from: b, to: a, sev: "warning" }, { from: a, to: t.max, sev: "good" }] + : [{ from: t.min, to: a, sev: "good" }, { from: a, to: b, sev: "warning" }, + { from: b, to: c, sev: "serious" }, { from: c, to: t.max, sev: "critical" }]; +} + +function protoKey(p) { + const s = String(p || "").toLowerCase().replace(/^802\.11/, ""); + for (const k of ["be", "ax", "ac", "n", "a", "g", "b"]) if (s === k || s.endsWith(k)) return k; + return ""; +} + +function phyCeiling(proto, width, streams) { + if (proto === "a" || proto === "g") return 54; + if (proto === "b") return 11; + const table = PER_STREAM_MAX[proto]; + if (!table) return null; + const w = table[width] ? width : Math.max(...Object.keys(table).map(Number).filter((x) => x <= (width || 20)), 20); + return table[w] ? table[w] * Math.min(Math.max(streams, 1), 4) : null; +} + +// Insight responses vary by metric: {rt:[...], results:[...]}, {rt, :[...]}, +// or results:[{timestamp, ...}]. Pull every numeric series out of whatever came back. +function parseSeries(res, metric) { + const out = []; + if (!res || typeof res !== "object") return out; + const toPts = (ts, vals) => ts.map((t, i) => [epoch(t), num(vals[i])]).filter(([t, v]) => t !== null && v !== null); + const rt = Array.isArray(res.rt) ? res.rt : null; + if (rt) { + for (const [k, arr] of Object.entries(res)) { + if (k === "rt" || !Array.isArray(arr) || arr.length !== rt.length) continue; + const base = k === "results" ? metric : `${metric}.${k}`; + if (arr.some((x) => x && typeof x === "object")) { + const keys = new Set(); + arr.forEach((o) => o && typeof o === "object" && Object.keys(o).forEach((kk) => keys.add(kk))); + for (const kk of keys) { + const pts = toPts(rt, arr.map((o) => o?.[kk])); + const name = `${base}.${kk}`; + if (pts.length) out.push({ name, pts }); + } + } else { + const pts = toPts(rt, arr); + const name = base; + if (pts.length) out.push({ name, pts }); + } + } + } else if (Array.isArray(res.results)) { + const keys = new Set(); + res.results.forEach((o) => o && typeof o === "object" && Object.keys(o).forEach((k) => keys.add(k))); + keys.delete("timestamp"); + for (const k of keys) { + const pts = res.results.map((o) => [epoch(o?.timestamp), num(o?.[k])]).filter(([t, v]) => t !== null && v !== null); + const name = k === "value" ? metric : `${metric}.${k}`; + if (pts.length) out.push({ name, pts }); + } + } + return out; +} + +// tx_rate + rx_rate share a chart (same unit, one axis); rssi and snr get their own. +function family(name) { + const n = name.toLowerCase(); + if (/rssi/.test(n)) return "rssi"; + if (/snr/.test(n)) return "snr"; + if (/retr/.test(n)) return "retries"; + if (/bps/.test(n)) return "bps"; + if (/rate|mcs/.test(n)) return "rate"; + if (/bytes/.test(n)) return "bytes"; + if (/pkts|packets/.test(n)) return "pkts"; + return n.replace(/(^|[._])(tx|rx)(?=[._]|$)/g, "$1").replace(/[._]+/g, "_").replace(/^_|_$/g, "") || n; +} +const FAMILY_META = { + rssi: { title: "Signal strength (RSSI)", unit: "dBm", t: THRESHOLDS.rssi }, + snr: { title: "Signal-to-noise ratio (SNR)", unit: "dB", t: THRESHOLDS.snr }, + rate: { title: "PHY data rate", unit: "Mbps" }, + bps: { title: "Throughput", unit: "bps" }, + retries: { title: "Retries", unit: "" }, + pkts: { title: "Packets", unit: "" }, + bytes: { title: "Bytes", unit: "bytes" }, +}; +const FAMILY_ORDER = ["rssi", "snr", "rate", "bps", "retries", "pkts", "bytes"]; + +// ---- The tool -------------------------------------------------------------- + +export default { + id: "client-wifi-phy", + name: "Client Wi-Fi PHY Inspector", + description: + "Examines every physical-layer facet of one wireless client: RSSI, SNR, noise floor, PHY rate " + + "against its ceiling, retries, band, Wi-Fi generation, channel load and interference, co-channel " + + "APs and roaming, with colour-coded charts that highlight what is wrong.", + tag: "Mist API", + level: "Site · Client", + notice: "Shows client identity (hostname, IP, username). Treat the export as personal data.", + needs: { mistToken: true, org: true }, + scope: "site", + params: [ + { + id: "mac", + label: "Client MAC", + placeholder: "aa:bb:cc:dd:ee:ff", + hint: "Any format. With \"All sites in the org\" ticked, the tool finds the client's site.", + }, + { + id: "duration", + type: "select", + label: "Look back", + options: [ + { value: "1d", label: "Past 1 day" }, + { value: "7d", label: "Past 7 days" }, + ], + default: "1d", + }, + ], + + async run(ctx) { + const mac = normalizeMac(ctx.params.mac); + const duration = ctx.params.duration === "7d" ? "7d" : "1d"; + const durS = duration === "7d" ? 7 * 86400 : 86400; + const now = Date.now() / 1000; + const scope = await ctx.targetSites(); + const siteName = (id) => scope.orgSites.find((s) => s.id === id)?.name || id; + + // ---- 1. Which site? ----------------------------------------------------- + let siteId = scope.all ? null : scope.sites[0].id; + if (!siteId) { + ctx.status("Finding the client…"); + let hits = []; + try { + hits = results(await ctx.searchAll(`/orgs/${ctx.orgId}/clients/search`, { mac, duration, limit: 100 })); + } catch (e) { + ctx.log(`Org-wide client search unavailable (${e.message}); checking each site`, "info"); + let done = 0; + const per = await ctx.pool(ctx.POOL_LIMIT, scope.sites.map((s) => async () => { + if (ctx.signal.aborted) return null; + try { const r = await ctx.mistGet(`/sites/${s.id}/stats/clients/${mac}`); return r ? { ...r, site_id: s.id } : null; } + catch { return null; } finally { ctx.progress(++done, scope.sites.length, s.name); } + })); + hits = per.filter(Boolean); + } + hits = hits.filter((h) => h?.site_id && scope.orgSites.some((s) => s.id === h.site_id)) + .sort((a, b) => (epoch(pick(b.last_seen, b.timestamp)) || 0) - (epoch(pick(a.last_seen, a.timestamp)) || 0)); + if (!hits.length) throw new Error(`Client ${formatMac(mac)} was not seen at any site in the ${duration === "7d" ? "past 7 days" : "past day"}.`); + siteId = hits[0].site_id; + ctx.log(`Client found at ${siteName(siteId)}`, "ok"); + } + if (ctx.signal.aborted) throw new Error("Cancelled."); + + // ---- 2. Collect ----------------------------------------------------------- + ctx.status(`Reading ${formatMac(mac)} at ${siteName(siteId)}…`); + const soft = async (label, fn) => { + try { return await fn(); } catch (e) { ctx.log(`${label}: ${e.message}`, "info"); return null; } + }; + const [stats, search, events, sessions, aps, catalog] = await Promise.all([ + soft("Live stats", () => ctx.mistGet(`/sites/${siteId}/stats/clients/${mac}`)), + soft("Client history", () => ctx.searchAll(`/sites/${siteId}/clients/search`, { mac, duration, limit: 100 })), + soft("Client events", () => ctx.mistGet(`/sites/${siteId}/clients/${mac}/events`, { duration, limit: 1000 })), + soft("Sessions", () => ctx.searchAll(`/sites/${siteId}/clients/sessions/search`, { mac, duration, limit: 100 })), + soft("AP stats", () => ctx.getAll(`/sites/${siteId}/stats/devices`, { type: "ap" })), + soft("Insight metric list", () => ctx.mistGet("/const/insight_metrics")), + ]); + ctx.progress(1, 3, "Client data"); + + const hist = results(search).sort((a, b) => (epoch(pick(b.last_seen, b.timestamp)) || 0) - (epoch(pick(a.last_seen, a.timestamp)) || 0)); + const last = hist[0] || {}; + const live = stats && typeof stats === "object" && !Array.isArray(stats) && Object.keys(stats).length ? stats : null; + const c = live || {}; + + // ---- 3. Insight time series --------------------------------------------- + const PHY_RE = /rssi|snr|rate|bps|retr|mcs|stream|noise|util|pkts|bytes|power/i; + let metrics = []; + if (catalog && typeof catalog === "object") { + const entries = Array.isArray(catalog) + ? catalog.map((m) => [m?.key || m?.name || m?.metric, m]) + : Object.entries(catalog); + metrics = entries + .filter(([k, m]) => k && (!m?.scopes || list(m.scopes).map(String).includes("client"))) + .filter(([, m]) => !m?.type || /time/i.test(String(m.type))) + .map(([k]) => String(k)); + const phy = metrics.filter((m) => PHY_RE.test(m)); + metrics = (phy.length ? phy : metrics).slice(0, 14); + } + if (!metrics.length) { + metrics = ["rssi", "snr", "tx_rate", "rx_rate", "tx_bps", "rx_bps", "tx_retries", "rx_retries", "tx_bytes", "rx_bytes"]; + } + ctx.status("Reading client time series…"); + const interval = duration === "7d" ? 3600 : 600; + let missing = 0; + const seriesSets = await ctx.pool(ctx.POOL_LIMIT, metrics.map((m) => async () => { + if (ctx.signal.aborted) return []; + try { + const res = await ctx.mistGet(`/sites/${siteId}/insights/client/${mac}/${encodeURIComponent(m)}`, { duration, interval }); + return parseSeries(res, m); + } catch { missing += 1; return []; } + })); + if (ctx.signal.aborted) throw new Error("Cancelled."); + const series = seriesSets.flat().filter((s) => s.pts.length >= 2); + if (missing) ctx.log(`${missing} of ${metrics.length} insight metric(s) returned nothing for this client`, "info"); + ctx.progress(2, 3, "Time series"); + + // RSSI/SNR fallback: per-record readings in events and history. + const harvest = (rows, field) => rows.map((r) => [epoch(pick(r.timestamp, r.last_seen)), num(r?.[field])]) + .filter(([t, v]) => t !== null && v !== null && t >= now - durS); + const evRows = results(events); + for (const f of ["rssi", "snr"]) { + if (!series.some((s) => family(s.name) === f)) { + const pts = [...harvest(evRows, f), ...harvest(hist, f)].sort((a, b) => a[0] - b[0]); + if (pts.length >= 2) series.push({ name: `${f} (event readings)`, pts }); + } + } + + // ---- 4. Shape ------------------------------------------------------------- + const apList = (aps || []).filter((d) => !d.type || d.type === "ap"); + const hex = (m) => String(m || "").toLowerCase().replace(/[^0-9a-f]/g, ""); + // Mist names the client's AP by base MAC or by a radio BSSID; match either. + const apKeys = apList.map((a) => ({ a, macs: new Set([hex(a.mac), ...deviceRadioMacs(a)]) })); + const findAp = (m) => { + const h = hex(m); + if (h.length !== 12) return null; + const exact = apKeys.find((k) => k.macs.has(h)); + if (exact) return exact.a; + // A BSSID is the AP's base MAC plus a small offset in the low bits. APs from + // one batch share most of their MAC, so take the closest base MAC at or + // below the BSSID, within 32 addresses. + const low = (x) => parseInt(x.slice(6), 16); + let best = null; + for (const k of apKeys) { + const m = hex(k.a.mac); + if (m.slice(0, 6) !== h.slice(0, 6)) continue; + const diff = low(h) - low(m); + if (diff >= 0 && diff < 32 && (!best || diff < best.diff)) best = { a: k.a, diff }; + } + return best ? best.a : null; + }; + const apCandidates = [c.ap_mac, c.ap, c.bssid, last.last_ap, last.ap, last.bssid].map(first).filter(Boolean); + const servingAp = apCandidates.map(findAp).find(Boolean) || null; + const apMac = servingAp ? hex(servingAp.mac) : hex(apCandidates[0]); + if (!servingAp && apCandidates.length) ctx.log(`Serving AP ${formatMac(apCandidates[0])} not found in this site's AP stats`, "info"); + const apName = (m) => findAp(m)?.name || (m ? formatMac(m) : ""); + + const band = bandGroup(pick(c.band, last.band, last.last_band)); + const radio = servingAp?.radio_stat?.[`band_${band}`] || null; + const proto = protoKey(pick(c.proto, c.protocol, last.proto, last.last_proto)); + const streamsKnown = num(pick(c.num_streams, c.streams, c.nss)); + const streams = streamsKnown || ASSUMED_STREAMS; + + const rssi = num(pick(c.rssi, last.rssi, last.last_rssi)); + const snr = num(pick(c.snr, last.snr, last.last_snr)); + const noise = num(pick(radio?.noise_floor, c.noise_floor)); + const txRate = num(c.tx_rate); + const rxRate = num(c.rx_rate); + const bestRate = Math.max(txRate || 0, rxRate || 0) || null; + + // Channel width: reported, or else the narrowest width whose ceiling covers + // the rate the client actually achieved (a 573 Mbps HE link is at least 80 MHz). + let width = num(pick(c.channel_width, c.bandwidth, radio?.bandwidth)) || (band === "24" ? 20 : null); + const widthInferred = !width; + if (!width) width = [20, 40, 80, 160, 320].find((w) => (phyCeiling(proto, w, streams) || 0) >= (bestRate || 0)) || 20; + const ceiling = phyCeiling(proto, width, streams); + const eff = bestRate && ceiling ? Math.min(100, pct(bestRate, ceiling)) : null; + const widthLabel = `${width} MHz${widthInferred ? " (inferred)" : ""}`; + const txRetries = num(c.tx_retries); const rxRetries = num(c.rx_retries); + const txPkts = num(c.tx_pkts); const rxPkts = num(c.rx_pkts); + const txRetryPct = txRetries != null && txPkts ? pct(txRetries, txPkts + txRetries) : null; + const rxRetryPct = rxRetries != null && rxPkts ? pct(rxRetries, rxPkts + rxRetries) : null; + const retryPct = [txRetryPct, rxRetryPct].filter((v) => v != null).reduce((m, v) => Math.max(m, v), -1); + const u = (k) => (radio && radio[k] != null ? utilPct(radio[k]) : null); + const util = { + all: u("util_all"), tx: u("util_tx"), inBss: u("util_rx_in_bss"), otherBss: u("util_rx_other_bss"), + unknownWifi: u("util_unknown_wifi"), nonWifi: u("util_non_wifi"), + }; + const channel = num(pick(c.channel, radio?.channel, last.channel)); + const radioClients = num(radio?.num_clients); + + // Channel occupancy: every AP radio in the client's band, as the 20 MHz channels + // it spans. Co-channel means any overlap with the serving radio, not just the + // same primary channel (an 80 MHz radio on 36 also occupies 40, 44 and 48). + const occ = apList.map((a) => { + const r = a.radio_stat?.[`band_${band}`]; + const ch = num(r?.channel); + if (ch == null) return null; + const w = num(r?.bandwidth) || 20; + return { + name: a.name || formatMac(a.mac), mac: hex(a.mac), ch, width: w, span: span(band, ch, w), + clients: num(r?.num_clients), util: r?.util_all != null ? utilPct(r.util_all) : null, serving: a === servingAp, + }; + }).filter(Boolean); + if (channel != null && !occ.some((o) => o.serving)) { + occ.push({ name: servingAp?.name || (apMac ? formatMac(apMac) : "Serving AP"), mac: apMac, ch: channel, + width: width || 20, span: span(band, channel, width || 20), clients: radioClients, util: util.all, serving: true }); + } + const servingOcc = occ.find((o) => o.serving) || null; + const coChannel = servingOcc ? occ.filter((o) => !o.serving && overlaps(o.span, servingOcc.span)) : []; + const samePrimary = servingOcc ? coChannel.filter((o) => o.ch === servingOcc.ch).length : 0; + + const evs = evRows.map((e) => { + const reason = pick(e.reason_code, e.reason); + return { + t: epoch(e.timestamp) || 0, type: String(e.type || "unknown"), text: String(e.text || ""), + ap: e.ap || "", band: bandGroup(e.band), channel: e.channel ?? "", reason, + reasonText: describeReason(reason) || "", negative: isNegative(e.type || "", e.text || ""), + }; + }).sort((a, b) => a.t - b.t); + const negEvents = evs.filter((e) => e.negative); + const reasonCounts = new Map(); + for (const e of negEvents) { + const k = e.reasonText ? `Reason ${e.reasonText}` : e.type.replace(/^CLIENT_|^MARVIS_EVENT_CLIENT_/, "").replace(/_/g, " ").toLowerCase(); + reasonCounts.set(k, (reasonCounts.get(k) || 0) + 1); + } + const authFails = negEvents.filter((e) => ["2", "15", "23"].includes(String(e.reason)) || /4-way|handshake|auth.*fail/i.test(`${e.type} ${e.text}`)); + + const sess = results(sessions).map((s) => ({ + ap: ((m) => (findAp(m) ? hex(findAp(m).mac) : hex(m)))(pick(s.ap, s.ap_mac, s.bssid)), band: bandGroup(s.band), ssid: String(pick(s.ssid) || ""), + start: epoch(s.connect), end: epoch(s.disconnect), dur: num(s.duration), + })).filter((s) => s.start).sort((a, b) => a.start - b.start); + let pingPong = 0; + for (let i = 2; i < sess.length; i++) { + if (sess[i].ap === sess[i - 2].ap && sess[i].ap !== sess[i - 1].ap && sess[i].start - sess[i - 2].start <= 300) pingPong += 1; + } + const roams = sess.filter((s, i) => i > 0 && s.ap !== sess[i - 1].ap).length; + const shortSess = sess.filter((s) => (s.dur ?? ((s.end || now) - s.start)) < 60).length; + const spanH = sess.length ? Math.max(1, ((sess.at(-1).end || now) - sess[0].start) / 3600) : 1; + const bandsUsed = new Set(sess.map((s) => s.band).filter((b) => b !== "unk")); + + const rssiPts = series.filter((s) => family(s.name) === "rssi").flatMap((s) => s.pts.map((p) => p[1])); + const weakShare = rssiPts.length ? pct(rssiPts.filter((v) => v < THRESHOLDS.rssi.cuts[1]).length, rssiPts.length) : null; + + // ---- 5. Findings ------------------------------------------------------------ + const findings = []; + const add = (sev, facet, title, evidence, advice) => findings.push({ sev, facet, title, evidence, advice }); + const lastKnown = live ? "" : " (last known; client is not connected now)"; + + const gR = grade(rssi, THRESHOLDS.rssi); + if (gR === "unknown") add("unknown", "Signal", "No RSSI reported", "Mist returned no RSSI for this client.", ""); + else add(gR, "Signal", gR === "good" ? "Strong signal" : gR === "warning" ? "Signal below the -65 dBm comfort zone" : gR === "serious" ? "Weak signal for voice and video" : "Coverage hole: signal below -75 dBm", + `RSSI ${rssi} dBm${lastKnown}.`, + gR === "good" ? "" : "Below -67 dBm real-time media starts to suffer; below -75 dBm expect retries, low MCS and sticky-client behaviour. Check AP placement and power, and whether the client is holding on to a distant AP."); + if (weakShare != null && weakShare >= 20 && RANK[gR] >= RANK.warning) { + add(weakShare >= 50 ? "serious" : "warning", "Signal", "Signal is often weak over the period", + `${round(weakShare, 0)}% of RSSI samples were below ${THRESHOLDS.rssi.cuts[1]} dBm even though the current value is ${rssi ?? "unknown"} dBm.`, + "Intermittent weak signal points at movement through a coverage gap or a client that roams late."); + } + + const gS = grade(snr, THRESHOLDS.snr); + if (gS !== "unknown") { + const interference = RANK[gR] >= RANK.warning && RANK[gS] <= RANK.serious; + add(gS, "SNR", gS === "good" ? "Clean signal-to-noise" : interference ? "Interference: SNR is low while RSSI is usable" : "Low signal-to-noise ratio", + `SNR ${snr} dB with RSSI ${rssi ?? "?"} dBm${noise != null ? ` and a ${noise} dBm noise floor` : ""}${lastKnown}.`, + gS === "good" ? "" : interference + ? "Distance is not the problem. Look for co-channel APs, non-Wi-Fi sources (see channel utilisation) or a noisy channel, and consider a channel change." + : "Below 25 dB higher MCS rates become unreachable; below 15 dB expect packet loss."); + } + + const gN = grade(noise, THRESHOLDS.noise); + if (gN !== "unknown") add(gN, "Noise floor", gN === "good" ? "Quiet channel" : "Raised noise floor on the serving radio", + `Noise floor ${noise} dBm on ${BAND[band].label} channel ${channel ?? "?"} of ${servingAp?.name || "the serving AP"}.`, + gN === "good" ? "" : "Every dB of noise above about -90 dBm is a dB of SNR lost. Hunt for non-Wi-Fi interferers (microwaves, wireless video, Bluetooth) or move channel."); + + const gE = grade(eff, THRESHOLDS.eff); + if (gE !== "unknown") add(gE, "PHY rate", gE === "good" ? "PHY rate near its ceiling" : "PHY rate far below what the link could do", + `Best rate ${round(bestRate, 0)} Mbps of a ${round(ceiling, 0)} Mbps ceiling (${round(eff, 0)}%) for ${GENERATION[proto]?.label || proto}, ${widthLabel}, ${streams} stream(s)${streamsKnown ? "" : " assumed"}.`, + gE === "good" ? "" : "Low MCS follows low SNR, retries or a client that is power-saving or idle. If SNR is good, check retries and channel utilisation."); + + const gT = grade(retryPct >= 0 ? retryPct : null, THRESHOLDS.retry); + if (gT !== "unknown") add(gT, "Retries", gT === "good" ? "Few retransmissions" : "High retransmission rate", + `${round(retryPct, 1)}% of frames were retries (tx ${round(txRetryPct, 1) ?? "?"}%, rx ${round(rxRetryPct, 1) ?? "?"}%).`, + gT === "good" ? "" : "Retries above about 10% waste airtime and add latency. Common causes: low SNR, hidden nodes, co-channel contention, or an overly aggressive rate."); + + const gU = grade(util.all, THRESHOLDS.util); + if (gU !== "unknown") add(gU, "Channel load", gU === "good" ? "Channel has airtime to spare" : "Busy channel", + `${util.all}% channel utilisation on the serving radio (tx ${util.tx ?? "?"}%, rx in-BSS ${util.inBss ?? "?"}%, other BSS ${util.otherBss ?? "?"}%, non-Wi-Fi ${util.nonWifi ?? "?"}%).`, + gU === "good" ? "" : "Above about 50% busy, contention and latency rise quickly. Split load across channels, narrow the channel width, or remove low-rate legacy clients."); + + const gW = grade(util.nonWifi, THRESHOLDS.nonwifi); + if (gW !== "unknown" && gW !== "good") add(gW, "Interference", "Non-Wi-Fi energy on the channel", + `${util.nonWifi}% of airtime is non-Wi-Fi energy.`, + "Something that is not Wi-Fi is transmitting on this channel. Check for microwave ovens, cordless phones, wireless cameras or Bluetooth, especially on 2.4 GHz."); + + if (servingOcc) { + const gC = grade(coChannel.length, THRESHOLDS.cochannel); + const s = servingOcc; + add(gC, "Co-channel", gC === "good" ? "Little co-channel overlap at this site" : "Many APs overlap the serving channel", + `The serving radio is on ${BAND[band].label} channel ${s.ch} at ${s.width} MHz (channels ${s.span[0]}–${s.span[1]}). ` + + `${coChannel.length} other AP radio(s) at this site overlap it, ${samePrimary} on the same primary channel` + + `${coChannel.length ? `: ${coChannel.slice(0, 6).map((o) => `${o.name} (ch ${o.ch}/${o.width})`).join(", ")}${coChannel.length > 6 ? "…" : ""}` : ""}.`, + gC === "good" ? "" : "Overlapping radios that hear each other share airtime, and wide channels overlap more neighbours. " + + "Review the RRM channel plan, consider narrower channels, and check transmit power."); + } + if (radioClients != null) { + const gL = grade(radioClients, THRESHOLDS.load); + if (gL !== "good") add(gL, "Radio load", "Crowded serving radio", `${radioClients} clients on the serving ${BAND[band].label} radio.`, + "Heavy client counts on one radio mean less airtime each. Check band steering and neighbouring AP coverage."); + } + + if (band === "24") { + const dual = c.dual_band === true || bandsUsed.has("5") || bandsUsed.has("6"); + add(dual ? "serious" : "warning", "Band", "Connected on 2.4 GHz", + `The client is on 2.4 GHz${dual ? " although it has used 5/6 GHz in this period" : ""}.`, + "2.4 GHz has three usable channels, more interference and lower rates. Enable band steering or raise the 2.4 GHz minimum RSSI so capable clients prefer 5 or 6 GHz."); + if (width && width > 20) add("serious", "Band", "40 MHz channel on 2.4 GHz", `The serving 2.4 GHz radio uses ${width} MHz.`, + "40 MHz on 2.4 GHz overlaps two of the three non-overlapping channels. Use 20 MHz."); + } else if (band !== "unk") { + add("good", "Band", `Connected on ${BAND[band].label}`, `${BAND[band].label}, channel ${channel ?? "?"}, ${widthLabel}.`, ""); + } + + if (proto) { + const g = GENERATION[proto]; + add(g.sev, "Wi-Fi generation", g.sev === "good" ? g.label : `Older PHY: ${g.label}`, `Negotiated protocol ${g.label}.`, + g.sev === "good" ? "" : "Older PHYs cap the rate and take more airtime per byte, slowing the whole cell. Check the client driver, or whether the SSID/radio allows newer modes."); + } + + if (sess.length) { + const gP = pingPong >= 4 ? "serious" : pingPong >= 1 ? "warning" : "good"; + add(gP, "Roaming", pingPong ? "Ping-pong roaming" : `${roams} roam(s) across ${new Set(sess.map((s) => s.ap)).size} AP(s)`, + `${sess.length} session(s), ${roams} roam(s) (${round(roams / spanH, 1)}/hour), ${pingPong} A→B→A bounce(s) within 5 minutes, ${shortSess} session(s) under a minute.`, + pingPong ? "Bouncing between two APs means overlapping cells of similar strength. Reduce power on one AP or tune roaming thresholds." : ""); + } + if (evs.length) { + const n = negEvents.length; + const gD = n > 30 ? "critical" : n > 10 ? "serious" : n > 3 ? "warning" : "good"; + const top = [...reasonCounts].sort((a, b) => b[1] - a[1]).slice(0, 3).map(([k, v]) => `${k} ×${v}`).join(", "); + add(gD, "Disconnects", n ? `${n} disconnect or failure event(s)` : "No disconnects or failures", + `${evs.length} event(s) in the period, ${n} negative${top ? `. Most common: ${top}` : ""}.`, + n ? "Reason 4 (inactivity) with weak RSSI suggests a sticky client; reasons 3 and 8 are the client leaving; 2, 15 and 23 are authentication failures." : ""); + if (authFails.length) add(authFails.length > 5 ? "serious" : "warning", "Disconnects", "Authentication or 4-way handshake failures", + `${authFails.length} failure(s) with reason 2, 15 or 23, or a handshake timeout.`, + "Usually a PSK mismatch, an expired 802.1X credential, or key exchange lost to poor RF. If RF is clean, check the RADIUS logs."); + } + findings.sort((a, b) => RANK[a.sev] - RANK[b.sev]); + const overall = worst(findings.map((f) => f.sev).filter((s) => s !== "unknown")); + const issues = findings.filter((f) => RANK[f.sev] <= RANK.warning); + + // ---- 6. Dashboard ----------------------------------------------------------- + const identity = { + mac: formatMac(mac), + hostname: pick(c.hostname, last.hostname, last.last_hostname) || "", + username: pick(c.username, last.username, last.last_username) || "", + ip: pick(c.ip, last.ip, last.last_ip) || "", + device: [pick(c.manufacture, last.mfg, last.manufacture), pick(c.os, last.os, last.last_os), pick(c.model, last.model, last.last_model)].filter(Boolean).join(" · "), + ssid: pick(c.ssid, last.ssid, last.last_ssid) || "", + ap: servingAp?.name || (apMac ? formatMac(apMac) : ""), + site: siteName(siteId), + live: !!live, + lastSeen: epoch(pick(c.last_seen, last.last_seen, last.timestamp)), + }; + const tiles = [ + { label: "RSSI", value: rssi, unit: "dBm", t: THRESHOLDS.rssi, sev: gR }, + { label: "SNR", value: snr, unit: "dB", t: THRESHOLDS.snr, sev: gS }, + { label: "Noise floor", value: noise, unit: "dBm", t: THRESHOLDS.noise, sev: gN }, + { label: "PHY rate vs ceiling", value: eff == null ? null : round(eff, 0), unit: "%", t: THRESHOLDS.eff, sev: gE, + note: bestRate ? `${round(bestRate, 0)} / ${round(ceiling, 0)} Mbps` : "" }, + { label: "Retries", value: retryPct >= 0 ? round(retryPct, 1) : null, unit: "%", t: THRESHOLDS.retry, sev: gT }, + { label: "Channel utilisation", value: util.all, unit: "%", t: THRESHOLDS.util, sev: gU }, + { label: "Non-Wi-Fi airtime", value: util.nonWifi, unit: "%", t: THRESHOLDS.nonwifi, sev: grade(util.nonWifi, THRESHOLDS.nonwifi) }, + { label: "Overlapping AP radios", value: servingOcc ? coChannel.length : null, unit: "", t: THRESHOLDS.cochannel, + sev: servingOcc ? grade(coChannel.length, THRESHOLDS.cochannel) : "unknown", + note: servingOcc ? `ch ${servingOcc.ch} · ${servingOcc.width} MHz · ${samePrimary} same primary` : "" }, + ]; + const facts = [ + ["Band", BAND[band].label, band === "24" ? "serious" : band === "unk" ? "unknown" : "good"], + ["Channel", channel != null ? `${channel} · ${widthLabel}` : "", channel != null ? "good" : "unknown"], + ["Generation", GENERATION[proto]?.label || "", GENERATION[proto]?.sev || "unknown"], + ["Spatial streams", streamsKnown ? String(streamsKnown) : `${ASSUMED_STREAMS} (assumed)`, streamsKnown ? "good" : "unknown"], + ["AP transmit power", radio?.power != null ? `${radio.power} dBm` : "", radio?.power != null ? "good" : "unknown"], + ["Clients on radio", radioClients != null ? String(radioClients) : "", radioClients != null ? grade(radioClients, THRESHOLDS.load) : "unknown"], + ]; + if (ctx.mount) { + drawDashboard(ctx, { + identity, overall, issues, findings, tiles, facts, series, util, band, channel, occ, servingOcc, + sess, evs, negEvents, reasonCounts, apName, t0: now - durS, t1: now, duration, + }); + } + ctx.progress(3, 3, "Done"); + + // ---- 7. Workbook -------------------------------------------------------------- + const fmtT = (t) => (t ? ctx.fmtTime(t) : ""); + const findingRows = findings.map((f) => ({ ...f, sev: STATUS[f.sev].label, + __style: f.sev === "critical" ? "red" : f.sev === "serious" || f.sev === "warning" ? "yellow" : f.sev === "good" ? "green" : undefined })); + const phyRows = [ + ["Client MAC", identity.mac], ["Hostname", identity.hostname], ["Username", identity.username], ["IP", identity.ip], + ["Device", identity.device], ["Site", identity.site], ["SSID", identity.ssid], ["AP", identity.ap], + ["Connected now", live ? "Yes" : "No"], ["Last seen", fmtT(identity.lastSeen)], + ["Band", BAND[band].label], ["Channel", channel ?? ""], ["Channel width (MHz)", widthInferred ? `${width} (inferred from rate)` : width], + ["Protocol", GENERATION[proto]?.label || ""], ["Spatial streams", streamsKnown ?? `${ASSUMED_STREAMS} (assumed)`], + ["RSSI (dBm)", rssi ?? ""], ["SNR (dB)", snr ?? ""], ["Noise floor (dBm)", noise ?? ""], + ["TX rate (Mbps)", txRate ?? ""], ["RX rate (Mbps)", rxRate ?? ""], ["PHY ceiling (Mbps)", round(ceiling, 1) ?? ""], + ["Rate efficiency (%)", round(eff, 1) ?? ""], ["TX retries", txRetries ?? ""], ["RX retries", rxRetries ?? ""], + ["TX packets", txPkts ?? ""], ["RX packets", rxPkts ?? ""], ["Retry rate (%)", retryPct >= 0 ? round(retryPct, 1) : ""], + ["Channel utilisation (%)", util.all ?? ""], [" Transmit (%)", util.tx ?? ""], [" Receive in-BSS (%)", util.inBss ?? ""], + [" Receive other BSS (%)", util.otherBss ?? ""], [" Unknown Wi-Fi (%)", util.unknownWifi ?? ""], [" Non-Wi-Fi (%)", util.nonWifi ?? ""], + ["AP transmit power (dBm)", radio?.power ?? ""], ["Clients on radio", radioClients ?? ""], + ["Overlapping AP radios at site", servingOcc ? coChannel.length : ""], + [" Same primary channel", servingOcc ? samePrimary : ""], + ]; + const tsRows = series.flatMap((s) => s.pts.map(([t, v]) => ({ metric: s.name, time: fmtT(t), value: v }))); + const sessRows = sess.map((s) => ({ ap: apName(s.ap), band: BAND[s.band].label, ssid: s.ssid, start: fmtT(s.start), + end: fmtT(s.end), dur: s.dur ?? (s.end ? round(s.end - s.start, 0) : "") })); + const evRowsOut = [...evs].reverse().map((e) => ({ time: fmtT(e.t), type: e.type, text: e.text, ap: apName(e.ap), + band: BAND[e.band].label, channel: e.channel, reason: e.reasonText, negative: e.negative ? "Yes" : "", + __style: e.negative ? "red" : undefined })); + const relation = (o) => (o.serving ? "Serving" : servingOcc && overlaps(o.span, servingOcc.span) ? "Overlaps serving" : "Clear"); + const chRows = [...occ].sort((a, b) => a.span[0] - b.span[0] || a.ch - b.ch || a.name.localeCompare(b.name)).map((o) => ({ + ap: o.name, ch: o.ch, width: o.width, span: `${o.span[0]}–${o.span[1]}`, clients: o.clients ?? "", util: o.util ?? "", + rel: relation(o), __style: o.serving ? "blue" : relation(o) === "Clear" ? undefined : "yellow" })); + const info = [ + ["Org", ctx.orgName], ["Site", identity.site], ["Client", identity.mac], ["Look back", duration], + ["Overall", STATUS[overall].label], ["Insight metrics tried", metrics.join(", ")], + ["RSSI grading (dBm)", ">= -65 good, -65..-70 warning, -70..-75 serious, < -75 critical"], + ["SNR grading (dB)", ">= 25 good, 20..25 warning, 15..20 serious, < 15 critical"], + ["Noise floor grading (dBm)", "<= -90 good, -90..-85 warning, -85..-80 serious, > -80 critical"], + ["Retry grading (%)", "<= 10 good, 10..20 warning, 20..30 serious, > 30 critical"], + ["Channel utilisation grading (%)", "<= 50 good, 50..70 warning, 70..85 serious, > 85 critical"], + ["PHY ceiling", "Top MCS for the generation and channel width times spatial streams (2 assumed when Mist does not report it)"], + ["Generated", ctx.epochToUtc(Date.now())], + ]; + const sheets = [ + ctx.xlsx.sheet("Findings", [ + { header: "Severity", key: "sev", width: 10 }, { header: "Facet", key: "facet", width: 16 }, + { header: "Finding", key: "title", width: 40 }, { header: "Evidence", key: "evidence", width: 70, wrap: true }, + { header: "What to check", key: "advice", width: 70, wrap: true }, + ], findingRows, { tabColor: "C00000" }), + ctx.xlsx.sheet("PHY Snapshot", [{ header: "Field", width: 26 }, { header: "Value", width: 40 }], phyRows, + { autofilter: false, tabColor: "1F4E78" }), + ctx.xlsx.sheet("Time Series", [{ header: "Metric", key: "metric", width: 24 }, { header: "Time", key: "time", width: 20 }, + { header: "Value", key: "value", width: 14 }], tsRows, { tabColor: "548235" }), + ctx.xlsx.sheet("Sessions", [{ header: "AP", key: "ap", width: 24 }, { header: "Band", key: "band", width: 10 }, + { header: "SSID", key: "ssid", width: 20 }, { header: "Connected", key: "start", width: 20 }, + { header: "Disconnected", key: "end", width: 20 }, { header: "Duration (s)", key: "dur", width: 12 }], sessRows, { tabColor: "7030A0" }), + ctx.xlsx.sheet("Events", [{ header: "Time", key: "time", width: 20 }, { header: "Type", key: "type", width: 30 }, + { header: "Text", key: "text", width: 40, wrap: true }, { header: "AP", key: "ap", width: 22 }, + { header: "Band", key: "band", width: 10 }, { header: "Channel", key: "channel", width: 8 }, + { header: "802.11 Reason", key: "reason", width: 40 }, { header: "Negative", key: "negative", width: 9 }], evRowsOut, { tabColor: "C55A11" }), + ctx.xlsx.sheet("Channel Map", [{ header: "AP", key: "ap", width: 26 }, + { header: `${BAND[band].label} primary channel`, key: "ch", width: 12 }, { header: "Width (MHz)", key: "width", width: 10 }, + { header: "Occupies channels", key: "span", width: 14 }, { header: "Clients", key: "clients", width: 8 }, + { header: "Utilisation (%)", key: "util", width: 12 }, { header: "Relation to serving radio", key: "rel", width: 22 }], + chRows, { tabColor: "2E75B6" }), + ctx.xlsx.sheet("Info", [{ header: "Field" }, { header: "Value", wrap: true }], info, { autofilter: false, freeze: null }), + ]; + + return { + summary: `${identity.hostname || identity.mac} at ${identity.site}: ${STATUS[overall].label}. ` + + `${issues.length} issue(s) across ${findings.length} check(s)${live ? "" : " (client offline, last-known values)"}`, + filename: ctx.stampedName(`mist_client_phy_${mac}`, ctx.orgName, "xlsx"), + sheets, + }; + }, +}; + +function formatMac(m) { + const h = String(m || "").toLowerCase().replace(/[^0-9a-f]/g, ""); + return h.length === 12 ? h.match(/../g).join(":") : String(m || ""); +} + +// --------------------------------------------------------------------------- +// Dashboard. Dark-surface steps of the reference palette: status colours for +// grades (always with icon + label), categorical slots for bands and series. + +function drawDashboard(ctx, d) { + const E = ctx.esc; + const W = 760; + const charts = new Map(); + let chartSeq = 0; + const fmtShort = (t) => { + const dt = new Date(t * 1000); + return d.duration === "7d" + ? dt.toLocaleDateString(undefined, { month: "short", day: "numeric" }) + : dt.toLocaleTimeString(undefined, { hour: "2-digit", minute: "2-digit" }); + }; + const fmtVal = (v, unit) => { + if (v == null) return "–"; + if (unit === "bps") return v >= 1e6 ? `${round(v / 1e6, 1)} Mbps` : v >= 1e3 ? `${round(v / 1e3, 1)} kbps` : `${round(v, 0)} bps`; + if (unit === "bytes") return ctx.fmtBytes(v); + return `${round(v, 1)}${unit ? ` ${unit}` : ""}`; + }; + const badge = (sev, text) => `` + + `${STATUS[sev].icon}${E(text ?? STATUS[sev].label)}`; + + // Bullet meter: threshold zones, a marker at the value. + const meter = (tile) => { + const t = tile.t; + const span = t.max - t.min; + const zs = zones(t).map((z) => ``).join(""); + const v = tile.value == null ? null : Math.min(t.max, Math.max(t.min, tile.value)); + const pos = v == null ? null : ((v - t.min) / span) * 100; + return `
${zs}${pos == null ? "" : ``}
` + + `
${t.min}${t.max} ${E(t.unit)}
`; + }; + + // Line chart with threshold zones, failure markers, crosshair tooltip. + const lineChart = ({ title, unit, lines, t, markers = [] }) => { + const id = `phyc${++chartSeq}`; + const H = 210, L = 52, R = 14, T = 12, B = 26; + const all = lines.flatMap((l) => l.pts.map((p) => p[1])); + let lo = t ? t.min : Math.min(...all); + let hi = t ? t.max : Math.max(...all); + if (t) { lo = Math.min(lo, ...all); hi = Math.max(hi, ...all); } + if (!t) { if (lo >= 0) lo = 0; const padY = (hi - lo) * 0.08 || 1; hi += padY; } + const sx = (x) => L + ((x - d.t0) / (d.t1 - d.t0)) * (W - L - R); + const sy = (y) => T + (1 - (y - lo) / (hi - lo || 1)) * (H - T - B); + const zoneRects = t ? zones(t).map((z) => { + const y1 = sy(Math.min(hi, z.to)); const y2 = sy(Math.max(lo, z.from)); + return y2 > y1 ? `` : ""; + }).join("") : ""; + const ticks = niceTicks(lo, hi); + const grid = ticks.map((v) => `` + + `${E(fmtVal(v, unit === "bps" || unit === "bytes" ? unit : ""))}`).join(""); + const xt = Array.from({ length: 5 }, (_, i) => d.t0 + ((d.t1 - d.t0) * i) / 4) + .map((x, i) => `${E(fmtShort(x))}`).join(""); + const paths = lines.map((l) => { + const pts = l.pts.filter((p) => p[0] >= d.t0 - 60); + const dpath = pts.map((p, i) => `${i ? "L" : "M"}${sx(p[0]).toFixed(1)},${sy(p[1]).toFixed(1)}`).join(""); + const dots = pts.length <= 48 ? pts.map((p) => ``).join("") : ""; + return `${dots}`; + }).join(""); + const marks = markers.filter((m) => m.t >= d.t0).map((m) => + ``).join(""); + charts.set(id, { lines, sx, sy, unit, L, R, T, B, H, markers }); + const legend = lines.length > 1 ? `
${lines.map((l) => + `${E(l.name)}`).join("")}
` : ""; + return `
${E(title)}${lines.length === 1 ? ` ${E(lines[0].name)}` : ""}
${legend} +
+ ${zoneRects}${grid}${xt}${marks}${paths} +
+ ${markers.length ? `
Dashed red lines: disconnect / failure events
` : ""}
`; + }; + + // ---- Sections ---------------------------------------------------------------- + const id = d.identity; + const counts = ["critical", "serious", "warning", "good"].map((s) => [s, d.findings.filter((f) => f.sev === s).length]).filter(([, n]) => n); + const header = `
+
Wi-Fi PHY health
${badge(d.overall, STATUS[d.overall].label)}
+
${counts.map(([s, n]) => badge(s, `${n} ${STATUS[s].label.toLowerCase()}`)).join("")}
+
${[["Client", `${id.hostname ? `${id.hostname} · ` : ""}${id.mac}`], ["Device", id.device], ["Site", id.site], + ["SSID / AP", [id.ssid, id.ap].filter(Boolean).join(" · ")], ["IP / user", [id.ip, id.username].filter(Boolean).join(" · ")], + ["State", id.live ? "Connected now" : `Offline · last seen ${id.lastSeen ? ctx.fmtTime(id.lastSeen) : "?"}`]] + .filter(([, v]) => v).map(([k, v]) => `
${E(k)}
${E(v)}
`).join("")}
`; + + const tiles = `

Scorecard

${d.tiles.map((t) => + `
+
${E(t.label)}
${E(t.value == null ? "–" : `${t.value}`)}${E(t.value == null ? "" : t.unit)}
+ ${badge(t.sev)}${t.note ? `
${E(t.note)}
` : ""}${meter(t)}
`).join("")}
+
${d.facts.map(([k, v, s]) => `
${E(k)}${E(v || "–")}
`).join("")}
`; + + const findingsHtml = `

Findings

${d.issues.length ? "" : `

No issues found. Every graded facet is healthy.

`} +
${d.findings.filter((f) => f.sev !== "good" && f.sev !== "unknown").map((f) => + `
${badge(f.sev)}
${E(f.title)} ${E(f.facet)} +

${E(f.evidence)}

${f.advice ? `

${E(f.advice)}

` : ""}
`).join("")}
+ ${d.findings.some((f) => f.sev === "good") ? `
Healthy checks (${d.findings.filter((f) => f.sev === "good").length}) +
    ${d.findings.filter((f) => f.sev === "good").map((f) => `
  • ${badge("good", f.facet)} ${E(f.title)}: ${E(f.evidence)}
  • `).join("")}
` : ""}
`; + + // Time series grouped by family; tx/rx share a chart, never two y-axes. + const groups = new Map(); + for (const s of d.series) groups.set(family(s.name), [...(groups.get(family(s.name)) || []), s]); + const famOrder = [...groups.keys()].sort((a, b) => (FAMILY_ORDER.indexOf(a) + 1 || 99) - (FAMILY_ORDER.indexOf(b) + 1 || 99)); + const markers = d.negEvents.map((e) => ({ t: e.t, label: e.reasonText || e.type })); + const tsHtml = famOrder.length + ? `

Over time

${famOrder.map((f) => { + const meta = FAMILY_META[f] || { title: f.replace(/_/g, " "), unit: "" }; + const lines = groups.get(f).slice(0, 8).map((s, i) => ({ name: s.name, pts: s.pts, color: SERIES[i] })); + return lineChart({ title: meta.title, unit: meta.unit, lines, t: meta.t, markers: ["rssi", "snr"].includes(f) ? markers : [] }); + }).join("")}
` + : `

Over time

Mist returned no time series for this client. The scorecard uses live or last-known values.

`; + + // Airtime breakdown of the serving radio. + const u = d.util; + const parts = [ + ["Transmit", u.tx, SERIES[0]], ["Receive, other BSS", u.otherBss, SERIES[1]], ["Receive, this BSS", u.inBss, SERIES[2]], + ["Unknown Wi-Fi", u.unknownWifi, SERIES[3]], ["Non-Wi-Fi", u.nonWifi, SERIES[4]], + ].filter(([, v]) => v != null && v > 0); + const used = parts.reduce((s, [, v]) => s + v, 0); + const airtime = u.all == null && !parts.length ? "" : `
Serving radio airtime + ${E(BAND[d.band].label)} ch ${E(d.channel ?? "?")} · ${E(u.all ?? round(used, 0))}% busy
+
${parts.map(([k, v, col]) => ``).join("")} +
+
${parts.map(([k, v, col]) => `${E(k)} ${E(v)}%`).join("")} + Idle
`; + + // Channel occupancy: the band's whole 20 MHz channel plan on the x-axis, every AP + // radio as a bar across the channels it occupies (width included), packed into + // rows. Serving radio blue; radios overlapping it flagged; DFS range shaded. + const plan = channelPlan(d.band, d.occ); + const chanHtml = d.occ.length && plan.length ? (() => { + const L = 8, R = 8, top = 26, RH = 22, MAXROWS = 24; + const cw = (W - L - R) / plan.length; + const col = (ch) => { const i = plan.findIndex((p) => p >= ch); return i < 0 ? plan.length - 1 : i; }; + const so = d.servingOcc; + const rel = (o) => (o.serving ? "serving" : so && overlaps(o.span, so.span) ? "overlap" : "clear"); + const STYLE = { + serving: { fill: SERIES[0], ink: "#ffffff" }, + overlap: { fill: STATUS.serious.color, ink: "#0b0d11" }, + clear: { fill: "var(--phy-neutral)", ink: "var(--phy-fg)" }, + }; + const order = { serving: 0, overlap: 1, clear: 2 }; + const items = [...d.occ].sort((a, b) => order[rel(a)] - order[rel(b)] || a.span[0] - b.span[0] || a.name.localeCompare(b.name)); + const rows = []; + let hidden = 0; + const placed = []; + for (const o of items) { + const i0 = col(o.span[0]); const i1 = Math.max(i0, col(o.span[1])); + let r = rows.findIndex((row) => row.every(([a, b]) => i1 < a || i0 > b)); + if (r < 0) { if (rows.length >= MAXROWS) { hidden += 1; continue; } rows.push([]); r = rows.length - 1; } + rows[r].push([i0, i1]); + placed.push({ o, i0, i1, r }); + } + const plotH = rows.length * RH; + const H = top + plotH + 44; + const cover = plan.map((ch) => d.occ.filter((o) => ch >= o.span[0] && ch <= o.span[1]).length); + const step = Math.max(1, Math.ceil(26 / cw)); + const inServing = (ch) => so && ch >= so.span[0] && ch <= so.span[1]; + const dfs = d.band === "5" ? [col(DFS_5[0]), col(DFS_5[1])] : null; + const tipFor = (o) => `${o.name}${o.serving ? " (serving)" : ""}\nPrimary channel ${o.ch} · ${o.width} MHz` + + `\nOccupies ${o.span[0] === o.span[1] ? `channel ${o.span[0]}` : `channels ${o.span[0]}–${o.span[1]}`}` + + `${o.clients != null ? `\n${o.clients} client(s)` : ""}${o.util != null ? ` · ${o.util}% busy` : ""}` + + `${rel(o) === "overlap" ? `\n▲ Shares airtime with the serving radio` : ""}`; + const svg = ` + ${dfs ? ` + DFS` : ""} + ${so ? `serving` : ""} + ${plan.map((_, i) => ``).join("")} + + ${placed.map(({ o, i0, i1, r }) => { + const x = L + i0 * cw + 2; const w = (i1 - i0 + 1) * cw - 4; const y = top + r * RH + 3; const s = STYLE[rel(o)]; + const chars = Math.floor((w - 10) / 6.4); + const label = chars >= 4 ? (o.name.length > chars ? `${o.name.slice(0, chars - 1)}…` : o.name) : ""; + return `${label ? `${E(label)}` : ""}`; + }).join("")} + + ${plan.map((ch, i) => (i % step && !inServing(ch) ? "" : `${ch}`)).join("")} + ${plan.map((ch, i) => `${cover[i] || "·"}`).join("")} + `; + const nOver = d.occ.filter((o) => rel(o) === "overlap").length; + return `
${E(BAND[d.band].label)} channel occupancy at this site + ${so ? `serving ch ${E(so.ch)} · ${E(so.width)} MHz (${E(so.span[0])}–${E(so.span[1])}) · ${E(nOver)} overlapping radio(s)` : "serving radio unknown"}
+
Serving AP radio + ${STATUS.serious.icon} Overlaps the serving channel + Other AP radios + ${dfs ? `DFS channels` : ""}
+ ${svg} +
Bars span every 20 MHz channel a radio occupies (primary channel plus width). Bottom row: AP radios on each channel.${hidden ? ` ${hidden} radio(s) not drawn.` : ""}
`; + })() : ""; + + // Roaming swimlanes: one row per AP, sessions coloured by band, failures on top. + const apOrder = [...new Set(d.sess.map((s) => s.ap))].slice(0, 12); + const RH = 22, LW = 150, TW = W - LW - 14, top = 24; + const tx = (t) => LW + ((Math.max(d.t0, Math.min(d.t1, t)) - d.t0) / (d.t1 - d.t0)) * TW; + const laneH = top + apOrder.length * RH + 24; + const roamHtml = d.sess.length ? `
Roaming timeline ${E(d.sess.length)} session(s) on ${E(apOrder.length)} AP(s)
+
${["24", "5", "6"].filter((b) => d.sess.some((s) => s.band === b)).map((b) => + `${BAND[b].label}`).join("")}${STATUS.critical.icon} Disconnect / failure
+ + ${d.negEvents.filter((e) => e.t >= d.t0).map((e) => ``).join("")} + ${apOrder.map((ap, i) => `${E(d.apName(ap).slice(0, 22))} + `).join("")} + ${d.sess.filter((s) => apOrder.includes(s.ap)).map((s) => { + const x1 = tx(s.start); const x2 = Math.max(x1 + 3, tx(s.end || d.t1)); + const i = apOrder.indexOf(s.ap); + return ``; + }).join("")} + ${Array.from({ length: 5 }, (_, i) => d.t0 + ((d.t1 - d.t0) * i) / 4).map((x, i) => + `${E(fmtShort(x))}`).join("")} +
` : ""; + + // Disconnect / failure causes. + const reasons = [...d.reasonCounts].sort((a, b) => b[1] - a[1]).slice(0, 10); + const maxR = Math.max(1, ...reasons.map(([, n]) => n)); + const reasonHtml = reasons.length ? `
Disconnect and failure causes + ${E(d.negEvents.length)} of ${E(d.evs.length)} events
${reasons.map(([k, n]) => + `
${E(k)}${n}
`).join("")}
` : ""; + + ctx.mount.innerHTML = ` + +
${header}${tiles}${findingsHtml}${tsHtml} +

Radio and roaming

${airtime}${chanHtml}${roamHtml}${reasonHtml} + ${airtime || chanHtml || roamHtml || reasonHtml ? "" : `

No radio, session or event data was returned.

`}
+
`; + + // ---- Hover layer -------------------------------------------------------------- + const root = ctx.mount.querySelector(".phy"); + const tip = root.querySelector(".phy-tip"); + const show = (text, ev) => { + tip.textContent = text; + tip.style.display = "block"; + const x = Math.min(ev.clientX + 14, window.innerWidth - tip.offsetWidth - 8); + const y = ev.clientY + 16 + tip.offsetHeight > window.innerHeight ? ev.clientY - tip.offsetHeight - 12 : ev.clientY + 16; + tip.style.left = `${x}px`; + tip.style.top = `${y}px`; + }; + const hide = () => { tip.style.display = "none"; }; + root.addEventListener("mousemove", (ev) => { + const wrap = ev.target.closest?.("[data-chart]"); + if (wrap) { + const c = charts.get(wrap.dataset.chart); + const svg = wrap.querySelector("svg"); + const r = svg.getBoundingClientRect(); + const vx = ((ev.clientX - r.left) / r.width) * W; + if (vx < c.L || vx > W - c.R) { hide(); svg.querySelector(".x").setAttribute("visibility", "hidden"); return; } + const tq = d.t0 + ((vx - c.L) / (W - c.L - c.R)) * (d.t1 - d.t0); + const rows = c.lines.map((l) => { + let best = null; + for (const p of l.pts) if (!best || Math.abs(p[0] - tq) < Math.abs(best[0] - tq)) best = p; + return best ? { name: l.name, p: best } : null; + }).filter(Boolean); + if (!rows.length) return; + const near = rows[0].p[0]; + const x = c.sx(near); + const xl = svg.querySelector(".x"); + xl.setAttribute("x1", x); xl.setAttribute("x2", x); xl.setAttribute("visibility", "visible"); + const ev2 = c.markers.filter((m) => Math.abs(m.t - near) <= (d.duration === "7d" ? 1800 : 300)); + show(`${ctx.fmtTime(near)}\n${rows.map((rw) => `${rw.name}: ${fmtVal(rw.p[1], c.unit)}`).join("\n")}` + + (ev2.length ? `\n✕ ${ev2.length} failure event(s) nearby` : ""), ev); + return; + } + const t = ev.target.closest?.("[data-tip]"); + if (t) show(t.getAttribute("data-tip"), ev); else hide(); + }); + root.addEventListener("mouseleave", () => { + hide(); + root.querySelectorAll(".phy-chart svg .x").forEach((l) => l.setAttribute("visibility", "hidden")); + }); +} diff --git a/mist-toolbox/tools/registry.js b/mist-toolbox/tools/registry.js index b5b43e1..2c1fad3 100644 --- a/mist-toolbox/tools/registry.js +++ b/mist-toolbox/tools/registry.js @@ -51,13 +51,16 @@ export function validateTool(tool, file) { * or remove these; tests/registry.test.js keeps the list honest. */ export const BUILTIN_TOOLS = [ + "client-wifi-phy.js", "disconnect-console.js", "ip-blocks.js", "port-inventory.js", "ssid-report.js", "site-alarms.js", "ssr-pre-post.js", + "switch-additional-cli.js", "switch-configs.js", + "switch-psu-status.js", "switch-report.js", "wifi-clients.js", ]; diff --git a/mist-toolbox/tools/switch-additional-cli.js b/mist-toolbox/tools/switch-additional-cli.js new file mode 100644 index 0000000..ed21c15 --- /dev/null +++ b/mist-toolbox/tools/switch-additional-cli.js @@ -0,0 +1,300 @@ +// Switch Additional CLI: collects every "additional CLI commands" block that +// applies to switches, for every site in the org or one chosen site. +// Levels read: +// Template GET /orgs/{org_id}/networktemplates (template + its switch rules) +// Site GET /sites/{site_id}/setting (site + its switch rules) +// Device GET /sites/{site_id}/devices?type=switch +// Mist field: additional_config_cmds (array of strings, one command per entry). + +const CELL_MAX = 32000; // Excel cell limit is 32,767 characters + +const list = (v) => (Array.isArray(v) ? v : v == null || v === "" ? [] : [v]); +const cmds = (obj) => list(obj?.additional_config_cmds).map((c) => String(c).trim()).filter(Boolean); +const clip = (s) => (s.length > CELL_MAX ? s.slice(0, CELL_MAX) + "\n…(truncated)" : s); +const rules = (obj) => list(obj?.switch_matching?.rules); + +export default { + id: "switch-additional-cli", + name: "Switch Additional CLI", + description: + "Gathers the additional CLI commands applied to switches at template, site and " + + "device level, for every site in the org or one site, laid out for side-by-side comparison.", + tag: "Mist API", + needs: { mistToken: true, org: true }, + params: [ + { + id: "allSites", + type: "checkbox", + label: "All sites in the org", + default: true, + hint: "When ticked, the Site dropdown is ignored.", + }, + { + id: "siteId", + type: "select", + label: "Site", + optionsFrom: "sites", + hint: "Used only when \"All sites in the org\" is unticked.", + }, + ], + + async run(ctx) { + const { allSites, siteId } = ctx.params; + + // Full site records, not ctx.listSites(): that trims each site to {id, name} + // and drops networktemplate_id, which the template level needs. + const sites = (await ctx.getAll(`/orgs/${ctx.orgId}/sites`)) + .filter((s) => typeof s.id === "string") + .sort((a, b) => String(a.name || a.id).localeCompare(String(b.name || b.id))); + let targets; + if (allSites) { + targets = sites; + if (!targets.length) throw new Error("This org has no sites."); + } else { + if (!siteId) throw new Error("Pick a site, or tick \"All sites in the org\"."); + const s = sites.find((x) => x.id === siteId); + targets = [s || { id: siteId, name: siteId }]; + } + const siteName = (s) => s.name || s.id; + const scopeLabel = allSites ? `all ${targets.length} sites` : siteName(targets[0]); + + // Templates: org level, read once. + ctx.status("Loading switch templates…"); + let templates = []; + try { + templates = await ctx.getAll(`/orgs/${ctx.orgId}/networktemplates`); + } catch (e) { + ctx.log(`Switch templates unavailable (${e.message})`, "err"); + } + const tplById = Object.fromEntries(templates.map((t) => [t.id, t])); + + // Sites: setting + switches, in parallel. One failing site is logged and skipped. + ctx.status(`Loading additional CLI for ${scopeLabel}…`); + let done = 0; + const failed = []; + const perSite = await ctx.pool( + ctx.POOL_LIMIT, + targets.map((site) => async () => { + if (ctx.signal.aborted) return null; + try { + const [setting, devices] = await Promise.all([ + ctx.mistGet(`/sites/${site.id}/setting`), + ctx.getAll(`/sites/${site.id}/devices`, { type: "switch" }), + ]); + return { site, setting, switches: devices.filter((d) => !d.type || d.type === "switch") }; + } catch (e) { + failed.push({ site: siteName(site), error: e.message }); + ctx.log(`${siteName(site)}: ${e.message}`, "err"); + return null; + } finally { + ctx.progress(++done, targets.length, siteName(site)); + } + }), + ); + if (ctx.signal.aborted) throw new Error("Cancelled."); + const results = perSite.filter(Boolean); + + // ---- Build rows ------------------------------------------------------- + const lineRows = []; // one row per command + const switchRows = []; // one row per switch + const siteRows = []; // one row per site + const unique = new Map(); // command -> { levels, sites, switches } + const textOut = []; // combined .txt + + const addLines = (base, list) => + list.forEach((command, i) => lineRows.push({ ...base, line: i + 1, command })); + const track = (command, level, site, sw) => { + const u = unique.get(command) || { command, levels: new Set(), sites: new Set(), switches: new Set() }; + u.levels.add(level); + if (site) u.sites.add(site); + if (sw) u.switches.add(sw); + unique.set(command, u); + }; + + // Template level: listed once per template, and credited to every site that uses it. + const tplSites = new Map(); // template id -> [site names in scope] + for (const { site } of results) { + const id = site.networktemplate_id; + if (id) tplSites.set(id, [...(tplSites.get(id) || []), siteName(site)]); + } + const templateRows = []; + for (const [id, usedBy] of tplSites) { + const t = tplById[id]; + const tName = t?.name || id; + const blocks = t ? [{ rule: "", list: cmds(t) }, ...rules(t).map((r) => ({ rule: r.name || "(unnamed rule)", list: cmds(r) }))] : []; + for (const b of blocks.filter((b) => b.list.length)) { + const level = b.rule ? "Template rule" : "Template"; + templateRows.push({ + template: tName, rule: b.rule, lines: b.list.length, + sites: usedBy.length, cli: clip(b.list.join("\n")), + }); + addLines({ site: "", level, source: tName, rule: b.rule, switch: "" }, b.list); + for (const c of b.list) for (const s of usedBy) track(c, level, s, ""); + } + } + + for (const { site, setting, switches } of results.sort((a, b) => siteName(a.site).localeCompare(siteName(b.site)))) { + const sName = siteName(site); + const tpl = tplById[site.networktemplate_id]; + const tName = tpl?.name || site.networktemplate_id || ""; + const tplCount = tpl ? cmds(tpl).length + rules(tpl).reduce((n, r) => n + cmds(r).length, 0) : 0; + + textOut.push(`${"=".repeat(78)}\nSITE: ${sName}${tName ? ` (template: ${tName})` : ""}\n${"=".repeat(78)}`); + + // Site level + const siteCmds = cmds(setting); + addLines({ site: sName, level: "Site", source: sName, rule: "", switch: "" }, siteCmds); + siteCmds.forEach((c) => track(c, "Site", sName, "")); + if (siteCmds.length) textOut.push(`--- Site additional CLI ---\n${siteCmds.join("\n")}`); + + let siteRuleCount = 0; + for (const r of rules(setting)) { + const rc = cmds(r); + if (!rc.length) continue; + siteRuleCount += rc.length; + const rName = r.name || "(unnamed rule)"; + addLines({ site: sName, level: "Site rule", source: sName, rule: rName, switch: "" }, rc); + rc.forEach((c) => track(c, "Site rule", sName, "")); + textOut.push(`--- Site switch rule: ${rName} ---\n${rc.join("\n")}`); + } + + // Device level + let devCount = 0; + let swWithCli = 0; + for (const sw of switches.sort((a, b) => String(a.name || a.mac).localeCompare(String(b.name || b.mac)))) { + const swName = sw.name || ctx.fmtMac(String(sw.mac || "")) || sw.id; + const dc = cmds(sw); + devCount += dc.length; + if (dc.length) swWithCli += 1; + addLines({ site: sName, level: "Device", source: swName, rule: "", switch: swName }, dc); + dc.forEach((c) => track(c, "Device", sName, `${sName} / ${swName}`)); + if (dc.length) textOut.push(`--- Switch: ${swName} (${sw.model || "?"}) ---\n${dc.join("\n")}`); + switchRows.push({ + site: sName, switch: swName, model: sw.model || "", + mac: sw.mac ? ctx.fmtMac(String(sw.mac)) : "", template: tName, + tplLines: tplCount, siteLines: siteCmds.length + siteRuleCount, devLines: dc.length, + cli: clip(dc.join("\n")), + __style: dc.length ? "yellow" : undefined, + }); + } + if (!siteCmds.length && !siteRuleCount && !devCount) textOut.push("(no site or device additional CLI)"); + textOut.push(""); + + siteRows.push({ + site: sName, template: tName, switches: switches.length, + tplLines: tplCount, siteLines: siteCmds.length, siteRuleLines: siteRuleCount, + swWithCli, devLines: devCount, + siteCli: clip(siteCmds.join("\n")), + }); + } + + const uniqueRows = [...unique.values()] + .map((u) => ({ + command: u.command, + levels: [...u.levels].join(", "), + siteCount: u.sites.size, + switchCount: u.switches.size, + sites: clip([...u.sites].sort().join(", ")), + })) + .sort((a, b) => b.siteCount - a.siteCount || a.command.localeCompare(b.command)); + + // ---- Sheets ----------------------------------------------------------- + const siteCols = [ + { header: "Site", key: "site", width: 30 }, + { header: "Switch Template", key: "template", width: 24 }, + { header: "Switches", key: "switches", width: 10 }, + { header: "Template CLI Lines", key: "tplLines", width: 12 }, + { header: "Site CLI Lines", key: "siteLines", width: 12 }, + { header: "Site Rule CLI Lines", key: "siteRuleLines", width: 12 }, + { header: "Switches w/ Device CLI", key: "swWithCli", width: 12 }, + { header: "Device CLI Lines", key: "devLines", width: 12 }, + { header: "Site Additional CLI", key: "siteCli", width: 70, wrap: true }, + ]; + const switchCols = [ + { header: "Site", key: "site", width: 30 }, + { header: "Switch", key: "switch", width: 26 }, + { header: "Model", key: "model", width: 14 }, + { header: "MAC", key: "mac", width: 18 }, + { header: "Switch Template", key: "template", width: 24 }, + { header: "Template CLI Lines", key: "tplLines", width: 12 }, + { header: "Site CLI Lines", key: "siteLines", width: 12 }, + { header: "Device CLI Lines", key: "devLines", width: 12 }, + { header: "Device Additional CLI", key: "cli", width: 70, wrap: true }, + ]; + const lineCols = [ + { header: "Site", key: "site", width: 30 }, + { header: "Level", key: "level", width: 14 }, + { header: "Source", key: "source", width: 26 }, + { header: "Switch Rule", key: "rule", width: 20 }, + { header: "Switch", key: "switch", width: 26 }, + { header: "Line #", key: "line", width: 8 }, + { header: "Command", key: "command", width: 90 }, + ]; + const uniqueCols = [ + { header: "Command", key: "command", width: 80 }, + { header: "Levels", key: "levels", width: 22 }, + { header: "Sites", key: "siteCount", width: 8 }, + { header: "Switches (device level)", key: "switchCount", width: 12 }, + { header: "Site Names", key: "sites", width: 60, wrap: true }, + ]; + const templateCols = [ + { header: "Template", key: "template", width: 26 }, + { header: "Switch Rule", key: "rule", width: 22 }, + { header: "Lines", key: "lines", width: 8 }, + { header: "Sites Using (in scope)", key: "sites", width: 12 }, + { header: "Additional CLI", key: "cli", width: 80, wrap: true }, + ]; + + const totalSwitches = switchRows.length; + const info = [ + ["Org", ctx.orgName], + ["Scope", allSites ? "All sites" : siteName(targets[0])], + ["Sites queried", targets.length], + ["Sites that failed", failed.length], + ["Switches found", totalSwitches], + ["Switches with device-level CLI", switchRows.filter((r) => r.devLines).length], + ["Command lines (all levels)", lineRows.length], + ["Distinct commands", uniqueRows.length], + ["Note", "Template and site switch-rule commands apply only to switches that match the rule; " + + "this report does not work out which switches each rule matches."], + ["Generated", ctx.epochToUtc(Date.now())], + ]; + + const sheets = [ + ctx.xlsx.sheet("By Site", siteCols, siteRows, { tabColor: "1F4E78" }), + ctx.xlsx.sheet("By Switch", switchCols, switchRows, { tabColor: "1F4E78" }), + ctx.xlsx.sheet("CLI Lines", lineCols, lineRows, { tabColor: "C55A11" }), + ctx.xlsx.sheet("Unique Commands", uniqueCols, uniqueRows, { tabColor: "548235" }), + ctx.xlsx.sheet("Templates", templateCols, templateRows, { tabColor: "7030A0" }), + ]; + if (failed.length) { + sheets.push(ctx.xlsx.sheet("Failed Sites", + [{ header: "Site", key: "site" }, { header: "Error", key: "error", wrap: true }], + failed, { tabColor: "7F7F7F" })); + } + sheets.push(ctx.xlsx.sheet("Info", [{ header: "Field" }, { header: "Value", wrap: true }], info, + { autofilter: false, freeze: null })); + + // Plain-text copy, grouped by site, for grep/diff. + const tplText = templateRows.map((t) => + `--- Template: ${t.template}${t.rule ? ` / rule: ${t.rule}` : ""} ---\n${t.cli}`); + const txt = [ + `Mist switch additional CLI — ${ctx.orgName} — ${scopeLabel} — ${ctx.epochToUtc(Date.now())}`, + "", + ...(tplText.length ? [`${"#".repeat(78)}\nTEMPLATES\n${"#".repeat(78)}`, ...tplText, ""] : []), + ...textOut, + ].join("\n"); + const scopeName = allSites ? "all_sites" : ctx.safeName(siteName(targets[0])); + const txtName = ctx.stampedName(`mist_switch_cli_${scopeName}`, ctx.orgName, "txt"); + + const failNote = failed.length ? `, ${failed.length} site(s) failed` : ""; + return { + summary: `${lineRows.length} command line(s), ${uniqueRows.length} distinct, across ` + + `${results.length} site(s) and ${totalSwitches} switch(es)${failNote}`, + filename: ctx.stampedName(`mist_switch_cli_${scopeName}`, ctx.orgName, "xlsx"), + sheets, + preview: { title: `Additional CLI lines: ${scopeLabel}`, columns: lineCols, rows: lineRows }, + files: [{ name: txtName, blob: new Blob([txt], { type: "text/plain" }) }], + }; + }, +}; diff --git a/mist-toolbox/tools/switch-psu-status.js b/mist-toolbox/tools/switch-psu-status.js new file mode 100644 index 0000000..b653bb1 --- /dev/null +++ b/mist-toolbox/tools/switch-psu-status.js @@ -0,0 +1,426 @@ +// Switch PSU Status: power supply health for every switch in the org or one +// site, drawn as an on-screen dashboard plus an .xlsx. +// Source: GET /sites/{site_id}/stats/devices?type=switch -> module_stat[].psus[] +// Each virtual-chassis member (module_stat entry) is judged on its own, and the +// switch takes the worst member's result. + +// Raw Junos/Mist PSU status -> class. Edit these lists if your switches report +// other words. Anything not listed is "unknown" (raw text is always kept). +const PSU_OK = ["ok", "online", "normal", "good", "present", "powered", "on"]; +const PSU_ABSENT = ["absent", "not present", "not-present", "empty", "missing", "not installed"]; +const PSU_FAILED = ["failed", "fail", "failure", "check", "error", "offline", "off", "down", "faulty", "alarm", "no power"]; + +const PSU = { + ok: { label: "OK", color: "#2e7d32" }, + failed: { label: "Failed", color: "#d32f2f" }, + absent: { label: "Empty slot", color: "#9e9e9e" }, + unknown: { label: "Unknown", color: "#5c6bc0" }, +}; +const HEALTH = { + failed: { label: "PSU failed", color: "#d32f2f", rank: 0, style: "red" }, + single: { label: "Not redundant", color: "#f57c00", rank: 1, style: "yellow" }, + nodata: { label: "No PSU data", color: "#757575", rank: 2, style: undefined }, + redundant: { label: "Redundant", color: "#2e7d32", rank: 3, style: "green" }, +}; +const HEALTH_KEYS = ["failed", "single", "nodata", "redundant"]; + +const list = (v) => (Array.isArray(v) ? v : v == null ? [] : [v]); + +function classify(raw) { + const s = String(raw ?? "").trim().toLowerCase(); + if (!s) return "unknown"; + if (PSU_ABSENT.includes(s)) return "absent"; + if (PSU_FAILED.includes(s) || s.includes("fail")) return "failed"; + if (PSU_OK.includes(s)) return "ok"; + return "unknown"; +} + +function memberHealth(psus) { + const n = (c) => psus.filter((p) => p.cls === c).length; + if (n("failed")) return "failed"; + if (n("ok") >= 2) return "redundant"; + if (n("ok") === 1) return "single"; + return "nodata"; +} + +const worst = (keys) => + keys.reduce((w, k) => (HEALTH[k].rank < HEALTH[w].rank ? k : w), "redundant"); + +export default { + id: "switch-psu-status", + name: "Switch PSU Status", + description: + "Shows the power supply status of every switch in the org or one site as a dashboard, " + + "flagging failed supplies and switches without PSU redundancy.", + tag: "Mist API", + needs: { mistToken: true, org: true }, + params: [ + { + id: "allSites", + type: "checkbox", + label: "All sites in the org", + default: true, + hint: "When ticked, the Site dropdown is ignored.", + }, + { + id: "siteId", + type: "select", + label: "Site", + optionsFrom: "sites", + hint: "Used only when \"All sites in the org\" is unticked.", + }, + { + id: "includeOffline", + type: "checkbox", + label: "Include disconnected switches (last known PSU status)", + default: true, + }, + ], + + async run(ctx) { + const { allSites, siteId, includeOffline } = ctx.params; + + const sites = await ctx.listSites(); + let targets; + if (allSites) { + targets = sites; + if (!targets.length) throw new Error("This org has no sites."); + } else { + if (!siteId) throw new Error("Pick a site, or tick \"All sites in the org\"."); + targets = [sites.find((x) => x.id === siteId) || { id: siteId, name: siteId }]; + } + const siteName = (s) => s.name || s.id; + const scopeLabel = allSites ? `all ${targets.length} sites` : siteName(targets[0]); + + // ---- Collect ---------------------------------------------------------- + ctx.status(`Loading switch stats for ${scopeLabel}\u2026`); + let done = 0; + const failedSites = []; + const perSite = await ctx.pool( + ctx.POOL_LIMIT, + targets.map((site) => async () => { + if (ctx.signal.aborted) return []; + try { + const stats = await ctx.getAll(`/sites/${site.id}/stats/devices`, { type: "switch", limit: 1000 }); + return stats + .filter((d) => !d.type || d.type === "switch") + .map((d) => ({ ...d, __site: siteName(site) })); + } catch (e) { + failedSites.push({ site: siteName(site), error: e.message }); + ctx.log(`${siteName(site)}: ${e.message}`, "err"); + return []; + } finally { + ctx.progress(++done, targets.length, siteName(site)); + } + }), + ); + if (ctx.signal.aborted) throw new Error("Cancelled."); + + // ---- Shape ------------------------------------------------------------ + const switches = perSite.flat() + .map((d) => { + const online = String(d.status || "").toLowerCase() === "connected"; + const members = list(d.module_stat) + .filter((m) => m && (m.psus || m.fpc_idx != null || m.serial)) + .map((m, i) => { + const psus = list(m.psus).map((p, j) => ({ + name: p?.name || `PSU ${j}`, + raw: p?.status ?? "", + cls: classify(p?.status), + })); + return { + idx: m.fpc_idx ?? m.idx ?? i, + role: m.vc_role || "", + model: m.model || d.model || "", + serial: m.serial || "", + psus, + health: memberHealth(psus), + }; + }) + .sort((a, b) => Number(a.idx) - Number(b.idx)); + return { + site: d.__site, + name: d.name || ctx.fmtMac(String(d.mac || "")) || d.id, + mac: d.mac ? ctx.fmtMac(String(d.mac)) : "", + model: d.model || "", + version: d.version || "", + online, + members, + health: members.length ? worst(members.map((m) => m.health)) : "nodata", + }; + }) + .filter((s) => includeOffline || s.online) + .sort((a, b) => a.site.localeCompare(b.site) || a.name.localeCompare(b.name)); + + const psuRows = []; + for (const sw of switches) { + for (const m of sw.members) { + for (const p of m.psus) { + psuRows.push({ + site: sw.site, switch: sw.name, model: m.model, member: m.idx, role: m.role, + serial: m.serial, psu: p.name, raw: p.raw, cls: PSU[p.cls].label, + online: sw.online ? "Connected" : "Disconnected", + __style: p.cls === "failed" ? "red" : p.cls === "unknown" ? "yellow" : undefined, + }); + } + } + } + + const count = (arr, f) => arr.filter(f).length; + const totals = Object.fromEntries(HEALTH_KEYS.map((k) => [k, count(switches, (s) => s.health === k)])); + const allPsus = switches.flatMap((s) => s.members.flatMap((m) => m.psus)); + const psuTotals = Object.fromEntries(Object.keys(PSU).map((k) => [k, count(allPsus, (p) => p.cls === k)])); + const offline = count(switches, (s) => !s.online); + + const bySite = new Map(); + for (const sw of switches) { + const r = bySite.get(sw.site) || { site: sw.site, total: 0, failed: 0, single: 0, nodata: 0, redundant: 0, offline: 0 }; + r.total += 1; + r[sw.health] += 1; + if (!sw.online) r.offline += 1; + bySite.set(sw.site, r); + } + const siteRows = [...bySite.values()].sort((a, b) => + b.failed - a.failed || b.single - a.single || a.site.localeCompare(b.site)); + + ctx.log(`${switches.length} switch(es), ${allPsus.length} PSU(s): ` + + `${psuTotals.failed} failed, ${totals.single} switch(es) not redundant`, + psuTotals.failed ? "err" : "ok"); + + // ---- Dashboard -------------------------------------------------------- + if (ctx.mount) drawDashboard(ctx, { switches, siteRows, totals, psuTotals, offline, scopeLabel }); + + // ---- Workbook --------------------------------------------------------- + const switchCols = [ + { header: "Site", key: "site", width: 30 }, + { header: "Switch", key: "name", width: 26 }, + { header: "Model", key: "model", width: 14 }, + { header: "MAC", key: "mac", width: 18 }, + { header: "State", key: "state", width: 13 }, + { header: "VC Members", key: "members", width: 10 }, + { header: "PSUs OK", key: "ok", width: 9 }, + { header: "PSUs Failed", key: "failed", width: 10 }, + { header: "Empty Slots", key: "absent", width: 10 }, + { header: "PSUs Unknown", key: "unknown", width: 10 }, + { header: "PSU Health", key: "health", width: 16 }, + { header: "Detail", key: "detail", width: 60, wrap: true }, + ]; + const switchRows = [...switches] + .sort((a, b) => HEALTH[a.health].rank - HEALTH[b.health].rank || a.site.localeCompare(b.site)) + .map((s) => { + const ps = s.members.flatMap((m) => m.psus); + return { + site: s.site, name: s.name, model: s.model, mac: s.mac, + state: s.online ? "Connected" : "Disconnected", + members: s.members.length, + ok: count(ps, (p) => p.cls === "ok"), + failed: count(ps, (p) => p.cls === "failed"), + absent: count(ps, (p) => p.cls === "absent"), + unknown: count(ps, (p) => p.cls === "unknown"), + health: HEALTH[s.health].label, + detail: s.members.map((m) => + `${s.members.length > 1 ? `FPC ${m.idx}: ` : ""}` + + (m.psus.length ? m.psus.map((p) => `${p.name}=${p.raw || "?"}`).join(", ") : "no PSU data")).join("\n"), + __style: HEALTH[s.health].style === "green" ? undefined : HEALTH[s.health].style, + }; + }); + const psuCols = [ + { header: "Site", key: "site", width: 30 }, + { header: "Switch", key: "switch", width: 26 }, + { header: "Member Model", key: "model", width: 14 }, + { header: "FPC", key: "member", width: 6 }, + { header: "VC Role", key: "role", width: 10 }, + { header: "Member Serial", key: "serial", width: 16 }, + { header: "PSU", key: "psu", width: 18 }, + { header: "Reported Status", key: "raw", width: 16 }, + { header: "Class", key: "cls", width: 12 }, + { header: "Switch State", key: "online", width: 13 }, + ]; + const siteCols = [ + { header: "Site", key: "site", width: 30 }, + { header: "Switches", key: "total" }, + { header: "PSU Failed", key: "failed" }, + { header: "Not Redundant", key: "single" }, + { header: "No PSU Data", key: "nodata" }, + { header: "Redundant", key: "redundant" }, + { header: "Disconnected", key: "offline" }, + ]; + const info = [ + ["Org", ctx.orgName], + ["Scope", allSites ? "All sites" : siteName(targets[0])], + ["Sites queried", targets.length], + ["Sites that failed", failedSites.length], + ["Switches", switches.length], + ["Disconnected switches", includeOffline ? `${offline} (last known status shown)` : "excluded"], + ["PSUs OK / failed / empty / unknown", + `${psuTotals.ok} / ${psuTotals.failed} / ${psuTotals.absent} / ${psuTotals.unknown}`], + ["Redundant", "Every member has 2 or more PSUs reporting OK"], + ["Not redundant", "A member has exactly 1 PSU reporting OK (includes single-PSU models)"], + ["PSU failed", "A member has a PSU reporting failed, check, offline or similar"], + ["Generated", ctx.epochToUtc(Date.now())], + ]; + const sheets = [ + ctx.xlsx.sheet("Switches", switchCols, switchRows, { tabColor: "1F4E78" }), + ctx.xlsx.sheet("PSUs", psuCols, psuRows, { tabColor: "C55A11" }), + ctx.xlsx.sheet("By Site", siteCols, siteRows.map((r) => ({ + ...r, __style: r.failed ? "red" : r.single ? "yellow" : undefined })), { tabColor: "548235" }), + ]; + if (failedSites.length) { + sheets.push(ctx.xlsx.sheet("Failed Sites", + [{ header: "Site", key: "site" }, { header: "Error", key: "error", wrap: true }], + failedSites, { tabColor: "7F7F7F" })); + } + sheets.push(ctx.xlsx.sheet("Info", [{ header: "Field" }, { header: "Value", wrap: true }], info, + { autofilter: false, freeze: null })); + + const scopeName = allSites ? "all_sites" : ctx.safeName(siteName(targets[0])); + const failNote = failedSites.length ? `, ${failedSites.length} site(s) failed` : ""; + return { + summary: `${switches.length} switch(es): ${totals.failed} with a failed PSU, ` + + `${totals.single} not redundant, ${totals.redundant} redundant${failNote}`, + filename: ctx.stampedName(`mist_switch_psu_${scopeName}`, ctx.orgName, "xlsx"), + sheets, + }; + }, +}; + +// --------------------------------------------------------------------------- +// On-screen dashboard: summary tiles, a stacked bar per site, and a card per +// switch showing each PSU. Filters: needs-attention toggle, text search, and +// clicking a site bar to focus that site. +function drawDashboard(ctx, { switches, siteRows, totals, psuTotals, offline, scopeLabel }) { + const E = ctx.esc; + const state = { issuesOnly: totals.failed + totals.single > 0, q: "", site: "" }; + + const tile = (n, label, color) => + `
${n}${E(label)}
`; + + const bar = (r) => HEALTH_KEYS.filter((k) => r[k]).map((k) => + ``).join(""); + + const pill = (p) => { + const num = (String(p.name).match(/(\d+)\s*$/) || [])[1]; + return `${E(num != null ? `PSU ${num}` : p.name)}`; + }; + + const card = (s) => { + const members = s.members.length + ? s.members.map((m) => + `
${s.members.length > 1 + ? `FPC ${E(m.idx)}${m.role ? ` \u00b7 ${E(m.role)}` : ""}` : ""}` + + `${m.psus.length ? m.psus.map(pill).join("") : `no PSU data`}
`).join("") + : `
no PSU data
`; + return `
` + + `
${E(s.name)}` + + `${E(HEALTH[s.health].label)}
` + + `
${E(s.model)}${s.online ? "" : " \u00b7 disconnected"}
${members}
`; + }; + + const legend = + Object.values(PSU).map((p) => `${E(p.label)}`).join("") + + `` + + HEALTH_KEYS.map((k) => `${E(HEALTH[k].label)}`).join(""); + + ctx.mount.innerHTML = ` + +
+
+ ${tile(switches.length, `Switches (${scopeLabel})`, "#1f4e78")} + ${tile(totals.failed, HEALTH.failed.label, HEALTH.failed.color)} + ${tile(totals.single, HEALTH.single.label, HEALTH.single.color)} + ${tile(totals.redundant, HEALTH.redundant.label, HEALTH.redundant.color)} + ${tile(totals.nodata, HEALTH.nodata.label, HEALTH.nodata.color)} + ${tile(`${psuTotals.failed}/${psuTotals.ok + psuTotals.failed + psuTotals.unknown}`, "PSUs failed / installed", PSU.failed.color)} + ${tile(offline, "Disconnected", "#9e9e9e")} +
+
${legend}
+

By site (click a site to focus it)

+
${siteRows.map((r) => + `
${E(r.site)}` + + `
${bar(r)}
` + + `${r.failed ? `${r.failed} failed \u00b7 ` : ""}` + + `${r.single ? `${r.single} not redundant \u00b7 ` : ""}${r.total} sw
`).join("")}
+
+ + + +
+
+
`; + + const root = ctx.mount; + const out = root.querySelector(".psu-out"); + + const render = () => { + const q = state.q.toLowerCase(); + const shown = switches.filter((s) => + (!state.issuesOnly || s.health === "failed" || s.health === "single") && + (!state.site || s.site === state.site) && + (!q || `${s.name} ${s.model} ${s.site} ${s.mac}`.toLowerCase().includes(q))); + shown.sort((a, b) => a.site.localeCompare(b.site) || + HEALTH[a.health].rank - HEALTH[b.health].rank || a.name.localeCompare(b.name)); + + const groups = new Map(); + for (const s of shown) groups.set(s.site, [...(groups.get(s.site) || []), s]); + out.innerHTML = shown.length + ? [...groups].map(([site, list]) => + `
${E(site)} (${list.length})
` + + `
${list.map(card).join("")}
`).join("") + : `
No switches match.${state.issuesOnly ? " No switch has a failed PSU or lost redundancy." : ""}
`; + + root.querySelectorAll("[data-mode]").forEach((b) => + b.classList.toggle("on", (b.dataset.mode === "issues") === state.issuesOnly)); + root.querySelectorAll(".psu-srow").forEach((r) => + r.classList.toggle("on", r.dataset.site === state.site)); + }; + + root.querySelectorAll("[data-mode]").forEach((b) => + b.addEventListener("click", () => { state.issuesOnly = b.dataset.mode === "issues"; render(); })); + root.querySelector(".psu-ctl input").addEventListener("input", (e) => { state.q = e.target.value; render(); }); + root.querySelectorAll(".psu-srow").forEach((r) => + r.addEventListener("click", () => { + state.site = state.site === r.dataset.site ? "" : r.dataset.site; + render(); + out.scrollIntoView({ behavior: "smooth", block: "start" }); + })); + render(); +} diff --git a/mist-toolbox/tools/tools.json b/mist-toolbox/tools/tools.json index c6c2642..e8b713c 100644 --- a/mist-toolbox/tools/tools.json +++ b/mist-toolbox/tools/tools.json @@ -1,11 +1,14 @@ [ + "client-wifi-phy.js", "disconnect-console.js", "ip-blocks.js", "port-inventory.js", "site-alarms.js", "ssid-report.js", "ssr-pre-post.js", + "switch-additional-cli.js", "switch-configs.js", + "switch-psu-status.js", "switch-report.js", "wifi-clients.js" ] diff --git a/switch-additional-cli.js b/switch-additional-cli.js new file mode 100644 index 0000000..ed21c15 --- /dev/null +++ b/switch-additional-cli.js @@ -0,0 +1,300 @@ +// Switch Additional CLI: collects every "additional CLI commands" block that +// applies to switches, for every site in the org or one chosen site. +// Levels read: +// Template GET /orgs/{org_id}/networktemplates (template + its switch rules) +// Site GET /sites/{site_id}/setting (site + its switch rules) +// Device GET /sites/{site_id}/devices?type=switch +// Mist field: additional_config_cmds (array of strings, one command per entry). + +const CELL_MAX = 32000; // Excel cell limit is 32,767 characters + +const list = (v) => (Array.isArray(v) ? v : v == null || v === "" ? [] : [v]); +const cmds = (obj) => list(obj?.additional_config_cmds).map((c) => String(c).trim()).filter(Boolean); +const clip = (s) => (s.length > CELL_MAX ? s.slice(0, CELL_MAX) + "\n…(truncated)" : s); +const rules = (obj) => list(obj?.switch_matching?.rules); + +export default { + id: "switch-additional-cli", + name: "Switch Additional CLI", + description: + "Gathers the additional CLI commands applied to switches at template, site and " + + "device level, for every site in the org or one site, laid out for side-by-side comparison.", + tag: "Mist API", + needs: { mistToken: true, org: true }, + params: [ + { + id: "allSites", + type: "checkbox", + label: "All sites in the org", + default: true, + hint: "When ticked, the Site dropdown is ignored.", + }, + { + id: "siteId", + type: "select", + label: "Site", + optionsFrom: "sites", + hint: "Used only when \"All sites in the org\" is unticked.", + }, + ], + + async run(ctx) { + const { allSites, siteId } = ctx.params; + + // Full site records, not ctx.listSites(): that trims each site to {id, name} + // and drops networktemplate_id, which the template level needs. + const sites = (await ctx.getAll(`/orgs/${ctx.orgId}/sites`)) + .filter((s) => typeof s.id === "string") + .sort((a, b) => String(a.name || a.id).localeCompare(String(b.name || b.id))); + let targets; + if (allSites) { + targets = sites; + if (!targets.length) throw new Error("This org has no sites."); + } else { + if (!siteId) throw new Error("Pick a site, or tick \"All sites in the org\"."); + const s = sites.find((x) => x.id === siteId); + targets = [s || { id: siteId, name: siteId }]; + } + const siteName = (s) => s.name || s.id; + const scopeLabel = allSites ? `all ${targets.length} sites` : siteName(targets[0]); + + // Templates: org level, read once. + ctx.status("Loading switch templates…"); + let templates = []; + try { + templates = await ctx.getAll(`/orgs/${ctx.orgId}/networktemplates`); + } catch (e) { + ctx.log(`Switch templates unavailable (${e.message})`, "err"); + } + const tplById = Object.fromEntries(templates.map((t) => [t.id, t])); + + // Sites: setting + switches, in parallel. One failing site is logged and skipped. + ctx.status(`Loading additional CLI for ${scopeLabel}…`); + let done = 0; + const failed = []; + const perSite = await ctx.pool( + ctx.POOL_LIMIT, + targets.map((site) => async () => { + if (ctx.signal.aborted) return null; + try { + const [setting, devices] = await Promise.all([ + ctx.mistGet(`/sites/${site.id}/setting`), + ctx.getAll(`/sites/${site.id}/devices`, { type: "switch" }), + ]); + return { site, setting, switches: devices.filter((d) => !d.type || d.type === "switch") }; + } catch (e) { + failed.push({ site: siteName(site), error: e.message }); + ctx.log(`${siteName(site)}: ${e.message}`, "err"); + return null; + } finally { + ctx.progress(++done, targets.length, siteName(site)); + } + }), + ); + if (ctx.signal.aborted) throw new Error("Cancelled."); + const results = perSite.filter(Boolean); + + // ---- Build rows ------------------------------------------------------- + const lineRows = []; // one row per command + const switchRows = []; // one row per switch + const siteRows = []; // one row per site + const unique = new Map(); // command -> { levels, sites, switches } + const textOut = []; // combined .txt + + const addLines = (base, list) => + list.forEach((command, i) => lineRows.push({ ...base, line: i + 1, command })); + const track = (command, level, site, sw) => { + const u = unique.get(command) || { command, levels: new Set(), sites: new Set(), switches: new Set() }; + u.levels.add(level); + if (site) u.sites.add(site); + if (sw) u.switches.add(sw); + unique.set(command, u); + }; + + // Template level: listed once per template, and credited to every site that uses it. + const tplSites = new Map(); // template id -> [site names in scope] + for (const { site } of results) { + const id = site.networktemplate_id; + if (id) tplSites.set(id, [...(tplSites.get(id) || []), siteName(site)]); + } + const templateRows = []; + for (const [id, usedBy] of tplSites) { + const t = tplById[id]; + const tName = t?.name || id; + const blocks = t ? [{ rule: "", list: cmds(t) }, ...rules(t).map((r) => ({ rule: r.name || "(unnamed rule)", list: cmds(r) }))] : []; + for (const b of blocks.filter((b) => b.list.length)) { + const level = b.rule ? "Template rule" : "Template"; + templateRows.push({ + template: tName, rule: b.rule, lines: b.list.length, + sites: usedBy.length, cli: clip(b.list.join("\n")), + }); + addLines({ site: "", level, source: tName, rule: b.rule, switch: "" }, b.list); + for (const c of b.list) for (const s of usedBy) track(c, level, s, ""); + } + } + + for (const { site, setting, switches } of results.sort((a, b) => siteName(a.site).localeCompare(siteName(b.site)))) { + const sName = siteName(site); + const tpl = tplById[site.networktemplate_id]; + const tName = tpl?.name || site.networktemplate_id || ""; + const tplCount = tpl ? cmds(tpl).length + rules(tpl).reduce((n, r) => n + cmds(r).length, 0) : 0; + + textOut.push(`${"=".repeat(78)}\nSITE: ${sName}${tName ? ` (template: ${tName})` : ""}\n${"=".repeat(78)}`); + + // Site level + const siteCmds = cmds(setting); + addLines({ site: sName, level: "Site", source: sName, rule: "", switch: "" }, siteCmds); + siteCmds.forEach((c) => track(c, "Site", sName, "")); + if (siteCmds.length) textOut.push(`--- Site additional CLI ---\n${siteCmds.join("\n")}`); + + let siteRuleCount = 0; + for (const r of rules(setting)) { + const rc = cmds(r); + if (!rc.length) continue; + siteRuleCount += rc.length; + const rName = r.name || "(unnamed rule)"; + addLines({ site: sName, level: "Site rule", source: sName, rule: rName, switch: "" }, rc); + rc.forEach((c) => track(c, "Site rule", sName, "")); + textOut.push(`--- Site switch rule: ${rName} ---\n${rc.join("\n")}`); + } + + // Device level + let devCount = 0; + let swWithCli = 0; + for (const sw of switches.sort((a, b) => String(a.name || a.mac).localeCompare(String(b.name || b.mac)))) { + const swName = sw.name || ctx.fmtMac(String(sw.mac || "")) || sw.id; + const dc = cmds(sw); + devCount += dc.length; + if (dc.length) swWithCli += 1; + addLines({ site: sName, level: "Device", source: swName, rule: "", switch: swName }, dc); + dc.forEach((c) => track(c, "Device", sName, `${sName} / ${swName}`)); + if (dc.length) textOut.push(`--- Switch: ${swName} (${sw.model || "?"}) ---\n${dc.join("\n")}`); + switchRows.push({ + site: sName, switch: swName, model: sw.model || "", + mac: sw.mac ? ctx.fmtMac(String(sw.mac)) : "", template: tName, + tplLines: tplCount, siteLines: siteCmds.length + siteRuleCount, devLines: dc.length, + cli: clip(dc.join("\n")), + __style: dc.length ? "yellow" : undefined, + }); + } + if (!siteCmds.length && !siteRuleCount && !devCount) textOut.push("(no site or device additional CLI)"); + textOut.push(""); + + siteRows.push({ + site: sName, template: tName, switches: switches.length, + tplLines: tplCount, siteLines: siteCmds.length, siteRuleLines: siteRuleCount, + swWithCli, devLines: devCount, + siteCli: clip(siteCmds.join("\n")), + }); + } + + const uniqueRows = [...unique.values()] + .map((u) => ({ + command: u.command, + levels: [...u.levels].join(", "), + siteCount: u.sites.size, + switchCount: u.switches.size, + sites: clip([...u.sites].sort().join(", ")), + })) + .sort((a, b) => b.siteCount - a.siteCount || a.command.localeCompare(b.command)); + + // ---- Sheets ----------------------------------------------------------- + const siteCols = [ + { header: "Site", key: "site", width: 30 }, + { header: "Switch Template", key: "template", width: 24 }, + { header: "Switches", key: "switches", width: 10 }, + { header: "Template CLI Lines", key: "tplLines", width: 12 }, + { header: "Site CLI Lines", key: "siteLines", width: 12 }, + { header: "Site Rule CLI Lines", key: "siteRuleLines", width: 12 }, + { header: "Switches w/ Device CLI", key: "swWithCli", width: 12 }, + { header: "Device CLI Lines", key: "devLines", width: 12 }, + { header: "Site Additional CLI", key: "siteCli", width: 70, wrap: true }, + ]; + const switchCols = [ + { header: "Site", key: "site", width: 30 }, + { header: "Switch", key: "switch", width: 26 }, + { header: "Model", key: "model", width: 14 }, + { header: "MAC", key: "mac", width: 18 }, + { header: "Switch Template", key: "template", width: 24 }, + { header: "Template CLI Lines", key: "tplLines", width: 12 }, + { header: "Site CLI Lines", key: "siteLines", width: 12 }, + { header: "Device CLI Lines", key: "devLines", width: 12 }, + { header: "Device Additional CLI", key: "cli", width: 70, wrap: true }, + ]; + const lineCols = [ + { header: "Site", key: "site", width: 30 }, + { header: "Level", key: "level", width: 14 }, + { header: "Source", key: "source", width: 26 }, + { header: "Switch Rule", key: "rule", width: 20 }, + { header: "Switch", key: "switch", width: 26 }, + { header: "Line #", key: "line", width: 8 }, + { header: "Command", key: "command", width: 90 }, + ]; + const uniqueCols = [ + { header: "Command", key: "command", width: 80 }, + { header: "Levels", key: "levels", width: 22 }, + { header: "Sites", key: "siteCount", width: 8 }, + { header: "Switches (device level)", key: "switchCount", width: 12 }, + { header: "Site Names", key: "sites", width: 60, wrap: true }, + ]; + const templateCols = [ + { header: "Template", key: "template", width: 26 }, + { header: "Switch Rule", key: "rule", width: 22 }, + { header: "Lines", key: "lines", width: 8 }, + { header: "Sites Using (in scope)", key: "sites", width: 12 }, + { header: "Additional CLI", key: "cli", width: 80, wrap: true }, + ]; + + const totalSwitches = switchRows.length; + const info = [ + ["Org", ctx.orgName], + ["Scope", allSites ? "All sites" : siteName(targets[0])], + ["Sites queried", targets.length], + ["Sites that failed", failed.length], + ["Switches found", totalSwitches], + ["Switches with device-level CLI", switchRows.filter((r) => r.devLines).length], + ["Command lines (all levels)", lineRows.length], + ["Distinct commands", uniqueRows.length], + ["Note", "Template and site switch-rule commands apply only to switches that match the rule; " + + "this report does not work out which switches each rule matches."], + ["Generated", ctx.epochToUtc(Date.now())], + ]; + + const sheets = [ + ctx.xlsx.sheet("By Site", siteCols, siteRows, { tabColor: "1F4E78" }), + ctx.xlsx.sheet("By Switch", switchCols, switchRows, { tabColor: "1F4E78" }), + ctx.xlsx.sheet("CLI Lines", lineCols, lineRows, { tabColor: "C55A11" }), + ctx.xlsx.sheet("Unique Commands", uniqueCols, uniqueRows, { tabColor: "548235" }), + ctx.xlsx.sheet("Templates", templateCols, templateRows, { tabColor: "7030A0" }), + ]; + if (failed.length) { + sheets.push(ctx.xlsx.sheet("Failed Sites", + [{ header: "Site", key: "site" }, { header: "Error", key: "error", wrap: true }], + failed, { tabColor: "7F7F7F" })); + } + sheets.push(ctx.xlsx.sheet("Info", [{ header: "Field" }, { header: "Value", wrap: true }], info, + { autofilter: false, freeze: null })); + + // Plain-text copy, grouped by site, for grep/diff. + const tplText = templateRows.map((t) => + `--- Template: ${t.template}${t.rule ? ` / rule: ${t.rule}` : ""} ---\n${t.cli}`); + const txt = [ + `Mist switch additional CLI — ${ctx.orgName} — ${scopeLabel} — ${ctx.epochToUtc(Date.now())}`, + "", + ...(tplText.length ? [`${"#".repeat(78)}\nTEMPLATES\n${"#".repeat(78)}`, ...tplText, ""] : []), + ...textOut, + ].join("\n"); + const scopeName = allSites ? "all_sites" : ctx.safeName(siteName(targets[0])); + const txtName = ctx.stampedName(`mist_switch_cli_${scopeName}`, ctx.orgName, "txt"); + + const failNote = failed.length ? `, ${failed.length} site(s) failed` : ""; + return { + summary: `${lineRows.length} command line(s), ${uniqueRows.length} distinct, across ` + + `${results.length} site(s) and ${totalSwitches} switch(es)${failNote}`, + filename: ctx.stampedName(`mist_switch_cli_${scopeName}`, ctx.orgName, "xlsx"), + sheets, + preview: { title: `Additional CLI lines: ${scopeLabel}`, columns: lineCols, rows: lineRows }, + files: [{ name: txtName, blob: new Blob([txt], { type: "text/plain" }) }], + }; + }, +}; diff --git a/switch-psu-status.js b/switch-psu-status.js new file mode 100644 index 0000000..b653bb1 --- /dev/null +++ b/switch-psu-status.js @@ -0,0 +1,426 @@ +// Switch PSU Status: power supply health for every switch in the org or one +// site, drawn as an on-screen dashboard plus an .xlsx. +// Source: GET /sites/{site_id}/stats/devices?type=switch -> module_stat[].psus[] +// Each virtual-chassis member (module_stat entry) is judged on its own, and the +// switch takes the worst member's result. + +// Raw Junos/Mist PSU status -> class. Edit these lists if your switches report +// other words. Anything not listed is "unknown" (raw text is always kept). +const PSU_OK = ["ok", "online", "normal", "good", "present", "powered", "on"]; +const PSU_ABSENT = ["absent", "not present", "not-present", "empty", "missing", "not installed"]; +const PSU_FAILED = ["failed", "fail", "failure", "check", "error", "offline", "off", "down", "faulty", "alarm", "no power"]; + +const PSU = { + ok: { label: "OK", color: "#2e7d32" }, + failed: { label: "Failed", color: "#d32f2f" }, + absent: { label: "Empty slot", color: "#9e9e9e" }, + unknown: { label: "Unknown", color: "#5c6bc0" }, +}; +const HEALTH = { + failed: { label: "PSU failed", color: "#d32f2f", rank: 0, style: "red" }, + single: { label: "Not redundant", color: "#f57c00", rank: 1, style: "yellow" }, + nodata: { label: "No PSU data", color: "#757575", rank: 2, style: undefined }, + redundant: { label: "Redundant", color: "#2e7d32", rank: 3, style: "green" }, +}; +const HEALTH_KEYS = ["failed", "single", "nodata", "redundant"]; + +const list = (v) => (Array.isArray(v) ? v : v == null ? [] : [v]); + +function classify(raw) { + const s = String(raw ?? "").trim().toLowerCase(); + if (!s) return "unknown"; + if (PSU_ABSENT.includes(s)) return "absent"; + if (PSU_FAILED.includes(s) || s.includes("fail")) return "failed"; + if (PSU_OK.includes(s)) return "ok"; + return "unknown"; +} + +function memberHealth(psus) { + const n = (c) => psus.filter((p) => p.cls === c).length; + if (n("failed")) return "failed"; + if (n("ok") >= 2) return "redundant"; + if (n("ok") === 1) return "single"; + return "nodata"; +} + +const worst = (keys) => + keys.reduce((w, k) => (HEALTH[k].rank < HEALTH[w].rank ? k : w), "redundant"); + +export default { + id: "switch-psu-status", + name: "Switch PSU Status", + description: + "Shows the power supply status of every switch in the org or one site as a dashboard, " + + "flagging failed supplies and switches without PSU redundancy.", + tag: "Mist API", + needs: { mistToken: true, org: true }, + params: [ + { + id: "allSites", + type: "checkbox", + label: "All sites in the org", + default: true, + hint: "When ticked, the Site dropdown is ignored.", + }, + { + id: "siteId", + type: "select", + label: "Site", + optionsFrom: "sites", + hint: "Used only when \"All sites in the org\" is unticked.", + }, + { + id: "includeOffline", + type: "checkbox", + label: "Include disconnected switches (last known PSU status)", + default: true, + }, + ], + + async run(ctx) { + const { allSites, siteId, includeOffline } = ctx.params; + + const sites = await ctx.listSites(); + let targets; + if (allSites) { + targets = sites; + if (!targets.length) throw new Error("This org has no sites."); + } else { + if (!siteId) throw new Error("Pick a site, or tick \"All sites in the org\"."); + targets = [sites.find((x) => x.id === siteId) || { id: siteId, name: siteId }]; + } + const siteName = (s) => s.name || s.id; + const scopeLabel = allSites ? `all ${targets.length} sites` : siteName(targets[0]); + + // ---- Collect ---------------------------------------------------------- + ctx.status(`Loading switch stats for ${scopeLabel}\u2026`); + let done = 0; + const failedSites = []; + const perSite = await ctx.pool( + ctx.POOL_LIMIT, + targets.map((site) => async () => { + if (ctx.signal.aborted) return []; + try { + const stats = await ctx.getAll(`/sites/${site.id}/stats/devices`, { type: "switch", limit: 1000 }); + return stats + .filter((d) => !d.type || d.type === "switch") + .map((d) => ({ ...d, __site: siteName(site) })); + } catch (e) { + failedSites.push({ site: siteName(site), error: e.message }); + ctx.log(`${siteName(site)}: ${e.message}`, "err"); + return []; + } finally { + ctx.progress(++done, targets.length, siteName(site)); + } + }), + ); + if (ctx.signal.aborted) throw new Error("Cancelled."); + + // ---- Shape ------------------------------------------------------------ + const switches = perSite.flat() + .map((d) => { + const online = String(d.status || "").toLowerCase() === "connected"; + const members = list(d.module_stat) + .filter((m) => m && (m.psus || m.fpc_idx != null || m.serial)) + .map((m, i) => { + const psus = list(m.psus).map((p, j) => ({ + name: p?.name || `PSU ${j}`, + raw: p?.status ?? "", + cls: classify(p?.status), + })); + return { + idx: m.fpc_idx ?? m.idx ?? i, + role: m.vc_role || "", + model: m.model || d.model || "", + serial: m.serial || "", + psus, + health: memberHealth(psus), + }; + }) + .sort((a, b) => Number(a.idx) - Number(b.idx)); + return { + site: d.__site, + name: d.name || ctx.fmtMac(String(d.mac || "")) || d.id, + mac: d.mac ? ctx.fmtMac(String(d.mac)) : "", + model: d.model || "", + version: d.version || "", + online, + members, + health: members.length ? worst(members.map((m) => m.health)) : "nodata", + }; + }) + .filter((s) => includeOffline || s.online) + .sort((a, b) => a.site.localeCompare(b.site) || a.name.localeCompare(b.name)); + + const psuRows = []; + for (const sw of switches) { + for (const m of sw.members) { + for (const p of m.psus) { + psuRows.push({ + site: sw.site, switch: sw.name, model: m.model, member: m.idx, role: m.role, + serial: m.serial, psu: p.name, raw: p.raw, cls: PSU[p.cls].label, + online: sw.online ? "Connected" : "Disconnected", + __style: p.cls === "failed" ? "red" : p.cls === "unknown" ? "yellow" : undefined, + }); + } + } + } + + const count = (arr, f) => arr.filter(f).length; + const totals = Object.fromEntries(HEALTH_KEYS.map((k) => [k, count(switches, (s) => s.health === k)])); + const allPsus = switches.flatMap((s) => s.members.flatMap((m) => m.psus)); + const psuTotals = Object.fromEntries(Object.keys(PSU).map((k) => [k, count(allPsus, (p) => p.cls === k)])); + const offline = count(switches, (s) => !s.online); + + const bySite = new Map(); + for (const sw of switches) { + const r = bySite.get(sw.site) || { site: sw.site, total: 0, failed: 0, single: 0, nodata: 0, redundant: 0, offline: 0 }; + r.total += 1; + r[sw.health] += 1; + if (!sw.online) r.offline += 1; + bySite.set(sw.site, r); + } + const siteRows = [...bySite.values()].sort((a, b) => + b.failed - a.failed || b.single - a.single || a.site.localeCompare(b.site)); + + ctx.log(`${switches.length} switch(es), ${allPsus.length} PSU(s): ` + + `${psuTotals.failed} failed, ${totals.single} switch(es) not redundant`, + psuTotals.failed ? "err" : "ok"); + + // ---- Dashboard -------------------------------------------------------- + if (ctx.mount) drawDashboard(ctx, { switches, siteRows, totals, psuTotals, offline, scopeLabel }); + + // ---- Workbook --------------------------------------------------------- + const switchCols = [ + { header: "Site", key: "site", width: 30 }, + { header: "Switch", key: "name", width: 26 }, + { header: "Model", key: "model", width: 14 }, + { header: "MAC", key: "mac", width: 18 }, + { header: "State", key: "state", width: 13 }, + { header: "VC Members", key: "members", width: 10 }, + { header: "PSUs OK", key: "ok", width: 9 }, + { header: "PSUs Failed", key: "failed", width: 10 }, + { header: "Empty Slots", key: "absent", width: 10 }, + { header: "PSUs Unknown", key: "unknown", width: 10 }, + { header: "PSU Health", key: "health", width: 16 }, + { header: "Detail", key: "detail", width: 60, wrap: true }, + ]; + const switchRows = [...switches] + .sort((a, b) => HEALTH[a.health].rank - HEALTH[b.health].rank || a.site.localeCompare(b.site)) + .map((s) => { + const ps = s.members.flatMap((m) => m.psus); + return { + site: s.site, name: s.name, model: s.model, mac: s.mac, + state: s.online ? "Connected" : "Disconnected", + members: s.members.length, + ok: count(ps, (p) => p.cls === "ok"), + failed: count(ps, (p) => p.cls === "failed"), + absent: count(ps, (p) => p.cls === "absent"), + unknown: count(ps, (p) => p.cls === "unknown"), + health: HEALTH[s.health].label, + detail: s.members.map((m) => + `${s.members.length > 1 ? `FPC ${m.idx}: ` : ""}` + + (m.psus.length ? m.psus.map((p) => `${p.name}=${p.raw || "?"}`).join(", ") : "no PSU data")).join("\n"), + __style: HEALTH[s.health].style === "green" ? undefined : HEALTH[s.health].style, + }; + }); + const psuCols = [ + { header: "Site", key: "site", width: 30 }, + { header: "Switch", key: "switch", width: 26 }, + { header: "Member Model", key: "model", width: 14 }, + { header: "FPC", key: "member", width: 6 }, + { header: "VC Role", key: "role", width: 10 }, + { header: "Member Serial", key: "serial", width: 16 }, + { header: "PSU", key: "psu", width: 18 }, + { header: "Reported Status", key: "raw", width: 16 }, + { header: "Class", key: "cls", width: 12 }, + { header: "Switch State", key: "online", width: 13 }, + ]; + const siteCols = [ + { header: "Site", key: "site", width: 30 }, + { header: "Switches", key: "total" }, + { header: "PSU Failed", key: "failed" }, + { header: "Not Redundant", key: "single" }, + { header: "No PSU Data", key: "nodata" }, + { header: "Redundant", key: "redundant" }, + { header: "Disconnected", key: "offline" }, + ]; + const info = [ + ["Org", ctx.orgName], + ["Scope", allSites ? "All sites" : siteName(targets[0])], + ["Sites queried", targets.length], + ["Sites that failed", failedSites.length], + ["Switches", switches.length], + ["Disconnected switches", includeOffline ? `${offline} (last known status shown)` : "excluded"], + ["PSUs OK / failed / empty / unknown", + `${psuTotals.ok} / ${psuTotals.failed} / ${psuTotals.absent} / ${psuTotals.unknown}`], + ["Redundant", "Every member has 2 or more PSUs reporting OK"], + ["Not redundant", "A member has exactly 1 PSU reporting OK (includes single-PSU models)"], + ["PSU failed", "A member has a PSU reporting failed, check, offline or similar"], + ["Generated", ctx.epochToUtc(Date.now())], + ]; + const sheets = [ + ctx.xlsx.sheet("Switches", switchCols, switchRows, { tabColor: "1F4E78" }), + ctx.xlsx.sheet("PSUs", psuCols, psuRows, { tabColor: "C55A11" }), + ctx.xlsx.sheet("By Site", siteCols, siteRows.map((r) => ({ + ...r, __style: r.failed ? "red" : r.single ? "yellow" : undefined })), { tabColor: "548235" }), + ]; + if (failedSites.length) { + sheets.push(ctx.xlsx.sheet("Failed Sites", + [{ header: "Site", key: "site" }, { header: "Error", key: "error", wrap: true }], + failedSites, { tabColor: "7F7F7F" })); + } + sheets.push(ctx.xlsx.sheet("Info", [{ header: "Field" }, { header: "Value", wrap: true }], info, + { autofilter: false, freeze: null })); + + const scopeName = allSites ? "all_sites" : ctx.safeName(siteName(targets[0])); + const failNote = failedSites.length ? `, ${failedSites.length} site(s) failed` : ""; + return { + summary: `${switches.length} switch(es): ${totals.failed} with a failed PSU, ` + + `${totals.single} not redundant, ${totals.redundant} redundant${failNote}`, + filename: ctx.stampedName(`mist_switch_psu_${scopeName}`, ctx.orgName, "xlsx"), + sheets, + }; + }, +}; + +// --------------------------------------------------------------------------- +// On-screen dashboard: summary tiles, a stacked bar per site, and a card per +// switch showing each PSU. Filters: needs-attention toggle, text search, and +// clicking a site bar to focus that site. +function drawDashboard(ctx, { switches, siteRows, totals, psuTotals, offline, scopeLabel }) { + const E = ctx.esc; + const state = { issuesOnly: totals.failed + totals.single > 0, q: "", site: "" }; + + const tile = (n, label, color) => + `
${n}${E(label)}
`; + + const bar = (r) => HEALTH_KEYS.filter((k) => r[k]).map((k) => + ``).join(""); + + const pill = (p) => { + const num = (String(p.name).match(/(\d+)\s*$/) || [])[1]; + return `${E(num != null ? `PSU ${num}` : p.name)}`; + }; + + const card = (s) => { + const members = s.members.length + ? s.members.map((m) => + `
${s.members.length > 1 + ? `FPC ${E(m.idx)}${m.role ? ` \u00b7 ${E(m.role)}` : ""}` : ""}` + + `${m.psus.length ? m.psus.map(pill).join("") : `no PSU data`}
`).join("") + : `
no PSU data
`; + return `
` + + `
${E(s.name)}` + + `${E(HEALTH[s.health].label)}
` + + `
${E(s.model)}${s.online ? "" : " \u00b7 disconnected"}
${members}
`; + }; + + const legend = + Object.values(PSU).map((p) => `${E(p.label)}`).join("") + + `` + + HEALTH_KEYS.map((k) => `${E(HEALTH[k].label)}`).join(""); + + ctx.mount.innerHTML = ` + +
+
+ ${tile(switches.length, `Switches (${scopeLabel})`, "#1f4e78")} + ${tile(totals.failed, HEALTH.failed.label, HEALTH.failed.color)} + ${tile(totals.single, HEALTH.single.label, HEALTH.single.color)} + ${tile(totals.redundant, HEALTH.redundant.label, HEALTH.redundant.color)} + ${tile(totals.nodata, HEALTH.nodata.label, HEALTH.nodata.color)} + ${tile(`${psuTotals.failed}/${psuTotals.ok + psuTotals.failed + psuTotals.unknown}`, "PSUs failed / installed", PSU.failed.color)} + ${tile(offline, "Disconnected", "#9e9e9e")} +
+
${legend}
+

By site (click a site to focus it)

+
${siteRows.map((r) => + `
${E(r.site)}` + + `
${bar(r)}
` + + `${r.failed ? `${r.failed} failed \u00b7 ` : ""}` + + `${r.single ? `${r.single} not redundant \u00b7 ` : ""}${r.total} sw
`).join("")}
+
+ + + +
+
+
`; + + const root = ctx.mount; + const out = root.querySelector(".psu-out"); + + const render = () => { + const q = state.q.toLowerCase(); + const shown = switches.filter((s) => + (!state.issuesOnly || s.health === "failed" || s.health === "single") && + (!state.site || s.site === state.site) && + (!q || `${s.name} ${s.model} ${s.site} ${s.mac}`.toLowerCase().includes(q))); + shown.sort((a, b) => a.site.localeCompare(b.site) || + HEALTH[a.health].rank - HEALTH[b.health].rank || a.name.localeCompare(b.name)); + + const groups = new Map(); + for (const s of shown) groups.set(s.site, [...(groups.get(s.site) || []), s]); + out.innerHTML = shown.length + ? [...groups].map(([site, list]) => + `
${E(site)} (${list.length})
` + + `
${list.map(card).join("")}
`).join("") + : `
No switches match.${state.issuesOnly ? " No switch has a failed PSU or lost redundancy." : ""}
`; + + root.querySelectorAll("[data-mode]").forEach((b) => + b.classList.toggle("on", (b.dataset.mode === "issues") === state.issuesOnly)); + root.querySelectorAll(".psu-srow").forEach((r) => + r.classList.toggle("on", r.dataset.site === state.site)); + }; + + root.querySelectorAll("[data-mode]").forEach((b) => + b.addEventListener("click", () => { state.issuesOnly = b.dataset.mode === "issues"; render(); })); + root.querySelector(".psu-ctl input").addEventListener("input", (e) => { state.q = e.target.value; render(); }); + root.querySelectorAll(".psu-srow").forEach((r) => + r.addEventListener("click", () => { + state.site = state.site === r.dataset.site ? "" : r.dataset.site; + render(); + out.scrollIntoView({ behavior: "smooth", block: "start" }); + })); + render(); +}