From 51a5d21196b53c9fdb24545ac76a5c46ae5370bd Mon Sep 17 00:00:00 2001 From: InterconnectedSystems <190777379+InterconnectedSystems@users.noreply.github.com> Date: Fri, 16 Oct 2026 14:49:31 +0000 Subject: [PATCH] Switch PSU Status: "Present" PSUs have no power; fix VC and CX grading Checked against a live org: - Junos "Present" means a PSU is installed but not supplying power. It was counted as OK, so a VC member with one unpowered PSU read as Redundant. It is now its own "No power" class and grades the switch "PSU failed / no power". - 1.1.2's missing-member check counted Mist's virtual chassis inventory row (its serial is the VC MAC) as a member, and compared non-VC chassis serials with module serials, so it flagged almost every VC. It now uses only real VC members, and skips switches that send no module data at all. - CX switches list PSUs, management modules, line cards and fan trays as separate modules. PSUs with no FPC or VC role are now pooled into one chassis, and modules without PSUs are skipped. - PSUs are listed in name order. Co-Authored-By: Claude Opus 5.5 (1M context) --- mist-toolbox/tests/switch-psu-status.test.js | 100 ++++++++++++++-- mist-toolbox/tools/switch-psu-status.js | 116 ++++++++++++------- switch-psu-status.js | 116 ++++++++++++------- 3 files changed, 244 insertions(+), 88 deletions(-) diff --git a/mist-toolbox/tests/switch-psu-status.test.js b/mist-toolbox/tests/switch-psu-status.test.js index 30c57b7..cc3eb13 100644 --- a/mist-toolbox/tests/switch-psu-status.test.js +++ b/mist-toolbox/tests/switch-psu-status.test.js @@ -15,8 +15,12 @@ const sw = (o) => ({ type: "switch", status: "connected", model: "EX4400-48P", . const routes = (o = {}) => ({ "/orgs/org-1/sites": SITES, "/orgs/org-1/inventory": [ - { type: "switch", mac: "aabbcc000003", vc_mac: "aabbcc000003", serial: "A" }, - { type: "switch", mac: "aabbcc000013", vc_mac: "aabbcc000003", serial: "B" }, + // Mist's virtual chassis row: its serial is the VC MAC, not a member. + { type: "switch", mac: "aabbcc000003", vc_mac: "aabbcc000003", serial: "AABBCC000003" }, + { type: "switch", mac: "aabbcc000013", vc_mac: "aabbcc000003", serial: "A" }, + { type: "switch", mac: "aabbcc000023", vc_mac: "aabbcc000003", serial: "B" }, + // Not a VC: its chassis serial differs from its module serials. + { type: "switch", mac: "aabbcc000001", serial: "CORE-CHASSIS" }, ], "/sites/s1/stats/devices": [ sw({ name: "core", mac: "aabbcc000001", module_stat: [{ fpc_idx: 0, psus: psus("ok", "ok") }] }), @@ -43,7 +47,7 @@ test("all sites: each switch graded, worst VC member wins, failures sort first", 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", + core: "Redundant", edge: "Not redundant", vc: "PSU failed / no power", 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"); @@ -53,7 +57,7 @@ test("all sites: each switch graded, worst VC member wins, failures sort first", 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, 0 with a missing member, 2 not redundant, 1 redundant$/); + /^5 switch\(es\): 1 with a failed or unpowered PSU, 0 with a missing member, 2 not redundant, 1 redundant$/); assert.match(result.filename, /^mist_switch_psu_all_sites_Acme_Corp_/); }); @@ -107,11 +111,14 @@ test("no site picked is a clear error", async () => { // A 4-member VC: inventory knows 4 members, module_stat reports only 3, all // healthy. Before, the 3 that report decided it and it read as Redundant. const STACK = "aabbcc0000a0"; -const stackInv = ["S0", "S1", "S2", "S3"].map((serial, i) => ({ - type: "switch", serial, mac: `aabbcc0000a${i}`, vc_mac: STACK, model: "EX4400-48P", -})); +const stackInv = [ + { type: "switch", serial: STACK.toUpperCase(), mac: STACK, vc_mac: STACK, model: "EX4400-48P" }, + ...["S0", "S1", "S2", "S3"].map((serial, i) => ({ + type: "switch", serial, mac: `aabbcc0000b${i}`, vc_mac: STACK, model: "EX4400-48P", + })), +]; const stack = (mods) => sw({ name: "stack", mac: STACK, module_stat: mods }); -const mod = (i, o = {}) => ({ fpc_idx: i, serial: `S${i}`, mac: `aabbcc0000a${i}`, psus: psus("ok", "ok"), ...o }); +const mod = (i, o = {}) => ({ fpc_idx: i, serial: `S${i}`, mac: `aabbcc0000b${i}`, psus: psus("ok", "ok"), ...o }); const stackRoutes = (mods, inv = stackInv) => routes({ "/orgs/org-1/inventory": inv, "/sites/s1/stats/devices": [stack(mods)], @@ -130,7 +137,7 @@ test("4-member VC with a member not reporting is Member missing, not Redundant", assert.equal(psuRow.cls, "Member missing"); assert.deepEqual(sheetNamed(result, "By Site").rows[0], { site: "HQ", total: 1, failed: 0, missing: 1, single: 0, nodata: 0, redundant: 0, offline: 0, __style: "red" }); - assert.match(result.summary, /0 with a failed PSU, 1 with a missing member/); + assert.match(result.summary, /0 with a failed or unpowered PSU, 1 with a missing member/); }); test("a member reporting as not present does not count on its stale PSU readings", async () => { @@ -149,7 +156,7 @@ test("all 4 members reporting is Redundant", async () => { }); test("members are matched by MAC when the inventory has no serial", async () => { - const inv = stackInv.map(({ serial, ...r }) => r); + const inv = stackInv.slice(1).map(({ serial, ...r }) => r); stubMist(stackRoutes([mod(0), mod(1), mod(2), mod(3)], inv)); const row = sheetNamed(await run({}), "Switches").rows.find((r) => r.name === "stack"); assert.equal(row.health, "Redundant"); @@ -162,3 +169,76 @@ test("an inventory failure only turns off the missing-member check", async () => assert.equal(sheetNamed(result, "Switches").rows.length, 5); assert.ok(ctx.logs.some(([, m]) => /missing VC members cannot be detected/.test(m))); }); + +// Shapes seen on a live org (serials and MACs made up). +const one = (name, mods, o = {}) => routes({ + "/sites/s1/stats/devices": [sw({ name, mac: "aabbcc0000c0", module_stat: mods, ...o })], + "/sites/s2/stats/devices": [], + ...o.routes, +}); +const rowOf = async (name) => sheetNamed(await run({}), "Switches").rows.find((r) => r.name === name); + +test('"Present" is installed but unpowered, so a VC with it is not Redundant', async () => { + stubMist(one("tor-vc", [ + { fpc_idx: 0, vc_role: "master", vc_state: "present", serial: "N0", psus: psus("present", "ok") }, + { fpc_idx: 1, vc_role: "backup", vc_state: "present", serial: "N1", psus: psus("present", "ok") }, + { fpc_idx: 2, vc_role: "linecard", vc_state: "present", serial: "N2", psus: psus("ok", "absent") }, + ])); + const result = await run({}); + const row = sheetNamed(result, "Switches").rows.find((r) => r.name === "tor-vc"); + assert.equal(row.health, "PSU failed / no power"); + assert.equal(row.nopower, 2); + assert.equal(row.ok, 3); + const p = sheetNamed(result, "PSUs").rows.filter((r) => r.raw === "present"); + assert.deepEqual(p.map((r) => [r.member, r.cls, r.__style]), [[0, "No power", "red"], [1, "No power", "red"]]); +}); + +test('one OK and two "Present" PSUs is not Redundant, and PSUs list in name order', async () => { + stubMist(one("desk", [{ fpc_idx: 0, vc_role: "master", serial: "F0", psus: [ + { name: "Power Supply 2", status: "present" }, { name: "Power Supply 0", status: "ok" }, + { name: "Power Supply 1", status: "present" }] }])); + const row = await rowOf("desk"); + assert.equal(row.health, "PSU failed / no power"); + assert.equal(row.detail, "Power Supply 0=ok, Power Supply 1=present, Power Supply 2=present"); +}); + +test("a disconnected VC with no module data is No PSU data, not missing members", async () => { + stubMist(one("dark-vc", [{ fpc_idx: null, serial: null, psus: null }], { + status: "disconnected", + routes: { "/orgs/org-1/inventory": [ + { mac: "aabbcc0000c0", vc_mac: "aabbcc0000c0", serial: "AABBCC0000C0" }, + { mac: "aabbcc0000c1", vc_mac: "aabbcc0000c0", serial: "J1" }, + { mac: "aabbcc0000c2", vc_mac: "aabbcc0000c0", serial: "J2" }, + ] }, + })); + const row = await rowOf("dark-vc"); + assert.equal(row.health, "No PSU data"); + assert.equal(row.members, 0); +}); + +test("CX chassis: PSU modules pool into one chassis; modules without PSUs are skipped", async () => { + const psuMod = (name, status) => ({ fpc_idx: null, serial: "X", model: "R0X35A", psus: [{ name, status }] }); + stubMist(one("cx6405", [ + { fpc_idx: null, vc_role: "master", vc_state: "ready", serial: "M1", psus: null }, + { fpc_idx: null, vc_role: "backup", vc_state: "ready", serial: "M2", psus: null }, + { fpc_idx: 3, serial: "L3", psus: null }, + { fpc_idx: 5, serial: "", psus: null }, + psuMod("1/1", "ok"), psuMod("1/2", "ok"), psuMod("1/3", "absent"), psuMod("1/4", "absent"), + { fpc_idx: null, serial: "FAN", model: "6400 Fan Tray", psus: null }, + ])); + const row = await rowOf("cx6405"); + assert.equal(row.health, "Redundant"); + assert.equal(row.members, 1); + assert.equal(row.detail, "1/1=ok, 1/2=ok, 1/3=absent, 1/4=absent"); +}); + +test("CX6300: the empty first module is ignored; ok + absent is Not redundant", async () => { + stubMist(one("cx6300", [ + { fpc_idx: null, serial: null, psus: null }, + { fpc_idx: 1, vc_role: "master", vc_state: "ready", serial: "SG1", psus: [ + { name: "1/2", status: "absent" }, { name: "1/1", status: "ok" }] }, + ])); + const row = await rowOf("cx6300"); + assert.equal(row.health, "Not redundant"); + assert.equal(row.members, 1); +}); diff --git a/mist-toolbox/tools/switch-psu-status.js b/mist-toolbox/tools/switch-psu-status.js index 7c57db3..f3b31e8 100644 --- a/mist-toolbox/tools/switch-psu-status.js +++ b/mist-toolbox/tools/switch-psu-status.js @@ -4,21 +4,26 @@ // Each virtual-chassis member (module_stat entry) is judged on its own, and the // switch takes the worst member's result. The org inventory says which members a // VC should have, so a member that stops reporting is flagged, not just dropped. +// Modules without PSUs (CX management modules, line cards, fan trays) are +// skipped, and PSU modules with no FPC or VC role are pooled as one chassis. // 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"]; +// Junos "Present" means installed but not supplying power (no cord, no input). +const PSU_OK = ["ok", "online", "normal", "good", "powered", "on"]; +const PSU_NOPOWER = ["present", "no input", "unpowered"]; 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" }, + nopower: { label: "No power", color: "#e65100" }, absent: { label: "Empty slot", color: "#9e9e9e" }, unknown: { label: "Unknown", color: "#5c6bc0" }, }; const HEALTH = { - failed: { label: "PSU failed", color: "#d32f2f", rank: 0, style: "red" }, + failed: { label: "PSU failed / no power", color: "#d32f2f", rank: 0, style: "red" }, missing: { label: "Member missing", color: "#8e24aa", rank: 1, style: "red" }, single: { label: "Not redundant", color: "#f57c00", rank: 2, style: "yellow" }, nodata: { label: "No PSU data", color: "#757575", rank: 3, style: undefined }, @@ -36,6 +41,7 @@ function classify(raw) { const s = String(raw ?? "").trim().toLowerCase(); if (!s) return "unknown"; if (PSU_ABSENT.includes(s)) return "absent"; + if (PSU_NOPOWER.includes(s)) return "nopower"; if (PSU_FAILED.includes(s) || s.includes("fail")) return "failed"; if (PSU_OK.includes(s)) return "ok"; return "unknown"; @@ -43,7 +49,7 @@ function classify(raw) { function memberHealth(psus) { const n = (c) => psus.filter((p) => p.cls === c).length; - if (n("failed")) return "failed"; + if (n("failed") || n("nopower")) return "failed"; if (n("ok") >= 2) return "redundant"; if (n("ok") === 1) return "single"; return "nodata"; @@ -51,6 +57,9 @@ function memberHealth(psus) { const normMac = (v) => String(v || "").toLowerCase().replace(/[^0-9a-f]/g, ""); +/** "FPC 2", or "Chassis" for pooled CX PSU modules. */ +const memberLabel = (m) => (m.idx === "chassis" ? "Chassis" : `FPC ${m.idx}`); + const worst = (keys) => keys.reduce((w, k) => (HEALTH[k].rank < HEALTH[w].rank ? k : w), "redundant"); @@ -131,10 +140,17 @@ export default { try { const inventory = await ctx.getAll(`/orgs/${ctx.orgId}/inventory`, { type: "switch", vc: true }); for (const item of inventory) { - const key = normMac(item.vc_mac || item.mac); + const key = normMac(item.vc_mac); if (!key) continue; expected.set(key, [...(expected.get(key) || []), item]); } + // Mist lists a VC's virtual chassis as a row whose serial is the VC MAC; + // it is not a physical member. Only a group of 2+ real members is a VC. + for (const [key, rows] of expected) { + const real = rows.filter((r) => normMac(r.serial) !== key && normMac(r.serial) !== normMac(r.mac)); + if (real.length > 1) expected.set(key, real); + else expected.delete(key); + } } catch (e) { ctx.log(`Inventory: ${e.message}; missing VC members cannot be detected`, "err"); } @@ -143,29 +159,45 @@ export default { 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), - })); - const absent = NOT_PRESENT.test(String(m.vc_state || "")); - return { - idx: m.fpc_idx ?? m.idx ?? i, - role: m.vc_role || "", - model: m.model || d.model || "", - serial: m.serial || "", - mac: normMac(m.mac), - psus, - // A not-present member keeps its last PSU readings; they are shown - // but cannot vouch for it. - missing: absent, - health: absent ? "missing" : memberHealth(psus), - }; + const toPsus = (arr, offset = 0) => arr.map((p, j) => ({ + name: p?.name || `PSU ${j + offset}`, + raw: p?.status ?? "", + cls: classify(p?.status), + })).sort((a, b) => a.name.localeCompare(b.name, undefined, { numeric: true })); + const members = []; + const pooled = []; + list(d.module_stat).forEach((m, i) => { + if (!m || typeof m !== "object") return; + const raw = list(m.psus); + const absent = NOT_PRESENT.test(String(m.vc_state || "")); + const isMember = m.fpc_idx != null || m.vc_role; + // CX PSU modules: PSUs with no FPC or VC role belong to the chassis. + if (!isMember) { pooled.push(...raw); return; } + // A module with no PSUs (management module, line card) says nothing + // about power, unless it is a VC member that is not present. + if (!raw.length && !absent) return; + const psus = toPsus(raw); + members.push({ + idx: m.fpc_idx ?? m.idx ?? i, + role: m.vc_role || "", + model: m.model || d.model || "", + serial: m.serial || "", + mac: normMac(m.mac), + psus, + // A not-present member keeps its last PSU readings; they are shown + // but cannot vouch for it. + missing: absent, + health: absent ? "missing" : memberHealth(psus), }); - const inv = expected.get(normMac(d.vc_mac || d.mac)) || []; + }); + if (pooled.length) { + const psus = toPsus(pooled); + members.push({ idx: "chassis", role: "", model: d.model || "", serial: "", mac: "", psus, + missing: false, health: memberHealth(psus) }); + } + const reporting = members.length; + // No member data at all (e.g. disconnected) is "No PSU data", not missing. + const inv = reporting ? expected.get(normMac(d.vc_mac || d.mac)) || [] : []; for (const e of inv) { const serial = String(e.serial || "").toUpperCase(); const mac = normMac(e.mac); @@ -178,7 +210,8 @@ export default { }); } } - members.sort((a, b) => (a.idx === "?") - (b.idx === "?") || Number(a.idx) - Number(b.idx)); + const order = (m) => (m.idx === "?" ? 1e6 : m.idx === "chassis" ? -1 : Number(m.idx)); + members.sort((a, b) => order(a) - order(b)); return { site: d.__site, name: d.name || ctx.fmtMac(String(d.mac || "")) || d.id, @@ -209,7 +242,8 @@ export default { 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" || m.missing ? "red" : p.cls === "unknown" ? "yellow" : undefined, + __style: p.cls === "failed" || p.cls === "nopower" || m.missing ? "red" + : p.cls === "unknown" ? "yellow" : undefined, }); } } @@ -233,9 +267,9 @@ export default { b.failed - a.failed || b.missing - a.missing || b.single - a.single || a.site.localeCompare(b.site)); ctx.log(`${switches.length} switch(es), ${allPsus.length} PSU(s): ` + - `${psuTotals.failed} failed, ${totals.missing} VC(s) missing a member, ` + + `${psuTotals.failed} failed, ${psuTotals.nopower} without power, ${totals.missing} VC(s) missing a member, ` + `${totals.single} switch(es) not redundant`, - psuTotals.failed || totals.missing ? "err" : "ok"); + psuTotals.failed || psuTotals.nopower || totals.missing ? "err" : "ok"); // ---- Dashboard -------------------------------------------------------- if (ctx.mount) drawDashboard(ctx, { switches, siteRows, totals, psuTotals, offline, scopeLabel }); @@ -250,6 +284,7 @@ export default { { header: "VC Members", key: "members", width: 10 }, { header: "PSUs OK", key: "ok", width: 9 }, { header: "PSUs Failed", key: "failed", width: 10 }, + { header: "PSUs No Power", key: "nopower", width: 10 }, { header: "Empty Slots", key: "absent", width: 10 }, { header: "PSUs Unknown", key: "unknown", width: 10 }, { header: "PSU Health", key: "health", width: 16 }, @@ -267,11 +302,12 @@ export default { : s.members.length, ok: count(ps, (p) => p.cls === "ok"), failed: count(ps, (p) => p.cls === "failed"), + nopower: count(ps, (p) => p.cls === "nopower"), 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}: ` : ""}` + + `${s.members.length > 1 ? `${memberLabel(m)}: ` : ""}` + (m.missing ? `${m.serial || "member"} not reporting${m.psus.length ? " (last: " : ""}` : "") + (m.psus.length ? m.psus.map((p) => `${p.name}=${p.raw || "?"}`).join(", ") : m.missing ? "" : "no PSU data") + (m.missing && m.psus.length ? ")" : "")).join("\n"), @@ -307,12 +343,13 @@ export default { ["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}`], + ["PSUs OK / failed / no power / empty / unknown", + `${psuTotals.ok} / ${psuTotals.failed} / ${psuTotals.nopower} / ${psuTotals.absent} / ${psuTotals.unknown}`], ["Redundant", "Every member has 2 or more PSUs reporting OK"], ["Member missing", "A VC member in the org inventory is not reporting, or reports as not present"], ["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"], + ["PSU failed / no power", "A member has a PSU reporting failed, check, offline or similar, " + + "or one that is installed but not supplying power (Junos \"Present\")"], ["Generated", ctx.epochToUtc(Date.now())], ]; const sheets = [ @@ -332,7 +369,7 @@ export default { 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, ` + + summary: `${switches.length} switch(es): ${totals.failed} with a failed or unpowered PSU, ` + `${totals.missing} with a missing member, ` + `${totals.single} not redundant, ${totals.redundant} redundant${failNote}`, filename: ctx.stampedName(`mist_switch_psu_${scopeName}`, ctx.orgName, "xlsx"), @@ -359,14 +396,14 @@ function drawDashboard(ctx, { switches, siteRows, totals, psuTotals, offline, sc const pill = (p) => { const num = (String(p.name).match(/(\d+)\s*$/) || [])[1]; return `${E(num != null ? `PSU ${num}` : p.name)}`; + `title="${E(`${p.name}: ${p.raw || "no status"}${p.cls === "nopower" ? " (installed, not supplying power)" : ""}`)}">${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)}` : ""}` : ""}` + + ? `${E(memberLabel(m))}${m.role ? ` \u00b7 ${E(m.role)}` : ""}` : ""}` + (m.missing ? `${E(m.serial || "member")} \u00b7 not reporting` : m.psus.length ? m.psus.map(pill).join("") : `no PSU data`) + @@ -431,7 +468,8 @@ function drawDashboard(ctx, { switches, siteRows, totals, psuTotals, offline, sc ${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(`${psuTotals.failed + psuTotals.nopower}/${psuTotals.ok + psuTotals.failed + psuTotals.nopower + psuTotals.unknown}`, + "PSUs failed or unpowered / installed", PSU.failed.color)} ${tile(offline, "Disconnected", "#9e9e9e")}
${legend}
@@ -468,7 +506,7 @@ function drawDashboard(ctx, { switches, siteRows, totals, psuTotals, offline, sc ? [...groups].map(([site, list]) => `
${E(site)} (${list.length})
` + `
${list.map(card).join("")}
`).join("") - : `
No switches match.${state.issuesOnly ? " No switch has a failed PSU, a missing member or lost redundancy." : ""}
`; + : `
No switches match.${state.issuesOnly ? " No switch has a failed or unpowered PSU, a missing member or lost redundancy." : ""}
`; root.querySelectorAll("[data-mode]").forEach((b) => b.classList.toggle("on", (b.dataset.mode === "issues") === state.issuesOnly)); diff --git a/switch-psu-status.js b/switch-psu-status.js index 7c57db3..f3b31e8 100644 --- a/switch-psu-status.js +++ b/switch-psu-status.js @@ -4,21 +4,26 @@ // Each virtual-chassis member (module_stat entry) is judged on its own, and the // switch takes the worst member's result. The org inventory says which members a // VC should have, so a member that stops reporting is flagged, not just dropped. +// Modules without PSUs (CX management modules, line cards, fan trays) are +// skipped, and PSU modules with no FPC or VC role are pooled as one chassis. // 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"]; +// Junos "Present" means installed but not supplying power (no cord, no input). +const PSU_OK = ["ok", "online", "normal", "good", "powered", "on"]; +const PSU_NOPOWER = ["present", "no input", "unpowered"]; 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" }, + nopower: { label: "No power", color: "#e65100" }, absent: { label: "Empty slot", color: "#9e9e9e" }, unknown: { label: "Unknown", color: "#5c6bc0" }, }; const HEALTH = { - failed: { label: "PSU failed", color: "#d32f2f", rank: 0, style: "red" }, + failed: { label: "PSU failed / no power", color: "#d32f2f", rank: 0, style: "red" }, missing: { label: "Member missing", color: "#8e24aa", rank: 1, style: "red" }, single: { label: "Not redundant", color: "#f57c00", rank: 2, style: "yellow" }, nodata: { label: "No PSU data", color: "#757575", rank: 3, style: undefined }, @@ -36,6 +41,7 @@ function classify(raw) { const s = String(raw ?? "").trim().toLowerCase(); if (!s) return "unknown"; if (PSU_ABSENT.includes(s)) return "absent"; + if (PSU_NOPOWER.includes(s)) return "nopower"; if (PSU_FAILED.includes(s) || s.includes("fail")) return "failed"; if (PSU_OK.includes(s)) return "ok"; return "unknown"; @@ -43,7 +49,7 @@ function classify(raw) { function memberHealth(psus) { const n = (c) => psus.filter((p) => p.cls === c).length; - if (n("failed")) return "failed"; + if (n("failed") || n("nopower")) return "failed"; if (n("ok") >= 2) return "redundant"; if (n("ok") === 1) return "single"; return "nodata"; @@ -51,6 +57,9 @@ function memberHealth(psus) { const normMac = (v) => String(v || "").toLowerCase().replace(/[^0-9a-f]/g, ""); +/** "FPC 2", or "Chassis" for pooled CX PSU modules. */ +const memberLabel = (m) => (m.idx === "chassis" ? "Chassis" : `FPC ${m.idx}`); + const worst = (keys) => keys.reduce((w, k) => (HEALTH[k].rank < HEALTH[w].rank ? k : w), "redundant"); @@ -131,10 +140,17 @@ export default { try { const inventory = await ctx.getAll(`/orgs/${ctx.orgId}/inventory`, { type: "switch", vc: true }); for (const item of inventory) { - const key = normMac(item.vc_mac || item.mac); + const key = normMac(item.vc_mac); if (!key) continue; expected.set(key, [...(expected.get(key) || []), item]); } + // Mist lists a VC's virtual chassis as a row whose serial is the VC MAC; + // it is not a physical member. Only a group of 2+ real members is a VC. + for (const [key, rows] of expected) { + const real = rows.filter((r) => normMac(r.serial) !== key && normMac(r.serial) !== normMac(r.mac)); + if (real.length > 1) expected.set(key, real); + else expected.delete(key); + } } catch (e) { ctx.log(`Inventory: ${e.message}; missing VC members cannot be detected`, "err"); } @@ -143,29 +159,45 @@ export default { 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), - })); - const absent = NOT_PRESENT.test(String(m.vc_state || "")); - return { - idx: m.fpc_idx ?? m.idx ?? i, - role: m.vc_role || "", - model: m.model || d.model || "", - serial: m.serial || "", - mac: normMac(m.mac), - psus, - // A not-present member keeps its last PSU readings; they are shown - // but cannot vouch for it. - missing: absent, - health: absent ? "missing" : memberHealth(psus), - }; + const toPsus = (arr, offset = 0) => arr.map((p, j) => ({ + name: p?.name || `PSU ${j + offset}`, + raw: p?.status ?? "", + cls: classify(p?.status), + })).sort((a, b) => a.name.localeCompare(b.name, undefined, { numeric: true })); + const members = []; + const pooled = []; + list(d.module_stat).forEach((m, i) => { + if (!m || typeof m !== "object") return; + const raw = list(m.psus); + const absent = NOT_PRESENT.test(String(m.vc_state || "")); + const isMember = m.fpc_idx != null || m.vc_role; + // CX PSU modules: PSUs with no FPC or VC role belong to the chassis. + if (!isMember) { pooled.push(...raw); return; } + // A module with no PSUs (management module, line card) says nothing + // about power, unless it is a VC member that is not present. + if (!raw.length && !absent) return; + const psus = toPsus(raw); + members.push({ + idx: m.fpc_idx ?? m.idx ?? i, + role: m.vc_role || "", + model: m.model || d.model || "", + serial: m.serial || "", + mac: normMac(m.mac), + psus, + // A not-present member keeps its last PSU readings; they are shown + // but cannot vouch for it. + missing: absent, + health: absent ? "missing" : memberHealth(psus), }); - const inv = expected.get(normMac(d.vc_mac || d.mac)) || []; + }); + if (pooled.length) { + const psus = toPsus(pooled); + members.push({ idx: "chassis", role: "", model: d.model || "", serial: "", mac: "", psus, + missing: false, health: memberHealth(psus) }); + } + const reporting = members.length; + // No member data at all (e.g. disconnected) is "No PSU data", not missing. + const inv = reporting ? expected.get(normMac(d.vc_mac || d.mac)) || [] : []; for (const e of inv) { const serial = String(e.serial || "").toUpperCase(); const mac = normMac(e.mac); @@ -178,7 +210,8 @@ export default { }); } } - members.sort((a, b) => (a.idx === "?") - (b.idx === "?") || Number(a.idx) - Number(b.idx)); + const order = (m) => (m.idx === "?" ? 1e6 : m.idx === "chassis" ? -1 : Number(m.idx)); + members.sort((a, b) => order(a) - order(b)); return { site: d.__site, name: d.name || ctx.fmtMac(String(d.mac || "")) || d.id, @@ -209,7 +242,8 @@ export default { 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" || m.missing ? "red" : p.cls === "unknown" ? "yellow" : undefined, + __style: p.cls === "failed" || p.cls === "nopower" || m.missing ? "red" + : p.cls === "unknown" ? "yellow" : undefined, }); } } @@ -233,9 +267,9 @@ export default { b.failed - a.failed || b.missing - a.missing || b.single - a.single || a.site.localeCompare(b.site)); ctx.log(`${switches.length} switch(es), ${allPsus.length} PSU(s): ` + - `${psuTotals.failed} failed, ${totals.missing} VC(s) missing a member, ` + + `${psuTotals.failed} failed, ${psuTotals.nopower} without power, ${totals.missing} VC(s) missing a member, ` + `${totals.single} switch(es) not redundant`, - psuTotals.failed || totals.missing ? "err" : "ok"); + psuTotals.failed || psuTotals.nopower || totals.missing ? "err" : "ok"); // ---- Dashboard -------------------------------------------------------- if (ctx.mount) drawDashboard(ctx, { switches, siteRows, totals, psuTotals, offline, scopeLabel }); @@ -250,6 +284,7 @@ export default { { header: "VC Members", key: "members", width: 10 }, { header: "PSUs OK", key: "ok", width: 9 }, { header: "PSUs Failed", key: "failed", width: 10 }, + { header: "PSUs No Power", key: "nopower", width: 10 }, { header: "Empty Slots", key: "absent", width: 10 }, { header: "PSUs Unknown", key: "unknown", width: 10 }, { header: "PSU Health", key: "health", width: 16 }, @@ -267,11 +302,12 @@ export default { : s.members.length, ok: count(ps, (p) => p.cls === "ok"), failed: count(ps, (p) => p.cls === "failed"), + nopower: count(ps, (p) => p.cls === "nopower"), 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}: ` : ""}` + + `${s.members.length > 1 ? `${memberLabel(m)}: ` : ""}` + (m.missing ? `${m.serial || "member"} not reporting${m.psus.length ? " (last: " : ""}` : "") + (m.psus.length ? m.psus.map((p) => `${p.name}=${p.raw || "?"}`).join(", ") : m.missing ? "" : "no PSU data") + (m.missing && m.psus.length ? ")" : "")).join("\n"), @@ -307,12 +343,13 @@ export default { ["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}`], + ["PSUs OK / failed / no power / empty / unknown", + `${psuTotals.ok} / ${psuTotals.failed} / ${psuTotals.nopower} / ${psuTotals.absent} / ${psuTotals.unknown}`], ["Redundant", "Every member has 2 or more PSUs reporting OK"], ["Member missing", "A VC member in the org inventory is not reporting, or reports as not present"], ["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"], + ["PSU failed / no power", "A member has a PSU reporting failed, check, offline or similar, " + + "or one that is installed but not supplying power (Junos \"Present\")"], ["Generated", ctx.epochToUtc(Date.now())], ]; const sheets = [ @@ -332,7 +369,7 @@ export default { 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, ` + + summary: `${switches.length} switch(es): ${totals.failed} with a failed or unpowered PSU, ` + `${totals.missing} with a missing member, ` + `${totals.single} not redundant, ${totals.redundant} redundant${failNote}`, filename: ctx.stampedName(`mist_switch_psu_${scopeName}`, ctx.orgName, "xlsx"), @@ -359,14 +396,14 @@ function drawDashboard(ctx, { switches, siteRows, totals, psuTotals, offline, sc const pill = (p) => { const num = (String(p.name).match(/(\d+)\s*$/) || [])[1]; return `${E(num != null ? `PSU ${num}` : p.name)}`; + `title="${E(`${p.name}: ${p.raw || "no status"}${p.cls === "nopower" ? " (installed, not supplying power)" : ""}`)}">${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)}` : ""}` : ""}` + + ? `${E(memberLabel(m))}${m.role ? ` \u00b7 ${E(m.role)}` : ""}` : ""}` + (m.missing ? `${E(m.serial || "member")} \u00b7 not reporting` : m.psus.length ? m.psus.map(pill).join("") : `no PSU data`) + @@ -431,7 +468,8 @@ function drawDashboard(ctx, { switches, siteRows, totals, psuTotals, offline, sc ${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(`${psuTotals.failed + psuTotals.nopower}/${psuTotals.ok + psuTotals.failed + psuTotals.nopower + psuTotals.unknown}`, + "PSUs failed or unpowered / installed", PSU.failed.color)} ${tile(offline, "Disconnected", "#9e9e9e")}
${legend}
@@ -468,7 +506,7 @@ function drawDashboard(ctx, { switches, siteRows, totals, psuTotals, offline, sc ? [...groups].map(([site, list]) => `
${E(site)} (${list.length})
` + `
${list.map(card).join("")}
`).join("") - : `
No switches match.${state.issuesOnly ? " No switch has a failed PSU, a missing member or lost redundancy." : ""}
`; + : `
No switches match.${state.issuesOnly ? " No switch has a failed or unpowered PSU, a missing member or lost redundancy." : ""}
`; root.querySelectorAll("[data-mode]").forEach((b) => b.classList.toggle("on", (b.dataset.mode === "issues") === state.issuesOnly));