diff --git a/README.md b/README.md index 2bcb2aa..a973ccd 100644 --- a/README.md +++ b/README.md @@ -118,6 +118,8 @@ which drives the real extension in headless Chromium. | ![Switch Port Inventory](mist-toolbox/docs/screenshots/09-port-inventory.png)
**Switch Port Inventory** | ![Switch Config Export](mist-toolbox/docs/screenshots/05-switch-config-export.png)
**Switch Config Export** | | ![IP Blocks / IRB Report](mist-toolbox/docs/screenshots/08-ip-blocks.png)
**IP Blocks / IRB Report** | ![Site Alarms](mist-toolbox/docs/screenshots/06-site-alarms.png)
**Site Alarms** | | ![Wi-Fi Clients Export](mist-toolbox/docs/screenshots/07-wifi-clients.png)
**Wi-Fi Clients Export** | ![Disconnect Console](mist-toolbox/docs/screenshots/13-disconnect-console.png)
**Disconnect Console** | +| ![Switch PSU Status](mist-toolbox/docs/screenshots/09b-switch-psu-status.png)
**Switch PSU Status** | ![Switch Additional CLI](mist-toolbox/docs/screenshots/09c-switch-additional-cli.png)
**Switch Additional CLI** | +| ![Client Wi-Fi PHY Inspector](mist-toolbox/docs/screenshots/07b-client-wifi-phy.png)
**Client Wi-Fi PHY Inspector** | | | ![SSR post-check, changes highlighted](mist-toolbox/docs/screenshots/11-ssr-post-highlighted.png)
**SSR Pre/Post**: changed fields highlighted | ![SSR pre and post side by side](mist-toolbox/docs/screenshots/12-ssr-side-by-side.png)
**SSR Pre/Post**: side by side | Every screenshot with a caption: [`mist-toolbox/docs/screenshots/`](mist-toolbox/docs/screenshots/README.md). diff --git a/mist-toolbox/docs/screenshots/00-chrome-extensions.png b/mist-toolbox/docs/screenshots/00-chrome-extensions.png index 2d055ca..98ae23c 100644 Binary files a/mist-toolbox/docs/screenshots/00-chrome-extensions.png and b/mist-toolbox/docs/screenshots/00-chrome-extensions.png differ diff --git a/mist-toolbox/docs/screenshots/01-home-signed-out.png b/mist-toolbox/docs/screenshots/01-home-signed-out.png index 8991459..dae01dc 100644 Binary files a/mist-toolbox/docs/screenshots/01-home-signed-out.png and b/mist-toolbox/docs/screenshots/01-home-signed-out.png differ diff --git a/mist-toolbox/docs/screenshots/02-home.png b/mist-toolbox/docs/screenshots/02-home.png index 0818a0b..45de11d 100644 Binary files a/mist-toolbox/docs/screenshots/02-home.png and b/mist-toolbox/docs/screenshots/02-home.png differ diff --git a/mist-toolbox/docs/screenshots/07b-client-wifi-phy.png b/mist-toolbox/docs/screenshots/07b-client-wifi-phy.png new file mode 100644 index 0000000..bba059e Binary files /dev/null and b/mist-toolbox/docs/screenshots/07b-client-wifi-phy.png differ diff --git a/mist-toolbox/docs/screenshots/09b-switch-psu-status.png b/mist-toolbox/docs/screenshots/09b-switch-psu-status.png new file mode 100644 index 0000000..f7f6433 Binary files /dev/null and b/mist-toolbox/docs/screenshots/09b-switch-psu-status.png differ diff --git a/mist-toolbox/docs/screenshots/09c-switch-additional-cli.png b/mist-toolbox/docs/screenshots/09c-switch-additional-cli.png new file mode 100644 index 0000000..0165f97 Binary files /dev/null and b/mist-toolbox/docs/screenshots/09c-switch-additional-cli.png differ diff --git a/mist-toolbox/docs/screenshots/20-home-added-tool.png b/mist-toolbox/docs/screenshots/20-home-added-tool.png index 67a1e03..25f8e08 100644 Binary files a/mist-toolbox/docs/screenshots/20-home-added-tool.png and b/mist-toolbox/docs/screenshots/20-home-added-tool.png differ diff --git a/mist-toolbox/docs/screenshots/README.md b/mist-toolbox/docs/screenshots/README.md index c58d010..1f23393 100644 --- a/mist-toolbox/docs/screenshots/README.md +++ b/mist-toolbox/docs/screenshots/README.md @@ -34,6 +34,10 @@ Generated by `scripts/screenshots/capture.mjs` against synthetic data (`fixtures ![Wi-Fi Clients Export](07-wifi-clients.png) +### Client Wi-Fi PHY Inspector — one client's radio link graded, site found automatically + +![Client Wi-Fi PHY Inspector — one client's radio link graded, site found automatically](07b-client-wifi-phy.png) + ### IP Blocks / IRB Report — duplicate subnet flagged across sites ![IP Blocks / IRB Report — duplicate subnet flagged across sites](08-ip-blocks.png) @@ -42,6 +46,14 @@ Generated by `scripts/screenshots/capture.mjs` against synthetic data (`fixtures ![Switch Port Inventory](09-port-inventory.png) +### Switch PSU Status — a failed supply and a switch without redundancy flagged + +![Switch PSU Status — a failed supply and a switch without redundancy flagged](09b-switch-psu-status.png) + +### Switch Additional CLI — template, rule, site and device commands side by side + +![Switch Additional CLI — template, rule, site and device commands side by side](09c-switch-additional-cli.png) + ### SSR Pre/Post Check — pre-check output, laid out like the CLI ![SSR Pre/Post Check — pre-check output, laid out like the CLI](10-ssr-pre-check.png) diff --git a/mist-toolbox/scripts/screenshots/capture.mjs b/mist-toolbox/scripts/screenshots/capture.mjs index 50085f9..3f030fe 100644 --- a/mist-toolbox/scripts/screenshots/capture.mjs +++ b/mist-toolbox/scripts/screenshots/capture.mjs @@ -17,7 +17,7 @@ import { tmpdir } from "node:os"; import { dirname, join, relative, resolve, sep } from "node:path"; import { fileURLToPath } from "node:url"; -import { CONDUCTOR, ORG_ID, conductor, mist } from "./fixtures.mjs"; +import { CONDUCTOR, ORG_ID, PHY_MAC, conductor, mist } from "./fixtures.mjs"; const root = resolve(dirname(fileURLToPath(import.meta.url)), "../.."); const outDir = join(root, "docs", "screenshots"); @@ -293,12 +293,21 @@ try { await runTool("wifi-clients"); await shot("07-wifi-clients", "Wi-Fi Clients Export"); + await runTool("client-wifi-phy", { params: { mac: PHY_MAC } }); + await shot("07b-client-wifi-phy", "Client Wi-Fi PHY Inspector — one client's radio link graded, site found automatically"); + await runTool("ip-blocks"); await shot("08-ip-blocks", "IP Blocks / IRB Report — duplicate subnet flagged across sites"); await runTool("port-inventory"); await shot("09-port-inventory", "Switch Port Inventory"); + await runTool("switch-psu-status"); + await shot("09b-switch-psu-status", "Switch PSU Status — a failed supply and a switch without redundancy flagged"); + + await runTool("switch-additional-cli"); + await shot("09c-switch-additional-cli", "Switch Additional CLI — template, rule, site and device commands side by side"); + // SSR Pre/Post: connect, pre-check, change, post-check. await click("#btnHome"); await click('[data-tool="ssr-pre-post"]'); diff --git a/mist-toolbox/scripts/screenshots/fixtures.mjs b/mist-toolbox/scripts/screenshots/fixtures.mjs index 2602cb3..500fee5 100644 --- a/mist-toolbox/scripts/screenshots/fixtures.mjs +++ b/mist-toolbox/scripts/screenshots/fixtures.mjs @@ -8,9 +8,9 @@ export const ORG_ID = "demo-org"; export const CONDUCTOR = "https://conductor.example.net"; const SITES = [ - { id: "site-hq", name: "HQ - Toronto", address: "100 Example Ave, Toronto ON", country_code: "CA", timezone: "America/Toronto" }, - { id: "site-ott", name: "Branch - Ottawa", address: "20 Sample St, Ottawa ON", country_code: "CA", timezone: "America/Toronto" }, - { id: "site-mtl", name: "Warehouse - Montreal", address: "5 Demo Rd, Montreal QC", country_code: "CA", timezone: "America/Toronto" }, + { id: "site-hq", name: "HQ - Toronto", networktemplate_id: "swtpl-campus", address: "100 Example Ave, Toronto ON", country_code: "CA", timezone: "America/Toronto" }, + { id: "site-ott", name: "Branch - Ottawa", networktemplate_id: "swtpl-campus", address: "20 Sample St, Ottawa ON", country_code: "CA", timezone: "America/Toronto" }, + { id: "site-mtl", name: "Warehouse - Montreal", networktemplate_id: "swtpl-branch", address: "5 Demo Rd, Montreal QC", country_code: "CA", timezone: "America/Toronto" }, ]; const NOW = 1760000000; // fixed, so every regeneration matches @@ -57,6 +57,17 @@ function configCmd(sw) { return lines; } +// Power supplies per VC member (Switch PSU Status): hq-core's backup member has +// lost a supply, hq-idf-1 has an empty slot, mtl-sw1 is a single-PSU model. +const PSUS = { + "dev-core": [["ok", "ok"], ["ok", "Failed"]], + "dev-idf1": [["ok", "absent"]], + "dev-idf2": [["ok", "ok"]], + "dev-ott": [["ok", "ok"]], + "dev-mtl": [["ok"]], +}; +const psus = (id, fpc) => (PSUS[id]?.[fpc] || []).map((status, i) => ({ name: `Power Supply ${i}`, status })); + function switchStats(sw) { const ports = []; for (let p = 0; p < 8; p += 1) { @@ -75,12 +86,12 @@ function switchStats(sw) { last_seen: NOW - (sw.status === "connected" ? 30 : 86400), ports, }; - if (sw.vc) { - base.module_stat = [ - { fpc_idx: 0, vc_role: "master", serial: sw.serial, version: sw.version, mac: sw.mac }, - ...sw.vc.map((m, i) => ({ fpc_idx: i + 1, vc_role: "backup", serial: m.serial, version: sw.version, mac: m.mac })), - ]; - } + base.module_stat = sw.vc + ? [ + { fpc_idx: 0, vc_role: "master", serial: sw.serial, version: sw.version, mac: sw.mac, psus: psus(sw.id, 0) }, + ...sw.vc.map((m, i) => ({ fpc_idx: i + 1, vc_role: "backup", serial: m.serial, version: sw.version, mac: m.mac, psus: psus(sw.id, i + 1) })), + ] + : [{ fpc_idx: 0, serial: sw.serial, version: sw.version, mac: sw.mac, psus: psus(sw.id, 0) }]; return base; } @@ -113,9 +124,15 @@ const PORT_USAGES = { uplink: { mode: "trunk", all_networks: true }, }; +const DEVICE_CLI = { + "dev-core": ["set protocols mstp bridge-priority 4k", "set chassis aggregated-devices ethernet device-count 8"], + "dev-mtl": ["set poe interface ge-0/0/3 disable"], +}; + function siteDevice(sw) { return { id: sw.id, mac: sw.mac, name: sw.name, model: sw.model, serial: sw.serial, site_id: sw.site, type: "switch", + ...(DEVICE_CLI[sw.id] ? { additional_config_cmds: DEVICE_CLI[sw.id] } : {}), port_config: { "ge-0/0/0": { usage: "uplink" }, "ge-0/0/1-3": { usage: "ap" }, "ge-0/0/4-7": { usage: "access" } }, port_usages: PORT_USAGES, }; @@ -125,6 +142,22 @@ function siteDevice(sw) { const TEMPLATES = [{ id: "tpl-corp", name: "Corporate WLANs" }]; +const SWITCH_TEMPLATES = [ + { id: "swtpl-campus", name: "Campus Switching", + additional_config_cmds: ["set system syslog host 192.0.2.10 any notice", "set system ntp server 192.0.2.123"], + switch_matching: { enable: true, rules: [ + { name: "access-48p", match_model: "EX2300", additional_config_cmds: ["set poe management class", "set protocols lldp-med interface all"] }, + ] } }, + { id: "swtpl-branch", name: "Small Branch", + additional_config_cmds: ["set system ntp server 192.0.2.123"] }, +]; +const SITE_SETTING = { + "site-hq": { additional_config_cmds: ["set snmp location \"HQ - Toronto, 3rd floor MDF\""], + switch_matching: { enable: true, rules: [{ name: "core", match_role: "core", additional_config_cmds: ["set protocols ospf area 0.0.0.0 interface irb.10 passive"] }] } }, + "site-ott": { additional_config_cmds: ["set snmp location \"Branch - Ottawa\"", "set system ntp server 192.0.2.123"] }, + "site-mtl": {}, +}; + const WLANS = { "site-hq": [ { id: "w-corp", ssid: "DemoCorp", enabled: true, auth: { type: "eap", pairwise: ["wpa2-ccmp", "wpa3"] }, vlan_enabled: true, vlan_id: 10, bands: ["5", "6"], interface: "all", template_id: "tpl-corp" }, @@ -159,6 +192,81 @@ function clients(siteId, count, subnet) { } const CLIENTS = { "site-hq": clients("site-hq", 9, 10), "site-ott": clients("site-ott", 5, 20), "site-mtl": clients("site-mtl", 3, 30) }; +// One HQ client in detail, for the Client Wi-Fi PHY Inspector. Its readings are +// chosen to show the dashboard's grades: usable signal but poor SNR on a busy, +// noisy, overlapped 80 MHz channel, with some ping-pong roaming. Times are +// relative to the run, not NOW, because the tool charts the past 24 hours. +export const PHY_MAC = mac(0x900a07); +const PHY_SITE = "site-hq"; +const liveNow = () => Math.floor(Date.now() / 600000) * 600; + +const AP_NAMES = ["hq-ap-01", "hq-ap-02", "hq-ap-03", "hq-ap-04", "hq-ap-05", "hq-ap-06"]; +// [5 GHz primary, width, clients, util] per AP; hq-ap-03 serves the client. +const AP_5G = [[36, 80, 9, 31], [149, 80, 12, 28], [52, 80, 22, 62], [56, 20, 7, 40], [52, 40, 11, 47], [100, 80, 6, 18]]; +function apStats() { + return AP_NAMES.map((name, i) => { + const [ch, bw, clients, util] = AP_5G[i]; + const serving = i === 2; + return { + id: `ap-${i}`, name, mac: mac(0x600 + i), type: "ap", model: "AP45", status: "connected", site_id: PHY_SITE, + radio_stat: { + band_24: { channel: [1, 6, 11][i % 3], bandwidth: 20, num_clients: 3, noise_floor: -91, util_all: 35, power: 8 }, + band_5: { mac: mac(0x6a0 + i), channel: ch, bandwidth: bw, num_clients: clients, power: serving ? 14 : 17, + noise_floor: serving ? -88 : -94, util_all: util, + ...(serving ? { util_tx: 12, util_rx_in_bss: 30, util_rx_other_bss: 14, util_unknown_wifi: 2, util_non_wifi: 14 } : {}) }, + }, + }; + }); +} + +function phyClient() { + const base = CLIENTS[PHY_SITE].find((c) => c.mac === PHY_MAC); + return { ...base, ap_mac: mac(0x602), band: "5", channel: 52, channel_width: 80, proto: "ax", num_streams: 2, + rssi: -68, snr: 18, tx_rate: 480, rx_rate: 360, tx_retries: 1400, tx_pkts: 7800, rx_retries: 300, rx_pkts: 9100, + dual_band: true, last_seen: liveNow() - 20 }; +} + +// A smooth, repeatable wobble for the time series. +const wave = (i, a, b) => Math.sin(i / 9) * a + Math.sin(i / 2.7) * b; +function phySeries(metric) { + const t1 = liveNow(); + const rt = Array.from({ length: 144 }, (_, i) => t1 - (143 - i) * 600); + const at = (f) => rt.map((_, i) => Math.round(f(i) * 10) / 10); + const series = { + rssi: at((i) => -63 + wave(i, 4, 2) - (i > 95 && i < 110 ? 9 : 0)), + snr: at((i) => 24 + wave(i, 4, 2) - (i > 95 && i < 110 ? 8 : 0)), + tx_rate: at((i) => Math.max(86, 620 + wave(i, 160, 70))), + rx_rate: at((i) => Math.max(65, 470 + wave(i, 120, 50))), + tx_retries: at((i) => Math.max(0, 140 + wave(i, 80, 40))), + rx_retries: at((i) => Math.max(0, 30 + wave(i, 15, 8))), + }[metric]; + return series ? { rt, results: series } : null; +} + +function phySessions() { + const t1 = liveNow(); + const ap = (i) => mac(0x600 + i); + // [AP index, start hours ago, end hours ago or null] + return [[2, 23, 17.5], [4, 17.5, 17.45], [2, 17.45, 9.2], [4, 9.2, 9.17], [2, 9.17, 9.1], [3, 9.1, 6.0], [2, 6.0, null]] + .map(([i, a, b]) => ({ ap: ap(i), band: "5", ssid: "DemoCorp", connect: t1 - a * 3600, disconnect: b == null ? null : t1 - b * 3600, + duration: Math.round(((b == null ? 0 : -b) + a) * 3600) })); +} + +function phyEvents() { + const t1 = liveNow(); + const ev = (h, type, text, o = {}) => ({ timestamp: t1 - h * 3600, type, text, ap: mac(0x602), band: "5", channel: 52, ...o }); + return [ + ev(22.9, "CLIENT_ASSOCIATION", "Associated"), + ev(17.5, "CLIENT_DEAUTHENTICATION", "Deauthenticated", { reason: 4, ap: mac(0x602) }), + ev(17.45, "CLIENT_REASSOCIATION", "Reassociated", { ap: mac(0x604) }), + ev(9.2, "CLIENT_DISASSOCIATION", "Disassociated", { reason: 8, ap: mac(0x602) }), + ev(9.17, "CLIENT_REASSOCIATION", "Reassociated", { ap: mac(0x604) }), + ev(8.3, "CLIENT_DEAUTHENTICATION", "Deauthenticated", { reason: 4, ap: mac(0x602) }), + ev(6.0, "CLIENT_REASSOCIATION", "Reassociated", { ap: mac(0x602) }), + ev(2.1, "CLIENT_DHCP_SUCCESS", "DHCP Success"), + ]; +} + // ---- Alarms ----------------------------------------------------------------- const ALARM_DEFS = [ @@ -208,7 +316,27 @@ export function mist(path, query) { } if ((r = m(/^\/sites\/([^/]+)\/wlans\/derived$/))) return { body: WLANS[r[1]] || [] }; if ((r = m(/^\/sites\/([^/]+)\/stats\/clients$/))) return { body: CLIENTS[r[1]] || [] }; - if ((r = m(/^\/sites\/([^/]+)\/stats\/devices$/))) return { body: SWITCHES.filter((s) => s.site === r[1]).map(switchStats) }; + if ((r = m(/^\/sites\/([^/]+)\/stats\/devices$/))) { + if (query.get("type") === "ap") return { body: r[1] === PHY_SITE ? apStats() : [] }; + return { body: SWITCHES.filter((s) => s.site === r[1]).map(switchStats) }; + } + if (path === `/orgs/${ORG_ID}/networktemplates`) return { body: SWITCH_TEMPLATES }; + if ((r = m(/^\/sites\/([^/]+)\/setting$/))) return { body: SITE_SETTING[r[1]] || {} }; + if (path === `/orgs/${ORG_ID}/clients/search`) { + const hit = query.get("mac") === PHY_MAC; + return { body: { results: hit ? [{ mac: PHY_MAC, site_id: PHY_SITE, last_seen: liveNow() - 20 }] : [], total: hit ? 1 : 0 } }; + } + if (path === `/sites/${PHY_SITE}/stats/clients/${PHY_MAC}`) return { body: phyClient() }; + if (path === `/sites/${PHY_SITE}/clients/search`) return { body: { results: [phyClient()], total: 1 } }; + if (path === `/sites/${PHY_SITE}/clients/${PHY_MAC}/events`) return { body: { results: phyEvents() } }; + if (path === `/sites/${PHY_SITE}/clients/sessions/search`) { + const rows = phySessions(); + return { body: { results: rows, total: rows.length } }; + } + if ((r = m(new RegExp(`^/sites/${PHY_SITE}/insights/client/${PHY_MAC}/([^/]+)$`)))) { + const body = phySeries(decodeURIComponent(r[1])); + return body ? { body } : null; + } if ((r = m(/^\/sites\/([^/]+)\/stats\/ports\/search$/))) { const rows = SWITCHES.filter((s) => s.site === r[1]).flatMap((sw) => switchStats(sw).ports.map((p) => ({ ...p, mac: sw.mac }))); return { body: { results: rows, total: rows.length } };