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));