diff --git a/README.md b/README.md index fb7a305..97f7519 100644 --- a/README.md +++ b/README.md @@ -129,6 +129,24 @@ which drives the real extension in headless Chromium. | ![Client Wi-Fi PHY Inspector](mist-toolbox/docs/screenshots/07b-client-wifi-phy.png)
**Client Wi-Fi PHY Inspector** | ![BGP Sessions](mist-toolbox/docs/screenshots/09d-bgp-sessions.png)
**BGP Sessions** | | ![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 | +**Switch PSU Status** dashboard filters: every switch including disconnected ones, a typed site +name, and a clicked site bar. + +| | | +|---|---| +| ![PSU Status, all switches](mist-toolbox/docs/screenshots/09b2-psu-all-switches.png)
**All switches**, disconnected ones dashed | ![PSU Status, site search](mist-toolbox/docs/screenshots/09b3-psu-search-site.png)
**Search** for a site | + +### Workbooks + +Every report tool also saves a styled `.xlsx`: a filled header, frozen top row, filters, coloured +tabs and problem rows highlighted. These are rendered from the actual downloaded files. + +| | | +|---|---| +| ![PSU workbook, Switches](mist-toolbox/docs/screenshots/30-xlsx-psu-switches.png)
**Switch PSU Status**: Switches | ![PSU workbook, PSUs](mist-toolbox/docs/screenshots/31-xlsx-psu-psus.png)
**Switch PSU Status**: PSUs | +| ![BGP workbook, Problems](mist-toolbox/docs/screenshots/32-xlsx-bgp-problems.png)
**BGP Sessions**: Problems | ![Alarms workbook](mist-toolbox/docs/screenshots/34-xlsx-alarms.png)
**Site Alarms**: Alarms | +| ![Port Inventory workbook](mist-toolbox/docs/screenshots/36-xlsx-port-inventory.png)
**Switch Port Inventory**: Switch Ports | ![Additional CLI workbook](mist-toolbox/docs/screenshots/38-xlsx-additional-cli.png)
**Switch Additional CLI**: CLI Lines | + Every screenshot with a caption: [`mist-toolbox/docs/screenshots/`](mist-toolbox/docs/screenshots/README.md). ## What's in this repository diff --git a/mist-toolbox/docs/screenshots/00-chrome-extensions.png b/mist-toolbox/docs/screenshots/00-chrome-extensions.png index 98ae23c..c90b63b 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/03-ssid-report.png b/mist-toolbox/docs/screenshots/03-ssid-report.png index 8dfe3f6..d6f5373 100644 Binary files a/mist-toolbox/docs/screenshots/03-ssid-report.png and b/mist-toolbox/docs/screenshots/03-ssid-report.png differ diff --git a/mist-toolbox/docs/screenshots/04-switch-report-single-site.png b/mist-toolbox/docs/screenshots/04-switch-report-single-site.png index a772945..54b0a95 100644 Binary files a/mist-toolbox/docs/screenshots/04-switch-report-single-site.png and b/mist-toolbox/docs/screenshots/04-switch-report-single-site.png differ diff --git a/mist-toolbox/docs/screenshots/05-switch-config-export.png b/mist-toolbox/docs/screenshots/05-switch-config-export.png index c63ed13..430f343 100644 Binary files a/mist-toolbox/docs/screenshots/05-switch-config-export.png and b/mist-toolbox/docs/screenshots/05-switch-config-export.png differ diff --git a/mist-toolbox/docs/screenshots/06-site-alarms.png b/mist-toolbox/docs/screenshots/06-site-alarms.png index f2ff343..936530f 100644 Binary files a/mist-toolbox/docs/screenshots/06-site-alarms.png and b/mist-toolbox/docs/screenshots/06-site-alarms.png differ diff --git a/mist-toolbox/docs/screenshots/07-wifi-clients.png b/mist-toolbox/docs/screenshots/07-wifi-clients.png index 4fde070..49c5bb7 100644 Binary files a/mist-toolbox/docs/screenshots/07-wifi-clients.png and b/mist-toolbox/docs/screenshots/07-wifi-clients.png differ diff --git a/mist-toolbox/docs/screenshots/07b-client-wifi-phy.png b/mist-toolbox/docs/screenshots/07b-client-wifi-phy.png index bba059e..12d8807 100644 Binary files a/mist-toolbox/docs/screenshots/07b-client-wifi-phy.png and b/mist-toolbox/docs/screenshots/07b-client-wifi-phy.png differ diff --git a/mist-toolbox/docs/screenshots/08-ip-blocks.png b/mist-toolbox/docs/screenshots/08-ip-blocks.png index ae6cfb9..d0c844b 100644 Binary files a/mist-toolbox/docs/screenshots/08-ip-blocks.png and b/mist-toolbox/docs/screenshots/08-ip-blocks.png differ diff --git a/mist-toolbox/docs/screenshots/09-port-inventory.png b/mist-toolbox/docs/screenshots/09-port-inventory.png index c70f66e..82966e5 100644 Binary files a/mist-toolbox/docs/screenshots/09-port-inventory.png and b/mist-toolbox/docs/screenshots/09-port-inventory.png differ diff --git a/mist-toolbox/docs/screenshots/09b-switch-psu-status.png b/mist-toolbox/docs/screenshots/09b-switch-psu-status.png index f7f6433..d002dcd 100644 Binary files a/mist-toolbox/docs/screenshots/09b-switch-psu-status.png and b/mist-toolbox/docs/screenshots/09b-switch-psu-status.png differ diff --git a/mist-toolbox/docs/screenshots/09b2-psu-all-switches.png b/mist-toolbox/docs/screenshots/09b2-psu-all-switches.png new file mode 100644 index 0000000..aef31ff Binary files /dev/null and b/mist-toolbox/docs/screenshots/09b2-psu-all-switches.png differ diff --git a/mist-toolbox/docs/screenshots/09b3-psu-search-site.png b/mist-toolbox/docs/screenshots/09b3-psu-search-site.png new file mode 100644 index 0000000..d5f954b Binary files /dev/null and b/mist-toolbox/docs/screenshots/09b3-psu-search-site.png differ diff --git a/mist-toolbox/docs/screenshots/09b4-psu-focus-site.png b/mist-toolbox/docs/screenshots/09b4-psu-focus-site.png new file mode 100644 index 0000000..ff7ca01 Binary files /dev/null and b/mist-toolbox/docs/screenshots/09b4-psu-focus-site.png differ diff --git a/mist-toolbox/docs/screenshots/09c-switch-additional-cli.png b/mist-toolbox/docs/screenshots/09c-switch-additional-cli.png index 0165f97..a3f8d8e 100644 Binary files a/mist-toolbox/docs/screenshots/09c-switch-additional-cli.png and b/mist-toolbox/docs/screenshots/09c-switch-additional-cli.png differ diff --git a/mist-toolbox/docs/screenshots/09d-bgp-sessions.png b/mist-toolbox/docs/screenshots/09d-bgp-sessions.png index 0e11390..07d6687 100644 Binary files a/mist-toolbox/docs/screenshots/09d-bgp-sessions.png and b/mist-toolbox/docs/screenshots/09d-bgp-sessions.png differ diff --git a/mist-toolbox/docs/screenshots/10-ssr-pre-check.png b/mist-toolbox/docs/screenshots/10-ssr-pre-check.png index de292c8..bc79c23 100644 Binary files a/mist-toolbox/docs/screenshots/10-ssr-pre-check.png and b/mist-toolbox/docs/screenshots/10-ssr-pre-check.png differ diff --git a/mist-toolbox/docs/screenshots/13-disconnect-console.png b/mist-toolbox/docs/screenshots/13-disconnect-console.png index 482dcd3..dec658b 100644 Binary files a/mist-toolbox/docs/screenshots/13-disconnect-console.png and b/mist-toolbox/docs/screenshots/13-disconnect-console.png differ diff --git a/mist-toolbox/docs/screenshots/14-manage-tools.png b/mist-toolbox/docs/screenshots/14-manage-tools.png index 51705f4..3db4ac8 100644 Binary files a/mist-toolbox/docs/screenshots/14-manage-tools.png and b/mist-toolbox/docs/screenshots/14-manage-tools.png differ diff --git a/mist-toolbox/docs/screenshots/17-manage-copy-prompt.png b/mist-toolbox/docs/screenshots/17-manage-copy-prompt.png index 6fa4600..242218f 100644 Binary files a/mist-toolbox/docs/screenshots/17-manage-copy-prompt.png and b/mist-toolbox/docs/screenshots/17-manage-copy-prompt.png differ diff --git a/mist-toolbox/docs/screenshots/18-manage-check-passed.png b/mist-toolbox/docs/screenshots/18-manage-check-passed.png index 32d177c..cf52451 100644 Binary files a/mist-toolbox/docs/screenshots/18-manage-check-passed.png and b/mist-toolbox/docs/screenshots/18-manage-check-passed.png differ diff --git a/mist-toolbox/docs/screenshots/19-manage-installed.png b/mist-toolbox/docs/screenshots/19-manage-installed.png index 3cb899a..abd4448 100644 Binary files a/mist-toolbox/docs/screenshots/19-manage-installed.png and b/mist-toolbox/docs/screenshots/19-manage-installed.png differ diff --git a/mist-toolbox/docs/screenshots/21-added-tool-run.png b/mist-toolbox/docs/screenshots/21-added-tool-run.png index 260fd34..0800df5 100644 Binary files a/mist-toolbox/docs/screenshots/21-added-tool-run.png and b/mist-toolbox/docs/screenshots/21-added-tool-run.png differ diff --git a/mist-toolbox/docs/screenshots/30-xlsx-psu-switches.png b/mist-toolbox/docs/screenshots/30-xlsx-psu-switches.png new file mode 100644 index 0000000..72df5ee Binary files /dev/null and b/mist-toolbox/docs/screenshots/30-xlsx-psu-switches.png differ diff --git a/mist-toolbox/docs/screenshots/31-xlsx-psu-psus.png b/mist-toolbox/docs/screenshots/31-xlsx-psu-psus.png new file mode 100644 index 0000000..d95aba3 Binary files /dev/null and b/mist-toolbox/docs/screenshots/31-xlsx-psu-psus.png differ diff --git a/mist-toolbox/docs/screenshots/32-xlsx-bgp-problems.png b/mist-toolbox/docs/screenshots/32-xlsx-bgp-problems.png new file mode 100644 index 0000000..a7fd14d Binary files /dev/null and b/mist-toolbox/docs/screenshots/32-xlsx-bgp-problems.png differ diff --git a/mist-toolbox/docs/screenshots/33-xlsx-bgp-paths.png b/mist-toolbox/docs/screenshots/33-xlsx-bgp-paths.png new file mode 100644 index 0000000..9fc3168 Binary files /dev/null and b/mist-toolbox/docs/screenshots/33-xlsx-bgp-paths.png differ diff --git a/mist-toolbox/docs/screenshots/34-xlsx-alarms.png b/mist-toolbox/docs/screenshots/34-xlsx-alarms.png new file mode 100644 index 0000000..6669388 Binary files /dev/null and b/mist-toolbox/docs/screenshots/34-xlsx-alarms.png differ diff --git a/mist-toolbox/docs/screenshots/35-xlsx-alarms-by-type.png b/mist-toolbox/docs/screenshots/35-xlsx-alarms-by-type.png new file mode 100644 index 0000000..8dd7717 Binary files /dev/null and b/mist-toolbox/docs/screenshots/35-xlsx-alarms-by-type.png differ diff --git a/mist-toolbox/docs/screenshots/36-xlsx-port-inventory.png b/mist-toolbox/docs/screenshots/36-xlsx-port-inventory.png new file mode 100644 index 0000000..e199a8b Binary files /dev/null and b/mist-toolbox/docs/screenshots/36-xlsx-port-inventory.png differ diff --git a/mist-toolbox/docs/screenshots/37-xlsx-ip-blocks.png b/mist-toolbox/docs/screenshots/37-xlsx-ip-blocks.png new file mode 100644 index 0000000..094be95 Binary files /dev/null and b/mist-toolbox/docs/screenshots/37-xlsx-ip-blocks.png differ diff --git a/mist-toolbox/docs/screenshots/38-xlsx-additional-cli.png b/mist-toolbox/docs/screenshots/38-xlsx-additional-cli.png new file mode 100644 index 0000000..508360f Binary files /dev/null and b/mist-toolbox/docs/screenshots/38-xlsx-additional-cli.png differ diff --git a/mist-toolbox/docs/screenshots/39-xlsx-wifi-phy.png b/mist-toolbox/docs/screenshots/39-xlsx-wifi-phy.png new file mode 100644 index 0000000..11ba0e1 Binary files /dev/null and b/mist-toolbox/docs/screenshots/39-xlsx-wifi-phy.png differ diff --git a/mist-toolbox/docs/screenshots/40-xlsx-wifi-clients.png b/mist-toolbox/docs/screenshots/40-xlsx-wifi-clients.png new file mode 100644 index 0000000..e51abb8 Binary files /dev/null and b/mist-toolbox/docs/screenshots/40-xlsx-wifi-clients.png differ diff --git a/mist-toolbox/docs/screenshots/41-xlsx-ssid.png b/mist-toolbox/docs/screenshots/41-xlsx-ssid.png new file mode 100644 index 0000000..5af7bf4 Binary files /dev/null and b/mist-toolbox/docs/screenshots/41-xlsx-ssid.png differ diff --git a/mist-toolbox/docs/screenshots/42-xlsx-switch-report.png b/mist-toolbox/docs/screenshots/42-xlsx-switch-report.png new file mode 100644 index 0000000..ed06630 Binary files /dev/null and b/mist-toolbox/docs/screenshots/42-xlsx-switch-report.png differ diff --git a/mist-toolbox/docs/screenshots/README.md b/mist-toolbox/docs/screenshots/README.md index f8bc180..7b2189b 100644 --- a/mist-toolbox/docs/screenshots/README.md +++ b/mist-toolbox/docs/screenshots/README.md @@ -46,9 +46,21 @@ 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 — failed supplies and switches without redundancy, by site -![Switch PSU Status — a failed supply and a switch without redundancy flagged](09b-switch-psu-status.png) +![Switch PSU Status — failed supplies and switches without redundancy, by site](09b-switch-psu-status.png) + +### Switch PSU Status — All switches, disconnected ones dashed with their last known PSU state + +![Switch PSU Status — All switches, disconnected ones dashed with their last known PSU state](09b2-psu-all-switches.png) + +### Switch PSU Status — type a site name: its failed, healthy and disconnected switches + +![Switch PSU Status — type a site name: its failed, healthy and disconnected switches](09b3-psu-search-site.png) + +### Switch PSU Status — click a site's bar to focus it + +![Switch PSU Status — click a site's bar to focus it](09b4-psu-focus-site.png) ### Switch Additional CLI — template, rule, site and device commands side by side @@ -78,6 +90,58 @@ Generated by `scripts/screenshots/capture.mjs` against synthetic data (`fixtures ![Disconnect Console — built-in sample investigation](13-disconnect-console.png) +### Switch PSU Status workbook — Switches sheet + +![Switch PSU Status workbook — Switches sheet](30-xlsx-psu-switches.png) + +### Switch PSU Status workbook — one row per power supply + +![Switch PSU Status workbook — one row per power supply](31-xlsx-psu-psus.png) + +### BGP Sessions workbook — Problems sheet + +![BGP Sessions workbook — Problems sheet](32-xlsx-bgp-problems.png) + +### BGP Sessions workbook — Peer paths sheet + +![BGP Sessions workbook — Peer paths sheet](33-xlsx-bgp-paths.png) + +### Site Alarms workbook — Alarms sheet + +![Site Alarms workbook — Alarms sheet](34-xlsx-alarms.png) + +### Site Alarms workbook — By Type sheet + +![Site Alarms workbook — By Type sheet](35-xlsx-alarms-by-type.png) + +### Switch Port Inventory workbook — Switch Ports table + +![Switch Port Inventory workbook — Switch Ports table](36-xlsx-port-inventory.png) + +### IP Blocks / IRB Report workbook — IRB Interfaces sheet + +![IP Blocks / IRB Report workbook — IRB Interfaces sheet](37-xlsx-ip-blocks.png) + +### Switch Additional CLI workbook — CLI Lines sheet + +![Switch Additional CLI workbook — CLI Lines sheet](38-xlsx-additional-cli.png) + +### Client Wi-Fi PHY Inspector workbook — Findings sheet + +![Client Wi-Fi PHY Inspector workbook — Findings sheet](39-xlsx-wifi-phy.png) + +### Wi-Fi Clients Export workbook — WiFi_Clients sheet + +![Wi-Fi Clients Export workbook — WiFi_Clients sheet](40-xlsx-wifi-clients.png) + +### SSID Report workbook — SSIDs by Site sheet + +![SSID Report workbook — SSIDs by Site sheet](41-xlsx-ssid.png) + +### Switch Software Report workbook — Switches sheet + +![Switch Software Report workbook — Switches sheet](42-xlsx-switch-report.png) + ### docs/TOOL_PROMPT.md in the docs viewer — Copy or Download it ![docs/TOOL_PROMPT.md in the docs viewer — Copy or Download it](15-ai-prompt-top.png) diff --git a/mist-toolbox/scripts/screenshots/capture.mjs b/mist-toolbox/scripts/screenshots/capture.mjs index 3a3be51..0dd79eb 100644 --- a/mist-toolbox/scripts/screenshots/capture.mjs +++ b/mist-toolbox/scripts/screenshots/capture.mjs @@ -12,12 +12,13 @@ import { spawn } from "node:child_process"; import { createHash } from "node:crypto"; -import { cp, mkdir, mkdtemp, readFile, rm, stat, unlink, writeFile } from "node:fs/promises"; +import { cp, mkdir, mkdtemp, readFile, readdir, rm, stat, unlink, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { dirname, join, relative, resolve, sep } from "node:path"; import { fileURLToPath } from "node:url"; import { CONDUCTOR, ORG_ID, PHY_MAC, conductor, mist } from "./fixtures.mjs"; +import { readXlsx, sheetPage } from "./sheetview.mjs"; const root = resolve(dirname(fileURLToPath(import.meta.url)), "../.."); const outDir = join(root, "docs", "screenshots"); @@ -91,7 +92,8 @@ await cmd("Page.enable"); await cmd("Runtime.enable"); await cmd("Emulation.setDeviceMetricsOverride", { width: WIDTH, height: 900, deviceScaleFactor: 1, mobile: false }); await cmd("Emulation.setEmulatedMedia", { features: [{ name: "prefers-color-scheme", value: "light" }] }); -await send("Browser.setDownloadBehavior", { behavior: "allow", downloadPath: join(work, "downloads") }); +const downloads = join(work, "downloads"); +await send("Browser.setDownloadBehavior", { behavior: "allow", downloadPath: downloads }); // ---- Fixture network ---------------------------------------------------------- @@ -204,6 +206,9 @@ async function go(url) { const click = (sel) => evaluate(`document.querySelector(${JSON.stringify(sel)}).click()`); const setValue = (sel, v) => evaluate(`(() => { const el = document.querySelector(${JSON.stringify(sel)}); el.value = ${JSON.stringify(v)}; el.dispatchEvent(new Event("input", {bubbles:true})); el.dispatchEvent(new Event("change", {bubbles:true})); })()`); +// For inputs without an id, such as a tool's own filter box. +const typeInto = (sel, v) => evaluate(`(() => { const el = document.querySelector(${JSON.stringify(sel)}); + el.value = ${JSON.stringify(v)}; el.dispatchEvent(new Event("input", {bubbles:true})); })()`); const setChecked = (sel, v) => evaluate(`(() => { const el = document.querySelector(${JSON.stringify(sel)}); el.checked = ${v}; el.dispatchEvent(new Event("change", {bubbles:true})); })()`); @@ -253,6 +258,42 @@ async function runTool(id, { params = {}, single } = {}) { await evaluate("window.scrollTo(0, 0)"); } +// ---- Workbooks -------------------------------------------------------------------- +// +// Every tool that builds a workbook saves it straight to the downloads folder. +// grabXlsx waits for the one a run just wrote; the workbook shots at the end +// render each through sheetview.mjs. + +const workbooks = {}; +const seenFiles = new Set(); +async function grabXlsx(id) { + const end = Date.now() + 15000; + while (Date.now() < end) { + const fresh = (await readdir(downloads).catch(() => [])) + .filter((f) => f.endsWith(".xlsx") && !seenFiles.has(f)); + if (fresh.length) { + await sleep(300); + fresh.forEach((f) => seenFiles.add(f)); + workbooks[id] = { name: fresh[0], buf: await readFile(join(downloads, fresh[0])) }; + return; + } + await sleep(200); + } + throw new Error(`No .xlsx downloaded for ${id}`); +} + +async function sheetShot(name, caption, id, sheetName, opts) { + const wb = workbooks[id]; + const book = readXlsx(wb.buf); + const idx = book.sheets.findIndex((sh) => sh.name === sheetName); + if (idx < 0) throw new Error(`${wb.name} has no sheet "${sheetName}" (${book.sheets.map((sh) => sh.name).join(", ")})`); + await go("about:blank"); + const { frameTree } = await cmd("Page.getFrameTree"); + await cmd("Page.setDocumentContent", { frameId: frameTree.frame.id, html: sheetPage(book, wb.name, idx, opts) }); + await sleep(300); + await shot(name, caption); +} + // ---- The tour ----------------------------------------------------------------- await rm(outDir, { recursive: true, force: true }); @@ -279,36 +320,54 @@ try { await shot("02-home", "Tool menu with each tool's level (Site · Org, Site · Client, SSR Conductor)"); await runTool("ssid-report"); + await grabXlsx("ssid-report"); await shot("03-ssid-report", "SSID Report — every site in the org"); await runTool("switch-report", { single: "site-hq" }); + await grabXlsx("switch-report"); await shot("04-switch-report-single-site", "Switch Software Report for one site (All sites unticked, Site picked)"); await runTool("switch-configs"); await shot("05-switch-config-export", "Switch Config Export — one .zip with a folder per site"); await runTool("site-alarms", { params: { duration: "7d" } }); + await grabXlsx("site-alarms"); await shot("06-site-alarms", "Site Alarms — past 7 days, all sites"); await runTool("wifi-clients"); + await grabXlsx("wifi-clients"); await shot("07-wifi-clients", "Wi-Fi Clients Export"); await runTool("client-wifi-phy", { params: { mac: PHY_MAC } }); + await grabXlsx("client-wifi-phy"); 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 grabXlsx("ip-blocks"); await shot("08-ip-blocks", "IP Blocks / IRB Report — duplicate subnet flagged across sites"); await runTool("port-inventory"); + await grabXlsx("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 grabXlsx("switch-psu-status"); + await shot("09b-switch-psu-status", "Switch PSU Status — failed supplies and switches without redundancy, by site"); + // The dashboard's own filters: every switch, a typed site name, a clicked site bar. + await click('.psu-ctl [data-mode="all"]'); + await shot("09b2-psu-all-switches", "Switch PSU Status — All switches, disconnected ones dashed with their last known PSU state", [".psu-v"]); + await typeInto(".psu-ctl input", "vancouver"); + await shot("09b3-psu-search-site", "Switch PSU Status — type a site name: its failed, healthy and disconnected switches", [".psu-v"]); + await typeInto(".psu-ctl input", ""); + await click('.psu-srow[data-site="Branch - Calgary"]'); + await shot("09b4-psu-focus-site", "Switch PSU Status — click a site's bar to focus it", [".psu-v"]); await runTool("switch-additional-cli"); + await grabXlsx("switch-additional-cli"); await shot("09c-switch-additional-cli", "Switch Additional CLI — template, rule, site and device commands side by side"); await runTool("bgp-sessions"); + await grabXlsx("bgp-sessions"); await shot("09d-bgp-sessions", "BGP Sessions — switch EVPN, WAN-edge BGP and SSR/SRX peer paths, problems first"); // SSR Pre/Post: connect, pre-check, change, post-check. @@ -338,6 +397,21 @@ try { await waitFor("document.body.innerText.includes('sample')", "console demo", 20000); await shot("13-disconnect-console", "Disconnect Console — built-in sample investigation"); + // The workbooks the runs above saved, one or two sheets each. + await sheetShot("30-xlsx-psu-switches", "Switch PSU Status workbook — Switches sheet", "switch-psu-status", "Switches"); + await sheetShot("31-xlsx-psu-psus", "Switch PSU Status workbook — one row per power supply", "switch-psu-status", "PSUs"); + await sheetShot("32-xlsx-bgp-problems", "BGP Sessions workbook — Problems sheet", "bgp-sessions", "Problems"); + await sheetShot("33-xlsx-bgp-paths", "BGP Sessions workbook — Peer paths sheet", "bgp-sessions", "Peer paths"); + await sheetShot("34-xlsx-alarms", "Site Alarms workbook — Alarms sheet", "site-alarms", "Alarms"); + await sheetShot("35-xlsx-alarms-by-type", "Site Alarms workbook — By Type sheet", "site-alarms", "By Type"); + await sheetShot("36-xlsx-port-inventory", "Switch Port Inventory workbook — Switch Ports table", "port-inventory", "Switch Ports"); + await sheetShot("37-xlsx-ip-blocks", "IP Blocks / IRB Report workbook — IRB Interfaces sheet", "ip-blocks", "IRB Interfaces"); + await sheetShot("38-xlsx-additional-cli", "Switch Additional CLI workbook — CLI Lines sheet", "switch-additional-cli", "CLI Lines"); + await sheetShot("39-xlsx-wifi-phy", "Client Wi-Fi PHY Inspector workbook — Findings sheet", "client-wifi-phy", "Findings"); + await sheetShot("40-xlsx-wifi-clients", "Wi-Fi Clients Export workbook — WiFi_Clients sheet", "wifi-clients", "WiFi_Clients"); + await sheetShot("41-xlsx-ssid", "SSID Report workbook — SSIDs by Site sheet", "ssid-report", "SSIDs by Site"); + await sheetShot("42-xlsx-switch-report", "Switch Software Report workbook — Switches sheet", "switch-report", "Switches"); + // ---- Adding a tool with an AI assistant ---------------------------------------- await go(extUrl("docs/view.html?doc=TOOL_PROMPT.md")); await waitFor("document.getElementById('doc').textContent.length > 1000", "prompt text"); diff --git a/mist-toolbox/scripts/screenshots/fixtures.mjs b/mist-toolbox/scripts/screenshots/fixtures.mjs index f880984..4278505 100644 --- a/mist-toolbox/scripts/screenshots/fixtures.mjs +++ b/mist-toolbox/scripts/screenshots/fixtures.mjs @@ -11,6 +11,11 @@ const SITES = [ { 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" }, + { id: "site-van", name: "DC - Vancouver", networktemplate_id: "swtpl-campus", address: "1 Placeholder Way, Vancouver BC", country_code: "CA", timezone: "America/Vancouver" }, + { id: "site-cgy", name: "Branch - Calgary", networktemplate_id: "swtpl-campus", address: "40 Example Blvd, Calgary AB", country_code: "CA", timezone: "America/Edmonton" }, + { id: "site-hfx", name: "Branch - Halifax", networktemplate_id: "swtpl-branch", address: "9 Sample Wharf, Halifax NS", country_code: "CA", timezone: "America/Halifax" }, + { id: "site-wpg", name: "Store - Winnipeg", networktemplate_id: "swtpl-branch", address: "300 Demo Ave, Winnipeg MB", country_code: "CA", timezone: "America/Winnipeg" }, + { id: "site-qc", name: "Store - Quebec City", networktemplate_id: "swtpl-branch", address: "12 Rue Exemple, Quebec QC", country_code: "CA", timezone: "America/Toronto" }, ]; const NOW = 1760000000; // fixed, so every regeneration matches @@ -25,6 +30,16 @@ const SWITCHES = [ { id: "dev-idf2", site: "site-hq", name: "hq-idf-2", model: "EX2300-48P", serial: "DEMO2300B2", mac: mac(0x112), version: "22.4R3-S2", status: "connected", ip: "10.10.0.12" }, { id: "dev-ott", site: "site-ott", name: "ott-sw1", model: "EX4100-24P", serial: "DEMO4100C1", mac: mac(0x201), version: "23.4R2-S3", status: "connected", ip: "10.20.0.2" }, { id: "dev-mtl", site: "site-mtl", name: "mtl-sw1", model: "EX2300-C-12P", serial: "DEMO2300D1", mac: mac(0x301), version: "21.4R3-S5", status: "disconnected", ip: "10.30.0.2" }, + { id: "dev-van1", site: "site-van", name: "van-core-1", model: "EX4650-48Y", serial: "DEMO4650E1", mac: mac(0x401), version: "23.4R2-S3", status: "connected", ip: "10.40.0.2" }, + { id: "dev-van2", site: "site-van", name: "van-core-2", model: "EX4650-48Y", serial: "DEMO4650E2", mac: mac(0x402), version: "23.4R2-S3", status: "connected", ip: "10.40.0.3" }, + { id: "dev-van3", site: "site-van", name: "van-acc-1", model: "EX4100-48P", serial: "DEMO4100E3", mac: mac(0x403), version: "23.4R2-S3", status: "connected", ip: "10.40.0.11" }, + { id: "dev-van4", site: "site-van", name: "van-acc-2", model: "EX4100-48P", serial: "DEMO4100E4", mac: mac(0x404), version: "22.4R3-S2", status: "disconnected", ip: "10.40.0.12" }, + { id: "dev-cgy1", site: "site-cgy", name: "cgy-sw1", model: "EX4100-24P", serial: "DEMO4100F1", mac: mac(0x411), version: "23.4R2-S3", status: "connected", ip: "10.50.0.2" }, + { id: "dev-cgy2", site: "site-cgy", name: "cgy-sw2", model: "EX2300-24P", serial: "DEMO2300F2", mac: mac(0x412), version: "21.4R3-S5", status: "disconnected", ip: "10.50.0.3" }, + { id: "dev-hfx", site: "site-hfx", name: "hfx-sw1", model: "EX2300-C-12P", serial: "DEMO2300G1", mac: mac(0x421), version: "22.4R3-S2", status: "connected", ip: "10.60.0.2" }, + { id: "dev-wpg1", site: "site-wpg", name: "wpg-sw1", model: "EX2300-C-12P", serial: "DEMO2300H1", mac: mac(0x431), version: "21.4R3-S5", status: "disconnected", ip: "10.70.0.2" }, + { id: "dev-wpg2", site: "site-wpg", name: "wpg-sw2", model: "EX4100-F-12P", serial: "DEMO4100H2", mac: mac(0x432), version: "23.4R2-S3", status: "connected", ip: "10.70.0.3" }, + { id: "dev-qc", site: "site-qc", name: "qc-sw1", model: "EX2300-24P", serial: "DEMO2300J1", mac: mac(0x441), version: "22.4R3-S2", status: "connected", ip: "10.80.0.2" }, ]; const IRB = { @@ -32,6 +47,10 @@ const IRB = { "dev-ott": [["data", 10, "10.20.10.1/24"], ["voice", 20, "10.20.20.1/24"]], // Deliberately reuses HQ's printer subnet, so the report flags a duplicate. "dev-mtl": [["data", 10, "10.30.10.1/24"], ["printers", 30, "10.10.30.1/24"]], + "dev-van1": [["data", 10, "10.40.10.1/24"], ["voice", 20, "10.40.20.1/24"], ["servers", 40, "10.40.40.1/24"], ["storage", 41, "10.40.41.1/24"]], + "dev-cgy1": [["data", 10, "10.50.10.1/24"], ["voice", 20, "10.50.20.1/24"]], + "dev-hfx": [["data", 10, "10.60.10.1/24"]], + "dev-qc": [["data", 10, "10.80.10.1/24"], ["guest", 99, "192.168.99.1/24"]], }; function configCmd(sw) { @@ -57,14 +76,24 @@ 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. +// Power supplies per VC member (Switch PSU Status): hq-core's backup member and +// van-core-2 have lost a supply, hq-idf-1 and cgy-sw2 have an empty slot, the +// compact EX2300-C models have one PSU, and wpg-sw2 reports no PSU data. const PSUS = { "dev-core": [["ok", "ok"], ["ok", "Failed"]], "dev-idf1": [["ok", "absent"]], "dev-idf2": [["ok", "ok"]], "dev-ott": [["ok", "ok"]], "dev-mtl": [["ok"]], + "dev-van1": [["ok", "ok"]], + "dev-van2": [["Failed", "ok"]], + "dev-van3": [["ok", "ok"]], + "dev-van4": [["ok", "ok"]], + "dev-cgy1": [["ok", "ok"]], + "dev-cgy2": [["ok", "absent"]], + "dev-hfx": [["ok"]], + "dev-wpg1": [["ok"]], + "dev-qc": [["ok", "ok"]], }; const psus = (id, fpc) => (PSUS[id]?.[fpc] || []).map((status, i) => ({ name: `Power Supply ${i}`, status })); @@ -113,6 +142,11 @@ const OTHER_DEVICES = [ ...Array.from({ length: 14 }, (_, i) => ({ type: "ap", mac: mac(0x600 + i), model: "AP45", site_id: "site-hq", connected: i !== 9 })), ...Array.from({ length: 5 }, (_, i) => ({ type: "ap", mac: mac(0x700 + i), model: "AP34", site_id: "site-ott", connected: true })), ...Array.from({ length: 3 }, (_, i) => ({ type: "ap", mac: mac(0x800 + i), model: "AP24", site_id: "site-mtl", connected: i !== 2 })), + ...Array.from({ length: 8 }, (_, i) => ({ type: "ap", mac: mac(0x810 + i), model: "AP45", site_id: "site-van", connected: i !== 6 })), + ...Array.from({ length: 4 }, (_, i) => ({ type: "ap", mac: mac(0x820 + i), model: "AP34", site_id: "site-cgy", connected: true })), + ...Array.from({ length: 2 }, (_, i) => ({ type: "ap", mac: mac(0x830 + i), model: "AP24", site_id: "site-hfx", connected: true })), + ...Array.from({ length: 3 }, (_, i) => ({ type: "ap", mac: mac(0x840 + i), model: "AP34", site_id: "site-wpg", connected: i !== 0 })), + ...Array.from({ length: 3 }, (_, i) => ({ type: "ap", mac: mac(0x850 + i), model: "AP34", site_id: "site-qc", connected: true })), { type: "gateway", mac: mac(0x150), model: "SRX320", site_id: "site-hq", connected: true }, { type: "gateway", mac: mac(0x250), model: "SSR120", site_id: "site-ott", connected: true }, { type: "ap", mac: mac(0x999), model: "AP45", connected: false }, @@ -127,6 +161,8 @@ const PORT_USAGES = { 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"], + "dev-van1": ["set protocols mstp bridge-priority 0", "set forwarding-options storm-control-profiles default all"], + "dev-wpg1": ["set poe interface ge-0/0/3 disable"], }; function siteDevice(sw) { @@ -156,6 +192,11 @@ const SITE_SETTING = { 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": {}, + "site-van": { additional_config_cmds: ["set snmp location \"DC - Vancouver, Hall B\"", "set system syslog host 192.0.2.11 any notice"] }, + "site-cgy": { additional_config_cmds: ["set snmp location \"Branch - Calgary\""] }, + "site-hfx": {}, + "site-wpg": { additional_config_cmds: ["set snmp location \"Store - Winnipeg\""] }, + "site-qc": {}, }; const WLANS = { @@ -173,6 +214,9 @@ const WLANS = { { id: "w-scan", ssid: "DemoScanners", enabled: false, auth: { type: "psk", pairwise: ["wpa2-ccmp"] }, vlan_enabled: false, band: "24", interface: "all", site_id: "site-mtl" }, ], }; +for (const site of ["site-van", "site-cgy", "site-hfx", "site-wpg", "site-qc"]) WLANS[site] = WLANS["site-ott"]; +WLANS["site-wpg"] = [...WLANS["site-ott"], + { id: "w-pos", ssid: "DemoPOS", enabled: true, hide_ssid: true, auth: { type: "psk", pairwise: ["wpa3"] }, vlan_enabled: true, vlan_id: 50, bands: ["5"], interface: "all", site_id: "site-wpg" }]; const NAMES = ["alex", "blair", "casey", "devon", "emery", "finley", "gray", "harper", "indigo", "jordan", "kai", "logan"]; function clients(siteId, count, subnet) { @@ -190,7 +234,9 @@ 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) }; +const CLIENTS = { "site-hq": clients("site-hq", 9, 10), "site-ott": clients("site-ott", 5, 20), "site-mtl": clients("site-mtl", 3, 30), + "site-van": clients("site-van", 7, 40), "site-cgy": clients("site-cgy", 4, 50), "site-hfx": clients("site-hfx", 2, 60), + "site-wpg": clients("site-wpg", 3, 70), "site-qc": clients("site-qc", 3, 80) }; // 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, @@ -275,6 +321,8 @@ const GATEWAYS = [ { site: "site-hq", name: "hq-ssr", model: "SSR130", mac: mac(0x701) }, { site: "site-ott", name: "ott-ssr", model: "SSR120", mac: mac(0x702) }, { site: "site-mtl", name: "mtl-srx", model: "SRX320", mac: mac(0x703) }, + { site: "site-van", name: "van-ssr", model: "SSR130", mac: mac(0x704) }, + { site: "site-cgy", name: "cgy-ssr", model: "SSR120", mac: mac(0x705) }, ]; const gatewayStats = (gw) => ({ type: "gateway", status: "connected", site_id: gw.site, name: gw.name, model: gw.model, mac: gw.mac }); @@ -291,18 +339,23 @@ const BGP_PEERS = [ bgpRow(mac(0x702), "site-ott", { neighbor: "198.51.100.1", neighbor_as: 64497, local_as: 65100, vrf_name: "internet", rx_routes: 1, tx_routes: 4 }), bgpRow(mac(0x703), "site-mtl", { neighbor: "192.0.2.65", neighbor_as: 64498, local_as: 65100, vrf_name: "internet", state: "active", up: false, rx_routes: 0, tx_routes: 0, flap_count: 3, uptime: 0 }), + bgpRow(mac(0x401), "site-van", { neighbor: "10.40.0.1", neighbor_as: 65100, local_as: 65040, rx_routes: 6, tx_routes: 9, uptime: 2592000 }), + bgpRow(mac(0x704), "site-van", { neighbor: "203.0.113.129", neighbor_as: 64499, local_as: 65100, vrf_name: "internet", rx_routes: 1, tx_routes: 8 }), + bgpRow(mac(0x705), "site-cgy", { neighbor: "198.51.100.129", neighbor_as: 64500, local_as: 65100, vrf_name: "internet", rx_routes: 1, tx_routes: 3, flap_count: 2, uptime: 5400 }), ]; const path = (gw, peer, o) => ({ mac: gw.mac, site_id: gw.site, router_name: gw.name, peer_mac: peer.mac, peer_site_id: peer.site, peer_router_name: peer.name, type: "svr", up: true, is_active: true, latency: 14, jitter: 1.2, loss: 0, mos: 4.4, mtu: 1500, uptime: 1209600, last_seen: NOW - 30, ...o }); -const [GW_HQ, GW_OTT, GW_MTL] = GATEWAYS; +const [GW_HQ, GW_OTT, GW_MTL, GW_VAN, GW_CGY] = GATEWAYS; const VPN_PEERS = [ path(GW_HQ, GW_OTT, { port_id: "ge-0/0/0", peer_port_id: "ge-0/0/0" }), path(GW_HQ, GW_OTT, { port_id: "ge-0/0/1", peer_port_id: "ge-0/0/1", is_active: false, latency: 31, jitter: 6.8, loss: 1.6, mos: 3.7 }), path(GW_OTT, GW_HQ, { port_id: "ge-0/0/0", peer_port_id: "ge-0/0/0", latency: 15 }), path(GW_MTL, GW_HQ, { type: "ipsec", port_id: "ge-0/0/0", peer_port_id: "ge-0/0/0", up: false, is_active: false, latency: "", jitter: "", loss: "", mos: "", uptime: 0, last_seen: NOW - 5400 }), + path(GW_VAN, GW_HQ, { port_id: "ge-0/0/0", peer_port_id: "ge-0/0/0", latency: 62, jitter: 2.1, mos: 4.3 }), + path(GW_CGY, GW_VAN, { port_id: "ge-0/0/0", peer_port_id: "ge-0/0/0", latency: 21, jitter: 1.4, mos: 4.4 }), path(GW_HQ, GW_MTL, { type: "ipsec", port_id: "ge-0/0/0", peer_port_id: "ge-0/0/0", up: false, is_active: false, latency: "", jitter: "", loss: "", mos: "", uptime: 0, last_seen: NOW - 5400 }), ]; @@ -314,6 +367,7 @@ const ALARM_DEFS = [ { key: "ap_bad_cable", display: "AP bad cable", severity: "warn", group: "infrastructure" }, { key: "dhcp_failure", display: "DHCP failure", severity: "major", group: "infrastructure" }, { key: "rogue_ap", display: "Rogue AP detected", severity: "minor", group: "security" }, + { key: "sw_psu_failure", display: "Switch power supply failure", severity: "critical", group: "infrastructure" }, ]; const ALARMS = { "site-hq": [ @@ -326,6 +380,17 @@ const ALARMS = { "site-mtl": [ { id: "al-4", type: "switch_offline", timestamp: NOW - 86000, last_seen: NOW - 120, count: 1, switches: [mac(0x301)], hostnames: ["mtl-sw1"], status: "open", acked: false }, ], + "site-van": [ + { id: "al-5", type: "sw_psu_failure", timestamp: NOW - 14400, last_seen: NOW - 300, count: 1, switches: [mac(0x402)], hostnames: ["van-core-2"], reasons: ["Power Supply 0 failed"], status: "open", acked: false }, + { id: "al-6", type: "switch_offline", timestamp: NOW - 10800, last_seen: NOW - 300, count: 1, switches: [mac(0x404)], hostnames: ["van-acc-2"], status: "open", acked: true, ack_admin_name: "Demo Admin" }, + ], + "site-cgy": [ + { id: "al-7", type: "switch_offline", timestamp: NOW - 172000, last_seen: NOW - 600, count: 1, switches: [mac(0x412)], hostnames: ["cgy-sw2"], status: "open", acked: false }, + ], + "site-wpg": [ + { id: "al-8", type: "switch_offline", timestamp: NOW - 43200, last_seen: NOW - 600, count: 1, switches: [mac(0x431)], hostnames: ["wpg-sw1"], status: "open", acked: false }, + { id: "al-9", type: "ap_bad_cable", timestamp: NOW - 20000, last_seen: NOW - 2400, count: 2, aps: [mac(0x840)], hostnames: ["wpg-ap-01"], reasons: ["Link flapping at 100 Mb/s"], status: "open", acked: false }, + ], }; // ---- Mist router -------------------------------------------------------------- diff --git a/mist-toolbox/scripts/screenshots/sheetview.mjs b/mist-toolbox/scripts/screenshots/sheetview.mjs new file mode 100644 index 0000000..9c2eb4f --- /dev/null +++ b/mist-toolbox/scripts/screenshots/sheetview.mjs @@ -0,0 +1,164 @@ +// Renders a downloaded .xlsx as an Excel-like HTML page, so the workbooks the +// tools save can be screenshotted next to the tools themselves. It reads only +// what lib/xlsx.js writes (inline strings, numbers, booleans, cellXfs with a +// font and a solid fill, column widths, a frozen header, tab colours, tables), +// which keeps it small and free of a spreadsheet dependency. + +import { inflateRawSync } from "node:zlib"; + +/** Entries of a zip, by name. Stored and deflated entries only — all zip.js writes. */ +function unzip(buf) { + let eocd = buf.length - 22; + while (eocd >= 0 && buf.readUInt32LE(eocd) !== 0x06054b50) eocd -= 1; + if (eocd < 0) throw new Error("not a zip"); + const count = buf.readUInt16LE(eocd + 10); + let p = buf.readUInt32LE(eocd + 16); + const out = {}; + for (let i = 0; i < count; i += 1) { + const method = buf.readUInt16LE(p + 10); + const size = buf.readUInt32LE(p + 20); + const nameLen = buf.readUInt16LE(p + 28); + const extraLen = buf.readUInt16LE(p + 30); + const commentLen = buf.readUInt16LE(p + 32); + const local = buf.readUInt32LE(p + 42); + const name = buf.toString("utf8", p + 46, p + 46 + nameLen); + const start = local + 30 + buf.readUInt16LE(local + 26) + buf.readUInt16LE(local + 28); + const raw = buf.subarray(start, start + size); + out[name] = (method === 8 ? inflateRawSync(raw) : raw).toString("utf8"); + p += 46 + nameLen + extraLen + commentLen; + } + return out; +} + +const unxml = (s) => s.replace(/</g, "<").replace(/>/g, ">").replace(/"/g, "\"").replace(/&/g, "&"); +const attr = (tag, name) => (tag.match(new RegExp(`\\s${name}="([^"]*)"`)) || [])[1]; +const argb = (v) => (v && v.length === 8 ? `#${v.slice(2)}` : null); + +function styles(xml) { + const section = (name) => (xml.match(new RegExp(`<${name}[^>]*>([\\s\\S]*?)`)) || [, ""])[1]; + const fonts = [...section("fonts").matchAll(/([\s\S]*?)<\/font>/g)].map(([, f]) => ({ + bold: //.test(f), color: argb(attr((f.match(/]*>/) || [""])[0], "rgb")), + })); + const fills = [...section("fills").matchAll(/([\s\S]*?)<\/fill>/g)].map(([, f]) => + /patternType="solid"/.test(f) ? argb(attr((f.match(/]*>/) || [""])[0], "rgb")) : null); + return [...section("cellXfs").matchAll(/]*?)(?:\/>|>([\s\S]*?)<\/xf>)/g)].map(([, a, inner = ""]) => ({ + ...fonts[Number(attr(a, "fontId")) || 0], + fill: fills[Number(attr(a, "fillId")) || 0], + border: Number(attr(a, "borderId")) > 0, + center: /horizontal="center"/.test(inner), + wrap: /wrapText="1"/.test(inner), + })); +} + +/** Parse an .xlsx buffer into { sheets: [{ name, tabColor, widths, rows, table }] }. */ +export function readXlsx(buf) { + const z = unzip(buf); + const xfs = styles(z["xl/styles.xml"] || ""); + const names = [...z["xl/workbook.xml"].matchAll(/]*>/g)].map(([t]) => unxml(attr(t, "name"))); + const sheets = names.map((name, i) => { + const xml = z[`xl/worksheets/sheet${i + 1}.xml`] || ""; + const widths = []; + for (const [t] of xml.matchAll(/]*>/g)) widths[Number(attr(t, "min")) - 1] = Number(attr(t, "width")); + const rows = [...xml.matchAll(/]*)>([\s\S]*?)<\/row>/g)].map(([, ra, body]) => ({ + height: Number(attr(ra, "ht")) || 15, + cells: [...body.matchAll(/]*?)(?:\/>|>([\s\S]*?)<\/c>)/g)].map(([, ca, inner = ""]) => { + const ref = attr(ca, "r"); + const col = [...ref.replace(/\d+$/, "")].reduce((n, ch) => n * 26 + ch.charCodeAt(0) - 64, 0) - 1; + const type = attr(ca, "t"); + const text = type === "inlineStr" ? unxml((inner.match(/]*>([\s\S]*?)<\/t>/) || [, ""])[1]) + : type === "b" ? ((inner.match(/(.*?)<\/v>/) || [])[1] === "1" ? "TRUE" : "FALSE") + : (inner.match(/(.*?)<\/v>/) || [, ""])[1]; + return { col, text, kind: type === "inlineStr" ? "s" : type === "b" ? "b" : "n", style: xfs[Number(attr(ca, "s")) || 0] || {} }; + }), + })); + return { + name, + tabColor: argb(attr((xml.match(/]*>/) || [""])[0], "rgb")), + frozen: /state="frozen"/.test(xml), + filter: / String(s).replace(/&/g, "&").replace(//g, ">").replace(/"/g, """); +const letter = (n) => { let s = ""; for (let i = n + 1; i > 0; i = Math.floor((i - 1) / 26)) s = String.fromCharCode(65 + ((i - 1) % 26)) + s; return s; }; + +/** + * One sheet as a full HTML page: title bar, formula bar, grid with column + * letters and row numbers, and the sheet tabs with the active one selected. + */ +export function sheetPage(book, fileName, active, { maxRows = 24, width = 1280 } = {}) { + const sh = book.sheets[active]; + const ncols = Math.max(sh.widths.length, ...sh.rows.map((r) => r.cells.length ? r.cells[r.cells.length - 1].col + 1 : 0)); + const px = (i) => Math.round((sh.widths[i] || 8.43) * 7 + 5); + // Fill the window with empty columns the way Excel does. + const colsPx = []; + let used = 42; + for (let i = 0; i < ncols || used < width; i += 1) { const w = i < ncols ? px(i) : 69; colsPx.push(w); used += w; } + const rows = sh.rows.slice(0, maxRows); + const blank = 3; + + const td = (c, r, i) => { + const st = c?.style || {}; + const css = []; + let fill = st.fill; + if (!fill && sh.table && r > 0 && r % 2 === 1) fill = "#DDEBF7"; + if (fill) css.push(`background:${fill}`); + if (st.color) css.push(`color:${st.color}`); + if (st.bold) css.push("font-weight:700"); + if (st.center) css.push("text-align:center"); + else if (c && c.kind !== "s") css.push(`text-align:${c.kind === "b" ? "center" : "right"}`); + if (st.border) css.push("border-color:#bfbfbf"); + const filter = r === 0 && sh.filter && i < ncols ? '▾' : ""; + const cls = [r === 0 && st.wrap ? "wr" : "", filter ? "flt" : ""].filter(Boolean).join(" "); + return `${c ? esc(c.text) : ""}${filter}`; + }; + + const body = rows.map((row, r) => { + const byCol = new Map(row.cells.map((c) => [c.col, c])); + return `${r + 1}` + + colsPx.map((_, i) => td(byCol.get(i), r, i)).join("") + ""; + }).join("") + Array.from({ length: blank }, (_, k) => + `${rows.length + k + 1}${colsPx.map(() => "").join("")}`).join(""); + + const first = rows[0]?.cells[0]?.text ?? ""; + const tabs = book.sheets.map((s, i) => + `${esc(s.name)}`).join(""); + + return ` +
X${esc(fileName)}— Excel
+
A1fx${esc(first)}
+
${colsPx.map((w) => ``).join("")} +${colsPx.map((_, i) => `${letter(i)}`).join("")} +${body}
+
${tabs}
+`; +}