b4714c70d1
Ship team/DM chat with files, avatars, unread alerts, and a first-open identity gate (including Guest); fold Team into Ops desk; clarify Datacenter Engineer vs "Data Plumbers" FDE roles; refresh Present story/tech slides to match. Co-authored-by: Cursor <cursoragent@cursor.com>
1832 lines
78 KiB
JavaScript
1832 lines
78 KiB
JavaScript
/* OME Cockpit — Reports / export / compliance drawer */
|
||
(function () {
|
||
const $ = (sel, root = document) => root.querySelector(sel);
|
||
const $$ = (sel, root = document) => [...root.querySelectorAll(sel)];
|
||
|
||
const state = {
|
||
tab: "analytics",
|
||
fleet: null,
|
||
compliance: null,
|
||
warranties: null,
|
||
reportDefs: null,
|
||
jobs: null,
|
||
catalogs: null,
|
||
brief: null,
|
||
outdatedOnly: true,
|
||
search: "",
|
||
hwDeviceId: null,
|
||
filters: {
|
||
model: "",
|
||
connected: "all", // all | yes | no
|
||
severity: "all", // all | critical | warning
|
||
component: "all", // all | BIOS | iDRAC | ...
|
||
warranty: "all", // all | expired | d30 | d90 | ok
|
||
category: "",
|
||
},
|
||
};
|
||
|
||
const TAB_HELP = {
|
||
analytics: "Live KPIs for fleet health, Dell catalog compliance and warranty. Click a KPI to jump to a filtered view.",
|
||
hardware: "Current physical inventory (OME) vs separate Dell max-expansion recommendations. RDP IPs resolved from OS hostname when available.",
|
||
fleet: "Full inventory matrix with Service Tags. Filter by model and connection, then export CSV/JSON.",
|
||
firmware: "Current vs Dell catalog versions (BIOS/iDRAC/…). Filter by component and severity; open Dell driver links.",
|
||
warranty: "OME warranty records. Filter expired / expiring soon for customer attention.",
|
||
ome: "Native OME ReportDefs, catalogs, baselines and recent jobs. Run a report or fetch results.",
|
||
brief: "Printable customer one-pager with Service Tag appendix. Use Print → PDF for handoff.",
|
||
};
|
||
|
||
function escapeHtml(s) {
|
||
return String(s ?? "")
|
||
.replace(/&/g, "&")
|
||
.replace(/</g, "<")
|
||
.replace(/>/g, ">")
|
||
.replace(/"/g, """);
|
||
}
|
||
|
||
function escapeAttr(s) {
|
||
return escapeHtml(s).replace(/'/g, "'");
|
||
}
|
||
|
||
function fmtDate(v) {
|
||
if (!v) return "—";
|
||
return String(v).replace(/\.\d+$/, "").slice(0, 19);
|
||
}
|
||
|
||
function badge(status) {
|
||
const s = String(status || "").toLowerCase();
|
||
let cls = "unk";
|
||
if (s.includes("critical") || s === "outdated" || s.includes("non-compliant")) cls = "bad";
|
||
else if (s.includes("warning")) cls = "warn";
|
||
else if (s.includes("normal") || s === "current" || s.includes("compliant") || s === "ok") cls = "ok";
|
||
return `<span class="rpt-badge ${cls}">${escapeHtml(status || "—")}</span>`;
|
||
}
|
||
|
||
function downloadUrl(kind, fmt) {
|
||
const q = new URLSearchParams({ fmt });
|
||
if (state.outdatedOnly && kind === "firmware") q.set("force", "0");
|
||
return `/api/export/${kind}?${q.toString()}`;
|
||
}
|
||
|
||
async function apiGet(url) {
|
||
const r = await fetch(url);
|
||
if (!r.ok) throw new Error(`${r.status} ${await r.text()}`);
|
||
return r.json();
|
||
}
|
||
|
||
function openReports() {
|
||
const drawer = $("#reports-drawer");
|
||
const scrim = $("#scrim");
|
||
if (!drawer) return;
|
||
["#chat-drawer", "#ops-drawer", "#ai-drawer", "#network-drawer", "#console-drawer", "#team-drawer"].forEach((id) => {
|
||
const el = $(id);
|
||
if (el) {
|
||
el.classList.remove("open");
|
||
el.setAttribute("aria-hidden", "true");
|
||
}
|
||
});
|
||
drawer.classList.add("open");
|
||
drawer.setAttribute("aria-hidden", "false");
|
||
if (scrim) {
|
||
scrim.classList.add("open");
|
||
scrim.dataset.mode = "reports-drawer";
|
||
}
|
||
loadTab(state.tab);
|
||
}
|
||
|
||
function closeReports() {
|
||
const drawer = $("#reports-drawer");
|
||
const scrim = $("#scrim");
|
||
drawer?.classList.remove("open");
|
||
drawer?.setAttribute("aria-hidden", "true");
|
||
if (scrim?.dataset.mode === "reports-drawer") {
|
||
scrim.classList.remove("open");
|
||
delete scrim.dataset.mode;
|
||
}
|
||
}
|
||
|
||
async function loadTab(tab) {
|
||
state.tab = tab;
|
||
$$("#reports-tabs .chip").forEach((c) => c.classList.toggle("active", c.dataset.tab === tab));
|
||
const mount = $("#reports-mount");
|
||
if (!mount) return;
|
||
mount.innerHTML = `<p class="hint">Loading ${escapeHtml(tab)}…</p>`;
|
||
try {
|
||
if (tab === "analytics") {
|
||
const [fleet, compliance, warranties, brief] = await Promise.all([
|
||
apiGet("/api/reports/fleet"),
|
||
apiGet("/api/compliance"),
|
||
apiGet("/api/warranties"),
|
||
apiGet("/api/reports/brief"),
|
||
]);
|
||
state.fleet = fleet;
|
||
state.compliance = compliance;
|
||
state.warranties = warranties;
|
||
state.brief = brief;
|
||
renderAnalytics(mount);
|
||
} else if (tab === "hardware") {
|
||
if (!state.fleet) state.fleet = await apiGet("/api/reports/fleet");
|
||
renderHardware(mount);
|
||
} else if (tab === "fleet") {
|
||
state.fleet = await apiGet("/api/reports/fleet");
|
||
renderFleet(mount);
|
||
} else if (tab === "firmware") {
|
||
state.compliance = await apiGet("/api/compliance");
|
||
renderFirmware(mount);
|
||
} else if (tab === "warranty") {
|
||
state.warranties = await apiGet("/api/warranties");
|
||
renderWarranty(mount);
|
||
} else if (tab === "ome") {
|
||
const [defs, jobs, catalogs, baselines] = await Promise.all([
|
||
apiGet("/api/ome/report-defs"),
|
||
apiGet("/api/ome/jobs"),
|
||
apiGet("/api/catalogs"),
|
||
apiGet("/api/baselines"),
|
||
]);
|
||
state.reportDefs = defs;
|
||
state.jobs = jobs;
|
||
state.catalogs = catalogs;
|
||
renderOme(mount, baselines);
|
||
} else if (tab === "brief") {
|
||
state.brief = await apiGet("/api/reports/brief");
|
||
renderBrief(mount);
|
||
}
|
||
} catch (err) {
|
||
mount.innerHTML = `<p class="hint">Failed: ${escapeHtml(err.message)}</p>`;
|
||
}
|
||
}
|
||
|
||
function uniqueModels(rows) {
|
||
return [...new Set((rows || []).map((r) => r.model).filter(Boolean))].sort((a, b) => a.localeCompare(b));
|
||
}
|
||
|
||
function applyBaseFilters(rows, opts = {}) {
|
||
const f = state.filters;
|
||
return (rows || []).filter((r) => {
|
||
if (f.model && r.model !== f.model) return false;
|
||
if (f.connected === "yes" && !r.connected) return false;
|
||
if (f.connected === "no" && r.connected) return false;
|
||
if (opts.searchFields && !matchSearch(r, opts.searchFields)) return false;
|
||
return true;
|
||
});
|
||
}
|
||
|
||
function filterBar(opts = {}) {
|
||
const models = opts.models || [];
|
||
const showConnected = opts.connected !== false;
|
||
const showSeverity = !!opts.severity;
|
||
const showComponent = !!opts.componentOptions;
|
||
const showWarranty = !!opts.warranty;
|
||
const showDeviceSelect = !!opts.deviceSelect;
|
||
const devices = opts.deviceSelect || [];
|
||
const help = TAB_HELP[state.tab] || "";
|
||
|
||
return `
|
||
<div class="rpt-sticky">
|
||
<div class="rpt-context">${escapeHtml(help)}</div>
|
||
<div class="rpt-toolbar">
|
||
<input type="search" id="rpt-search" class="search" placeholder="${escapeHtml(opts.searchPlaceholder || "Filter name, service tag, IP, model…")}" value="${escapeHtml(state.search)}" title="Type to filter the list instantly" />
|
||
<div class="rpt-exports">
|
||
${opts.exports || ""}
|
||
<button type="button" class="btn ghost compact" id="rpt-print" title="Open browser print dialog — choose Save as PDF for a customer handout">Print / PDF</button>
|
||
<button type="button" class="btn ghost compact" id="rpt-clear-filters" title="Reset search and all filters on this tab">Clear filters</button>
|
||
</div>
|
||
</div>
|
||
<div class="rpt-filters" role="group" aria-label="Report filters">
|
||
${
|
||
showDeviceSelect
|
||
? `<label class="rpt-f" title="Jump directly to a server without scrolling the list">
|
||
Server
|
||
<select id="flt-device">
|
||
<option value="">Select server…</option>
|
||
${devices
|
||
.map(
|
||
(d) =>
|
||
`<option value="${d.id}" ${Number(state.hwDeviceId) === Number(d.id) ? "selected" : ""}>${escapeHtml((d.service_tag || "—") + " · " + (d.name || "").slice(0, 40))}</option>`
|
||
)
|
||
.join("")}
|
||
</select>
|
||
</label>`
|
||
: ""
|
||
}
|
||
<label class="rpt-f" title="Limit results to one PowerEdge / chassis model">
|
||
Model
|
||
<select id="flt-model">
|
||
<option value="">All models</option>
|
||
${models.map((m) => `<option value="${escapeHtml(m)}" ${state.filters.model === m ? "selected" : ""}>${escapeHtml(m)}</option>`).join("")}
|
||
</select>
|
||
</label>
|
||
${
|
||
showConnected
|
||
? `<label class="rpt-f" title="Show only OME-connected or offline nodes">
|
||
Connection
|
||
<select id="flt-connected">
|
||
<option value="all" ${state.filters.connected === "all" ? "selected" : ""}>All</option>
|
||
<option value="yes" ${state.filters.connected === "yes" ? "selected" : ""}>Connected</option>
|
||
<option value="no" ${state.filters.connected === "no" ? "selected" : ""}>Offline</option>
|
||
</select>
|
||
</label>`
|
||
: ""
|
||
}
|
||
${
|
||
showSeverity
|
||
? `<label class="rpt-f" title="Filter Dell catalog compliance severity">
|
||
Severity
|
||
<select id="flt-severity">
|
||
<option value="all" ${state.filters.severity === "all" ? "selected" : ""}>All</option>
|
||
<option value="critical" ${state.filters.severity === "critical" ? "selected" : ""}>Critical</option>
|
||
<option value="warning" ${state.filters.severity === "warning" ? "selected" : ""}>Warning</option>
|
||
</select>
|
||
</label>`
|
||
: ""
|
||
}
|
||
${
|
||
showComponent
|
||
? `<label class="rpt-f" title="Show only one firmware family (BIOS, iDRAC, NIC, …)">
|
||
Component
|
||
<select id="flt-component">
|
||
<option value="all">All components</option>
|
||
${(opts.componentOptions || [])
|
||
.map(
|
||
(c) =>
|
||
`<option value="${escapeHtml(c)}" ${state.filters.component === c ? "selected" : ""}>${escapeHtml(c)}</option>`
|
||
)
|
||
.join("")}
|
||
</select>
|
||
</label>`
|
||
: ""
|
||
}
|
||
${
|
||
showWarranty
|
||
? `<label class="rpt-f" title="Focus warranties that need attention">
|
||
Warranty
|
||
<select id="flt-warranty">
|
||
<option value="all" ${state.filters.warranty === "all" ? "selected" : ""}>All</option>
|
||
<option value="expired" ${state.filters.warranty === "expired" ? "selected" : ""}>Expired</option>
|
||
<option value="d30" ${state.filters.warranty === "d30" ? "selected" : ""}>≤ 30 days</option>
|
||
<option value="d90" ${state.filters.warranty === "d90" ? "selected" : ""}>31–90 days</option>
|
||
<option value="ok" ${state.filters.warranty === "ok" ? "selected" : ""}>> 90 days</option>
|
||
</select>
|
||
</label>`
|
||
: ""
|
||
}
|
||
${
|
||
opts.outdatedToggle
|
||
? `<label class="rpt-check" title="Hide components that already match the Dell catalog">
|
||
<input type="checkbox" id="rpt-outdated-only" ${state.outdatedOnly ? "checked" : ""}/> Outdated only
|
||
</label>`
|
||
: ""
|
||
}
|
||
</div>
|
||
<div class="rpt-quick" id="rpt-quick">
|
||
${(opts.quick || [])
|
||
.map(
|
||
(q) =>
|
||
`<button type="button" class="chip ${q.active ? "active" : ""}" data-quick="${escapeHtml(q.id)}" title="${escapeHtml(q.title || q.label)}">${escapeHtml(q.label)}</button>`
|
||
)
|
||
.join("")}
|
||
</div>
|
||
</div>`;
|
||
}
|
||
|
||
function wireFilters(mount, rerender) {
|
||
const sync = () => {
|
||
state.search = $("#rpt-search")?.value || "";
|
||
state.filters.model = $("#flt-model")?.value || "";
|
||
state.filters.connected = $("#flt-connected")?.value || "all";
|
||
state.filters.severity = $("#flt-severity")?.value || "all";
|
||
state.filters.component = $("#flt-component")?.value || "all";
|
||
state.filters.warranty = $("#flt-warranty")?.value || "all";
|
||
const od = $("#rpt-outdated-only");
|
||
if (od) state.outdatedOnly = od.checked;
|
||
rerender();
|
||
};
|
||
$("#rpt-search")?.addEventListener("input", sync);
|
||
["flt-model", "flt-connected", "flt-severity", "flt-component", "flt-warranty", "rpt-outdated-only"].forEach((id) => {
|
||
$(`#${id}`)?.addEventListener("change", sync);
|
||
});
|
||
$("#flt-device")?.addEventListener("change", (e) => {
|
||
const id = Number(e.target.value);
|
||
if (!id) return;
|
||
state.hwDeviceId = id;
|
||
rerender();
|
||
});
|
||
$("#rpt-clear-filters")?.addEventListener("click", () => {
|
||
state.search = "";
|
||
state.filters = { model: "", connected: "all", severity: "all", component: "all", warranty: "all", category: "" };
|
||
state.outdatedOnly = state.tab === "firmware";
|
||
rerender();
|
||
});
|
||
$("#rpt-print")?.addEventListener("click", () => window.print());
|
||
$("#rpt-quick")?.addEventListener("click", (e) => {
|
||
const btn = e.target.closest("[data-quick]");
|
||
if (!btn) return;
|
||
const id = btn.dataset.quick;
|
||
if (id && id.startsWith("cat:")) {
|
||
state.filters.category = id.slice(4);
|
||
rerender();
|
||
return;
|
||
}
|
||
if (id === "connected") state.filters.connected = "yes";
|
||
if (id === "offline") state.filters.connected = "no";
|
||
if (id === "outdated") {
|
||
state.outdatedOnly = true;
|
||
state.filters.severity = "critical";
|
||
if (state.tab !== "firmware") loadTab("firmware");
|
||
else rerender();
|
||
return;
|
||
}
|
||
if (id === "warranty-risk") {
|
||
state.filters.warranty = "expired";
|
||
if (state.tab !== "warranty") loadTab("warranty");
|
||
else rerender();
|
||
return;
|
||
}
|
||
if (id === "all") {
|
||
state.filters.connected = "all";
|
||
state.filters.severity = "all";
|
||
state.filters.warranty = "all";
|
||
state.filters.model = "";
|
||
state.filters.category = "";
|
||
state.filters.component = "all";
|
||
state.search = "";
|
||
}
|
||
if (id === "bios") state.filters.component = "BIOS";
|
||
if (id === "idrac") state.filters.component = "iDRAC";
|
||
rerender();
|
||
});
|
||
}
|
||
|
||
function toolbar(exports) {
|
||
return filterBar({ exports, models: uniqueModels(state.fleet?.rows || state.compliance?.devices || []) });
|
||
}
|
||
|
||
function matchSearch(row, fields) {
|
||
const q = state.search.trim().toLowerCase();
|
||
if (!q) return true;
|
||
return fields.some((f) => String(row[f] ?? "").toLowerCase().includes(q));
|
||
}
|
||
|
||
function countBy(items, keyFn) {
|
||
const map = new Map();
|
||
for (const item of items) {
|
||
const k = keyFn(item) || "Unknown";
|
||
map.set(k, (map.get(k) || 0) + 1);
|
||
}
|
||
return [...map.entries()].sort((a, b) => b[1] - a[1]);
|
||
}
|
||
|
||
function barRows(entries, maxN, fillClass, kind) {
|
||
const top = entries.slice(0, maxN);
|
||
const max = Math.max(1, ...top.map(([, n]) => n));
|
||
return top
|
||
.map(
|
||
([label, n]) => `<button type="button" class="an-bar-row an-clickable" data-an-kind="${escapeHtml(kind || "bar")}" data-an-key="${escapeHtml(label)}" data-an-count="${n}" title="Open context for ${escapeHtml(label)}">
|
||
<div class="an-bar-label">${escapeHtml(label)}</div>
|
||
<div class="an-bar-track"><div class="an-bar-fill ${fillClass || ""}" style="width:${Math.round((n / max) * 100)}%"></div></div>
|
||
<div class="an-bar-val">${n}</div>
|
||
</button>`
|
||
)
|
||
.join("");
|
||
}
|
||
|
||
function donut(slices, chartId) {
|
||
const total = slices.reduce((s, x) => s + x.value, 0) || 1;
|
||
let acc = 0;
|
||
const parts = [];
|
||
const meta = [];
|
||
slices.forEach((s) => {
|
||
const start = (acc / total) * 100;
|
||
acc += s.value;
|
||
const end = (acc / total) * 100;
|
||
parts.push(`${s.color} ${start}% ${end}%`);
|
||
meta.push({
|
||
key: s.key || s.label,
|
||
label: s.label,
|
||
value: s.value,
|
||
color: s.color,
|
||
startPct: start,
|
||
endPct: end,
|
||
});
|
||
});
|
||
const legend = meta
|
||
.map(
|
||
(s) =>
|
||
`<li><button type="button" class="an-legend-btn an-clickable" data-an-kind="${escapeHtml(chartId || "donut")}" data-an-key="${escapeHtml(s.key)}" data-an-count="${s.value}" title="Open context: ${escapeHtml(s.label)}">
|
||
<span class="an-swatch" style="background:${s.color}"></span>
|
||
<span class="an-legend-text">${escapeHtml(s.label)}</span>
|
||
<strong>${s.value}</strong>
|
||
</button></li>`
|
||
)
|
||
.join("");
|
||
return `<div class="an-donut-wrap" data-an-chart="${escapeHtml(chartId || "")}" data-an-meta="${encodeURIComponent(JSON.stringify(meta))}">
|
||
<button type="button" class="an-donut an-donut-hit an-clickable" data-an-kind="${escapeHtml(chartId || "donut")}" data-an-donut="1" style="--slices:${parts.join(", ")}" title="Click a colored segment for context" aria-label="Chart ${escapeHtml(chartId || "")}"></button>
|
||
<ul class="an-legend">${legend}</ul>
|
||
</div>`;
|
||
}
|
||
|
||
function closeAnContext() {
|
||
const modal = $("#an-context-modal");
|
||
if (!modal) return;
|
||
modal.classList.add("hidden");
|
||
modal.setAttribute("aria-hidden", "true");
|
||
const scrim = $("#scrim");
|
||
if (scrim?.dataset.mode === "an-context") {
|
||
// keep reports drawer open underneath
|
||
if ($("#reports-drawer")?.classList.contains("open")) {
|
||
scrim.dataset.mode = "reports-drawer";
|
||
scrim.classList.add("open");
|
||
} else {
|
||
scrim.classList.remove("open");
|
||
delete scrim.dataset.mode;
|
||
}
|
||
}
|
||
}
|
||
|
||
function openAnContext({ title, sub, pills, rows, actions }) {
|
||
const modal = $("#an-context-modal");
|
||
if (!modal) return;
|
||
$("#an-ctx-title").textContent = title || "Context";
|
||
$("#an-ctx-sub").textContent = sub || "";
|
||
$("#an-ctx-pills").innerHTML = (pills || [])
|
||
.map((p) => `<span class="pill">${escapeHtml(p)}</span>`)
|
||
.join("");
|
||
const list = $("#an-ctx-list");
|
||
if (!rows?.length) {
|
||
list.innerHTML = `<p class="hint">No matching records in the current OME snapshot.</p>`;
|
||
} else {
|
||
list.innerHTML = `
|
||
<table class="an-ctx-table">
|
||
<thead><tr>${(rows[0].cols || []).map((c) => `<th>${escapeHtml(c)}</th>`).join("")}</tr></thead>
|
||
<tbody>
|
||
${rows
|
||
.map((r) => {
|
||
const cells = (r.cells || []).map((c) => `<td>${c}</td>`).join("");
|
||
const attr = r.jumpHw ? ` class="an-row-click" data-jump-hw="${r.jumpHw}"` : "";
|
||
return `<tr${attr}>${cells}</tr>`;
|
||
})
|
||
.join("")}
|
||
</tbody>
|
||
</table>
|
||
${rows.length >= 80 ? `<p class="an-note">Showing first ${rows.length} rows.</p>` : ""}`;
|
||
}
|
||
$("#an-ctx-actions").innerHTML = (actions || [])
|
||
.map(
|
||
(a) =>
|
||
`<button type="button" class="btn ${a.primary ? "primary" : "compact"}" data-an-action="${escapeHtml(a.id)}">${escapeHtml(a.label)}</button>`
|
||
)
|
||
.join("");
|
||
modal.classList.remove("hidden");
|
||
modal.setAttribute("aria-hidden", "false");
|
||
const scrim = $("#scrim");
|
||
if (scrim) {
|
||
scrim.classList.add("open");
|
||
scrim.dataset.mode = "an-context";
|
||
}
|
||
$("#an-ctx-close")?.addEventListener("click", closeAnContext, { once: true });
|
||
$("#an-ctx-actions")?.querySelectorAll("[data-an-action]").forEach((btn) => {
|
||
btn.addEventListener("click", () => {
|
||
const id = btn.dataset.anAction;
|
||
const act = (actions || []).find((x) => x.id === id);
|
||
if (act?.run) act.run();
|
||
closeAnContext();
|
||
});
|
||
});
|
||
list.querySelectorAll("[data-jump-hw]").forEach((el) => {
|
||
el.addEventListener("click", () => {
|
||
const id = Number(el.dataset.jumpHw);
|
||
if (!id) return;
|
||
closeAnContext();
|
||
state.hwDeviceId = id;
|
||
loadTab("hardware");
|
||
});
|
||
});
|
||
}
|
||
|
||
function sliceFromDonutClick(btn, evt) {
|
||
const wrap = btn.closest(".an-donut-wrap");
|
||
let meta = [];
|
||
try {
|
||
meta = JSON.parse(decodeURIComponent(wrap?.dataset.anMeta || "%5B%5D"));
|
||
} catch (_) {
|
||
meta = [];
|
||
}
|
||
if (!meta.length) return null;
|
||
const rect = btn.getBoundingClientRect();
|
||
const cx = rect.left + rect.width / 2;
|
||
const cy = rect.top + rect.height / 2;
|
||
const dx = evt.clientX - cx;
|
||
const dy = evt.clientY - cy;
|
||
let deg = (Math.atan2(dx, -dy) * 180) / Math.PI;
|
||
if (deg < 0) deg += 360;
|
||
const pct = (deg / 360) * 100;
|
||
return meta.find((s) => pct >= s.startPct && pct < s.endPct) || meta[meta.length - 1];
|
||
}
|
||
|
||
function renderAnalytics(mount) {
|
||
const fleet = state.fleet?.rows || [];
|
||
const sum = state.fleet?.summary || state.brief?.summary || {};
|
||
const cs = state.compliance?.summary || {};
|
||
const comps = state.compliance?.components || [];
|
||
const devices = state.compliance?.devices || [];
|
||
const warranties = state.warranties?.items || [];
|
||
|
||
const connected = fleet.filter((d) => d.connected).length;
|
||
const offline = Math.max(0, fleet.length - connected);
|
||
const outdatedDevs = cs.outdated_devices ?? devices.filter((d) => String(d.compliance_status || "").toUpperCase() === "CRITICAL").length;
|
||
const critComps = comps.filter((c) => String(c.compliance_status || "").toUpperCase() === "CRITICAL").length;
|
||
const warnComps = comps.filter((c) => String(c.compliance_status || "").toUpperCase() === "WARNING").length;
|
||
const upgradeComps = comps.filter((c) => String(c.update_action || "").toUpperCase() === "UPGRADE").length;
|
||
|
||
const expired = warranties.filter((w) => intOr(w.days_remaining) <= 0).length;
|
||
const d30 = warranties.filter((w) => {
|
||
const d = intOr(w.days_remaining);
|
||
return d > 0 && d <= 30;
|
||
}).length;
|
||
const d90 = warranties.filter((w) => {
|
||
const d = intOr(w.days_remaining);
|
||
return d > 30 && d <= 90;
|
||
}).length;
|
||
const okW = warranties.filter((w) => intOr(w.days_remaining) > 90).length;
|
||
|
||
const byComponent = countBy(
|
||
comps.filter((c) => String(c.update_action || "").toUpperCase() === "UPGRADE"),
|
||
(c) => shortComponent(c.component)
|
||
);
|
||
const byModel = countBy(
|
||
devices.filter(
|
||
(d) =>
|
||
String(d.compliance_status || "").toUpperCase() === "CRITICAL" ||
|
||
String(d.firmware_status || "").toLowerCase().includes("non-compliant")
|
||
),
|
||
(d) => d.model || "Unknown"
|
||
);
|
||
const byStatus = countBy(comps, (c) => String(c.compliance_status || "UNKNOWN").toUpperCase());
|
||
|
||
const topOutdated = devices
|
||
.filter(
|
||
(d) =>
|
||
String(d.compliance_status || "").toUpperCase() === "CRITICAL" ||
|
||
String(d.firmware_status || "").toLowerCase().includes("non-compliant")
|
||
)
|
||
.slice(0, 8);
|
||
|
||
mount.innerHTML = `
|
||
${filterBar({
|
||
models: uniqueModels(fleet),
|
||
connected: true,
|
||
exports: `
|
||
<a class="btn compact" href="${downloadUrl("firmware", "csv")}" title="Download outdated/current firmware matrix as CSV">Firmware CSV</a>
|
||
<a class="btn compact" href="${downloadUrl("fleet", "csv")}" title="Download full fleet inventory CSV with Service Tags">Fleet CSV</a>
|
||
<button type="button" class="btn primary compact" id="an-refresh" title="Reload analytics from OME caches">Refresh</button>
|
||
`,
|
||
quick: [
|
||
{ id: "all", label: "All", title: "Clear connection focus" },
|
||
{ id: "connected", label: "Connected", title: "Jump to fleet · connected only", active: state.filters.connected === "yes" },
|
||
{ id: "offline", label: "Offline", title: "Jump to fleet · offline only", active: state.filters.connected === "no" },
|
||
{ id: "outdated", label: "Outdated firmware", title: "Open Firmware tab filtered to critical upgrades" },
|
||
{ id: "warranty-risk", label: "Warranty risk", title: "Open Warranty tab · expired" },
|
||
],
|
||
})}
|
||
<div class="rpt-meta">Live OME analytics · baseline <strong>${escapeHtml(cs.baseline_name || "—")}</strong> · last run ${escapeHtml(fmtDate(cs.last_run))}</div>
|
||
<div class="analytics-dash">
|
||
<div class="an-kpis">
|
||
<button type="button" class="an-kpi" data-jump="fleet" data-conn="all" title="Open Fleet inventory for all devices">
|
||
<em>${fleet.length || sum.total || 0}</em><span>Fleet devices</span>
|
||
</button>
|
||
<button type="button" class="an-kpi" data-jump="fleet" data-conn="yes" title="Show only OME-connected systems">
|
||
<em>${connected}</em><span>Connected</span>
|
||
</button>
|
||
<button type="button" class="an-kpi" data-jump="fleet" data-conn="no" title="Show offline / not connected systems">
|
||
<em>${offline}</em><span>Offline</span>
|
||
</button>
|
||
<button type="button" class="an-kpi danger" data-jump="firmware" data-sev="critical" title="Open Firmware / BIOS · critical Dell catalog gaps">
|
||
<em>${outdatedDevs}</em><span>Outdated vs Dell</span>
|
||
</button>
|
||
<button type="button" class="an-kpi danger" data-jump="firmware" data-sev="critical" title="Critical component upgrades against Dell Online Baseline">
|
||
<em>${critComps}</em><span>Critical components</span>
|
||
</button>
|
||
<button type="button" class="an-kpi warn" data-jump="warranty" data-war="expired" title="Warranties expired or ≤ 30 days">
|
||
<em>${d30 + expired}</em><span>Warranty ≤30d / expired</span>
|
||
</button>
|
||
</div>
|
||
|
||
<div class="an-grid">
|
||
<section class="an-card">
|
||
<h3 title="Share of component compliance statuses from the Dell Online Baseline">Compliance mix</h3>
|
||
${donut(
|
||
[
|
||
{ key: "CRITICAL", label: "Critical", value: critComps || Number(byStatus.find(([k]) => k === "CRITICAL")?.[1] || 0), color: "#ff5c5c" },
|
||
{ key: "WARNING", label: "Warning", value: warnComps || Number(byStatus.find(([k]) => k === "WARNING")?.[1] || 0), color: "#ffb84d" },
|
||
{
|
||
key: "OK",
|
||
label: "Other / OK",
|
||
value: Math.max(0, comps.length - critComps - warnComps),
|
||
color: "#3dffa0",
|
||
},
|
||
],
|
||
"compliance"
|
||
)}
|
||
<p class="an-note an-clickable" data-an-kind="compliance" data-an-key="UPGRADE" data-an-count="${upgradeComps}" title="Show all UPGRADE components">${upgradeComps} components marked UPGRADE against Dell catalog — click for list</p>
|
||
</section>
|
||
|
||
<section class="an-card">
|
||
<h3 title="Which firmware families need the most upgrades">Outdated by component</h3>
|
||
<div class="an-bars">${barRows(byComponent, 8, "crit", "component") || '<p class="an-note">No upgrade components</p>'}</div>
|
||
</section>
|
||
|
||
<section class="an-card">
|
||
<h3 title="Which server models appear most often in the non-compliant set">Outdated by model</h3>
|
||
<div class="an-bars">${barRows(byModel, 8, "warn", "model") || '<p class="an-note">No outdated models</p>'}</div>
|
||
</section>
|
||
|
||
<section class="an-card">
|
||
<h3 title="OME ConnectionState across the fleet snapshot">Connection state</h3>
|
||
${donut(
|
||
[
|
||
{ key: "connected", label: "Connected", value: connected, color: "#3dffa0" },
|
||
{ key: "offline", label: "Offline", value: offline, color: "#e53935" },
|
||
],
|
||
"connection"
|
||
)}
|
||
<p class="an-note">${escapeHtml(String(sum.total_watts != null ? Math.round(sum.total_watts) + " W sampled" : "Power samples from live poll"))}</p>
|
||
</section>
|
||
|
||
<section class="an-card">
|
||
<h3 title="Days remaining buckets from OME WarrantyService">Warranty health</h3>
|
||
${donut(
|
||
[
|
||
{ key: "expired", label: "Expired", value: expired, color: "#ff5c5c" },
|
||
{ key: "d30", label: "≤ 30 days", value: d30, color: "#ffb84d" },
|
||
{ key: "d90", label: "31–90 days", value: d90, color: "#ff9a3c" },
|
||
{ key: "ok", label: "> 90 days", value: okW, color: "#3dffa0" },
|
||
],
|
||
"warranty"
|
||
)}
|
||
<p class="an-note">${warranties.length} warranty records from OME</p>
|
||
</section>
|
||
|
||
<section class="an-card">
|
||
<h3 title="Highest-priority systems from the Dell baseline report">Top outdated systems</h3>
|
||
<table class="an-table-mini">
|
||
<thead><tr><th>Service Tag</th><th>Device</th><th>Status</th></tr></thead>
|
||
<tbody>
|
||
${
|
||
topOutdated
|
||
.map(
|
||
(d) => `<tr class="an-row-click" data-jump-hw="${d.device_id || ""}" title="Open Hardware / Expansion for this Service Tag">
|
||
<td><code class="st">${escapeHtml(d.service_tag || "—")}</code></td>
|
||
<td>${escapeHtml(d.name || "—")}<div class="sub">${escapeHtml(d.model || "")}</div></td>
|
||
<td>${badge(d.compliance_status || d.firmware_status)}</td>
|
||
</tr>`
|
||
)
|
||
.join("") || `<tr><td colspan="3">No outdated devices in baseline</td></tr>`
|
||
}
|
||
</tbody>
|
||
</table>
|
||
<p class="an-note"><button type="button" class="btn compact" id="an-goto-firmware" title="Open full firmware compliance table">Open Firmware / BIOS</button></p>
|
||
</section>
|
||
</div>
|
||
</div>`;
|
||
|
||
wireFilters(mount, () => {
|
||
if (state.filters.connected !== "all") {
|
||
loadTab("fleet");
|
||
return;
|
||
}
|
||
renderAnalytics(mount);
|
||
});
|
||
$("#an-refresh")?.addEventListener("click", () => loadTab("analytics"));
|
||
$("#an-goto-firmware")?.addEventListener("click", () => {
|
||
state.outdatedOnly = true;
|
||
state.filters.severity = "critical";
|
||
loadTab("firmware");
|
||
});
|
||
|
||
function ctxRowsFromComps(list) {
|
||
return list.slice(0, 80).map((c) => ({
|
||
cols: ["ST", "Device", "Component", "Current → Catalog", "Status"],
|
||
cells: [
|
||
`<code class="st">${escapeHtml(c.service_tag || "—")}</code>`,
|
||
escapeHtml(c.device_name || "—"),
|
||
escapeHtml(shortComponent(c.component)),
|
||
`<span class="mono">${escapeHtml(c.current_version || "?")} → ${escapeHtml(c.catalog_version || "?")}</span>`,
|
||
badge(c.compliance_status || c.update_action),
|
||
],
|
||
jumpHw: c.device_id || null,
|
||
}));
|
||
}
|
||
function ctxRowsFromDevices(list) {
|
||
return list.slice(0, 80).map((d) => ({
|
||
cols: ["ST", "Name", "Model", "Status"],
|
||
cells: [
|
||
`<code class="st">${escapeHtml(d.service_tag || "—")}</code>`,
|
||
escapeHtml(d.name || d.device_name || "—"),
|
||
escapeHtml(d.model || "—"),
|
||
badge(d.compliance_status || d.firmware_status || (d.connected ? "connected" : "offline")),
|
||
],
|
||
jumpHw: d.device_id || d.id || null,
|
||
}));
|
||
}
|
||
function ctxRowsFromFleet(list) {
|
||
return list.slice(0, 80).map((d) => ({
|
||
cols: ["ST", "Name", "Model", "iDRAC", "State"],
|
||
cells: [
|
||
`<code class="st">${escapeHtml(d.service_tag || "—")}</code>`,
|
||
escapeHtml(d.name || "—"),
|
||
escapeHtml(d.model || "—"),
|
||
`<span class="mono">${escapeHtml(d.idrac_ip || d.ip || "—")}</span>`,
|
||
d.connected
|
||
? `<span class="rpt-badge ok">online</span>`
|
||
: `<span class="rpt-badge bad">offline</span>`,
|
||
],
|
||
jumpHw: d.id || d.device_id || null,
|
||
}));
|
||
}
|
||
function ctxRowsFromWarranty(list) {
|
||
return list.slice(0, 80).map((w) => ({
|
||
cols: ["ST", "Device", "Level", "Ends", "Days left"],
|
||
cells: [
|
||
`<code class="st">${escapeHtml(w.service_tag || "—")}</code>`,
|
||
escapeHtml(w.device_name || "—"),
|
||
escapeHtml(w.service_level || "—"),
|
||
escapeHtml(w.end_date || "—"),
|
||
String(intOr(w.days_remaining)),
|
||
],
|
||
jumpHw: w.device_id || null,
|
||
}));
|
||
}
|
||
|
||
function handleAnContext(kind, key, count, evt) {
|
||
let title = key;
|
||
let sub = "";
|
||
let pills = [`${count ?? "?"} items`];
|
||
let rows = [];
|
||
let actions = [];
|
||
|
||
if (kind === "compliance") {
|
||
if (key === "UPGRADE") {
|
||
const list = comps.filter((c) => String(c.update_action || "").toUpperCase() === "UPGRADE");
|
||
title = "UPGRADE components";
|
||
sub = "Dell Online Baseline · update_action = UPGRADE";
|
||
rows = ctxRowsFromComps(list);
|
||
pills = [`${list.length} components`, `baseline ${cs.baseline_name || "—"}`];
|
||
actions = [
|
||
{
|
||
id: "fw",
|
||
label: "Open Firmware / BIOS",
|
||
primary: true,
|
||
run: () => {
|
||
state.outdatedOnly = true;
|
||
loadTab("firmware");
|
||
},
|
||
},
|
||
];
|
||
} else {
|
||
const list =
|
||
key === "OK"
|
||
? comps.filter((c) => {
|
||
const s = String(c.compliance_status || "").toUpperCase();
|
||
return s !== "CRITICAL" && s !== "WARNING";
|
||
})
|
||
: comps.filter((c) => String(c.compliance_status || "").toUpperCase() === key);
|
||
title = `Compliance · ${key}`;
|
||
sub = "Component compliance from Dell catalog baseline";
|
||
rows = ctxRowsFromComps(list);
|
||
pills = [`${list.length} components`, key];
|
||
actions = [
|
||
{
|
||
id: "fw",
|
||
label: "Open Firmware tab",
|
||
primary: true,
|
||
run: () => {
|
||
state.outdatedOnly = key !== "OK";
|
||
state.filters.severity = key === "CRITICAL" ? "critical" : key === "WARNING" ? "warning" : "all";
|
||
loadTab("firmware");
|
||
},
|
||
},
|
||
];
|
||
}
|
||
} else if (kind === "component") {
|
||
const list = comps.filter(
|
||
(c) =>
|
||
String(c.update_action || "").toUpperCase() === "UPGRADE" && shortComponent(c.component) === key
|
||
);
|
||
title = `Outdated · ${key}`;
|
||
sub = "Components marked UPGRADE in this firmware family";
|
||
rows = ctxRowsFromComps(list);
|
||
pills = [`${list.length} components`, key];
|
||
actions = [
|
||
{
|
||
id: "fw",
|
||
label: "Open Firmware filtered",
|
||
primary: true,
|
||
run: () => {
|
||
state.outdatedOnly = true;
|
||
state.filters.component = key;
|
||
loadTab("firmware");
|
||
},
|
||
},
|
||
];
|
||
} else if (kind === "model") {
|
||
const list = devices.filter(
|
||
(d) =>
|
||
(d.model || "Unknown") === key &&
|
||
(String(d.compliance_status || "").toUpperCase() === "CRITICAL" ||
|
||
String(d.firmware_status || "").toLowerCase().includes("non-compliant"))
|
||
);
|
||
title = `Outdated · ${key}`;
|
||
sub = "Non-compliant systems for this model";
|
||
rows = ctxRowsFromDevices(list);
|
||
pills = [`${list.length} systems`, key];
|
||
actions = [
|
||
{
|
||
id: "fw",
|
||
label: "Open Firmware / BIOS",
|
||
primary: true,
|
||
run: () => {
|
||
state.outdatedOnly = true;
|
||
state.filters.model = key;
|
||
loadTab("firmware");
|
||
},
|
||
},
|
||
];
|
||
} else if (kind === "connection") {
|
||
const list = fleet.filter((d) => (key === "connected" ? d.connected : !d.connected));
|
||
title = key === "connected" ? "Connected systems" : "Offline systems";
|
||
sub = "OME ConnectionState from live fleet snapshot";
|
||
rows = ctxRowsFromFleet(list);
|
||
pills = [`${list.length} devices`, key === "connected" ? "online" : "offline"];
|
||
actions = [
|
||
{
|
||
id: "fleet",
|
||
label: "Open Fleet inventory",
|
||
primary: true,
|
||
run: () => {
|
||
state.filters.connected = key === "connected" ? "yes" : "no";
|
||
loadTab("fleet");
|
||
},
|
||
},
|
||
];
|
||
} else if (kind === "warranty") {
|
||
const list = warranties.filter((w) => {
|
||
const d = intOr(w.days_remaining);
|
||
if (key === "expired") return d <= 0;
|
||
if (key === "d30") return d > 0 && d <= 30;
|
||
if (key === "d90") return d > 30 && d <= 90;
|
||
return d > 90;
|
||
});
|
||
const labels = { expired: "Expired", d30: "≤ 30 days", d90: "31–90 days", ok: "> 90 days" };
|
||
title = `Warranty · ${labels[key] || key}`;
|
||
sub = "OME WarrantyService days_remaining buckets";
|
||
rows = ctxRowsFromWarranty(list);
|
||
pills = [`${list.length} records`, labels[key] || key];
|
||
actions = [
|
||
{
|
||
id: "war",
|
||
label: "Open Warranty tab",
|
||
primary: true,
|
||
run: () => {
|
||
state.filters.warranty =
|
||
key === "expired" ? "expired" : key === "d30" ? "d30" : key === "d90" ? "d90" : "ok";
|
||
loadTab("warranty");
|
||
},
|
||
},
|
||
];
|
||
} else if (kind === "kpi") {
|
||
// handled separately
|
||
return;
|
||
} else {
|
||
title = `${kind} · ${key}`;
|
||
sub = "Analytics selection";
|
||
}
|
||
|
||
openAnContext({ title, sub, pills, rows, actions });
|
||
}
|
||
|
||
mount.querySelectorAll("[data-an-kind]").forEach((el) => {
|
||
el.addEventListener("click", (evt) => {
|
||
evt.preventDefault();
|
||
evt.stopPropagation();
|
||
let kind = el.dataset.anKind;
|
||
let key = el.dataset.anKey;
|
||
let count = el.dataset.anCount;
|
||
if (el.dataset.anDonut === "1") {
|
||
const hit = sliceFromDonutClick(el, evt);
|
||
if (!hit) return;
|
||
key = hit.key;
|
||
count = hit.value;
|
||
}
|
||
if (!key) return;
|
||
handleAnContext(kind, key, count, evt);
|
||
});
|
||
});
|
||
|
||
mount.querySelectorAll("[data-jump]").forEach((el) => {
|
||
el.addEventListener("click", (evt) => {
|
||
evt.preventDefault();
|
||
const jump = el.dataset.jump;
|
||
// KPI → context popup first
|
||
if (el.classList.contains("an-kpi")) {
|
||
let kind = "connection";
|
||
let key = "connected";
|
||
if (el.dataset.conn === "all") {
|
||
openAnContext({
|
||
title: "Fleet devices",
|
||
sub: "Full OME fleet snapshot",
|
||
pills: [`${fleet.length} devices`, `${connected} online`, `${offline} offline`],
|
||
rows: ctxRowsFromFleet(fleet),
|
||
actions: [
|
||
{
|
||
id: "fleet",
|
||
label: "Open Fleet inventory",
|
||
primary: true,
|
||
run: () => {
|
||
state.filters.connected = "all";
|
||
loadTab("fleet");
|
||
},
|
||
},
|
||
],
|
||
});
|
||
return;
|
||
}
|
||
if (el.dataset.conn === "yes") {
|
||
handleAnContext("connection", "connected", connected);
|
||
return;
|
||
}
|
||
if (el.dataset.conn === "no") {
|
||
handleAnContext("connection", "offline", offline);
|
||
return;
|
||
}
|
||
if (el.dataset.sev === "critical") {
|
||
const label = (el.querySelector("span")?.textContent || "").toLowerCase();
|
||
if (label.includes("component")) {
|
||
handleAnContext("compliance", "CRITICAL", critComps);
|
||
} else {
|
||
const list = devices.filter(
|
||
(d) =>
|
||
String(d.compliance_status || "").toUpperCase() === "CRITICAL" ||
|
||
String(d.firmware_status || "").toLowerCase().includes("non-compliant")
|
||
);
|
||
openAnContext({
|
||
title: "Outdated vs Dell",
|
||
sub: "Systems non-compliant against Dell Online Baseline",
|
||
pills: [`${list.length} systems`, `baseline ${cs.baseline_name || "—"}`],
|
||
rows: ctxRowsFromDevices(list),
|
||
actions: [
|
||
{
|
||
id: "fw",
|
||
label: "Open Firmware / BIOS",
|
||
primary: true,
|
||
run: () => {
|
||
state.outdatedOnly = true;
|
||
state.filters.severity = "critical";
|
||
loadTab("firmware");
|
||
},
|
||
},
|
||
],
|
||
});
|
||
}
|
||
return;
|
||
}
|
||
if (el.dataset.war) {
|
||
const list = warranties.filter((w) => {
|
||
const d = intOr(w.days_remaining);
|
||
return d <= 30;
|
||
});
|
||
openAnContext({
|
||
title: "Warranty risk (≤30d / expired)",
|
||
sub: "OME warranty records needing attention",
|
||
pills: [`${list.length} records`, `${expired} expired`, `${d30} ≤30d`],
|
||
rows: ctxRowsFromWarranty(list),
|
||
actions: [
|
||
{
|
||
id: "war",
|
||
label: "Open Warranty tab",
|
||
primary: true,
|
||
run: () => {
|
||
state.filters.warranty = "expired";
|
||
loadTab("warranty");
|
||
},
|
||
},
|
||
],
|
||
});
|
||
return;
|
||
}
|
||
}
|
||
if (el.dataset.conn) state.filters.connected = el.dataset.conn;
|
||
if (el.dataset.sev) {
|
||
state.filters.severity = el.dataset.sev;
|
||
state.outdatedOnly = true;
|
||
}
|
||
if (el.dataset.war) state.filters.warranty = el.dataset.war;
|
||
loadTab(jump);
|
||
});
|
||
});
|
||
mount.querySelectorAll("[data-jump-hw]").forEach((el) => {
|
||
el.addEventListener("click", () => {
|
||
const id = Number(el.dataset.jumpHw);
|
||
if (!id) return;
|
||
state.hwDeviceId = id;
|
||
loadTab("hardware");
|
||
});
|
||
});
|
||
}
|
||
|
||
function intOr(v) {
|
||
const n = Number(v);
|
||
return Number.isFinite(n) ? n : 0;
|
||
}
|
||
|
||
function shortComponent(name) {
|
||
const s = String(name || "component");
|
||
if (/bios/i.test(s)) return "BIOS";
|
||
if (/idrac/i.test(s)) return "iDRAC";
|
||
if (/boss/i.test(s)) return "BOSS";
|
||
if (/cpld/i.test(s)) return "CPLD";
|
||
if (/nic|network|broadcom|mellanox|intel/i.test(s)) return "NIC / Network";
|
||
if (/perc|raid|storage/i.test(s)) return "RAID / Storage";
|
||
if (/openmanage|ome/i.test(s)) return "OME / Modular";
|
||
return s.length > 28 ? s.slice(0, 26) + "…" : s;
|
||
}
|
||
|
||
function renderHardware(mount) {
|
||
const all = (state.fleet?.rows || []).filter(
|
||
(r) => r.is_server || String(r.model || "").toLowerCase().includes("poweredge")
|
||
);
|
||
const rows = applyBaseFilters(all, { searchFields: ["name", "service_tag", "ip", "model"] });
|
||
const selectedId = state.hwDeviceId || rows[0]?.id || all[0]?.id;
|
||
if (selectedId) state.hwDeviceId = selectedId;
|
||
|
||
mount.innerHTML = `
|
||
${filterBar({
|
||
models: uniqueModels(all),
|
||
connected: true,
|
||
deviceSelect: rows.length ? rows : all,
|
||
searchPlaceholder: "Filter server list…",
|
||
exports: `<button type="button" class="btn compact" id="hw-reload" title="Reload DIMM/disk/PCIe inventory from OME for the selected server">Reload hardware</button>`,
|
||
quick: [
|
||
{ id: "all", label: "All servers", title: "Clear filters" },
|
||
{ id: "connected", label: "Connected", title: "Only connected", active: state.filters.connected === "yes" },
|
||
{ id: "offline", label: "Offline", title: "Only offline", active: state.filters.connected === "no" },
|
||
],
|
||
})}
|
||
<div class="hw-layout">
|
||
<aside class="hw-list">
|
||
<h3>${rows.length} servers</h3>
|
||
<div class="hw-list-scroll">
|
||
${rows
|
||
.map(
|
||
(r) => `<button type="button" class="hw-item ${Number(r.id) === Number(selectedId) ? "active" : ""}" data-hw-id="${r.id}" title="Show DIMM map & expansion for ${escapeHtml(r.service_tag || r.name || "")}">
|
||
<strong>${escapeHtml(r.name || "—")}</strong>
|
||
<span><code class="st">${escapeHtml(r.service_tag || "—")}</code> · ${escapeHtml(r.model || "")}</span>
|
||
</button>`
|
||
)
|
||
.join("") || "<p class='hint'>No servers match filters — Clear filters or pick another model.</p>"}
|
||
</div>
|
||
</aside>
|
||
<section class="hw-detail" id="hw-detail">
|
||
<p class="hint">Select a server above or use the Server dropdown…</p>
|
||
</section>
|
||
</div>`;
|
||
|
||
wireFilters(mount, () => renderHardware(mount));
|
||
$("#hw-reload")?.addEventListener("click", () => {
|
||
if (state.hwDeviceId) loadHardwareDetail(state.hwDeviceId, true);
|
||
});
|
||
mount.querySelectorAll("[data-hw-id]").forEach((btn) => {
|
||
btn.addEventListener("click", () => {
|
||
state.hwDeviceId = Number(btn.dataset.hwId);
|
||
mount.querySelectorAll(".hw-item").forEach((el) => el.classList.toggle("active", Number(el.dataset.hwId) === state.hwDeviceId));
|
||
const sel = $("#flt-device");
|
||
if (sel) sel.value = String(state.hwDeviceId);
|
||
loadHardwareDetail(state.hwDeviceId);
|
||
});
|
||
});
|
||
if (selectedId) loadHardwareDetail(selectedId);
|
||
}
|
||
|
||
async function loadHardwareDetail(deviceId, force) {
|
||
const el = $("#hw-detail");
|
||
if (!el) return;
|
||
|
||
const liveHint = (state.fleet?.rows || []).find((r) => Number(r.id) === Number(deviceId));
|
||
const label = liveHint
|
||
? `${liveHint.service_tag || "—"} · ${liveHint.name || ""}`
|
||
: `OME #${deviceId}`;
|
||
const steps = [
|
||
"Connecting to OpenManage Enterprise…",
|
||
"Reading processors & memory map…",
|
||
"Reading disks, RAID & adapters…",
|
||
"Reading OS / network inventory…",
|
||
"Building current state vs Dell catalog…",
|
||
];
|
||
let stepIdx = 0;
|
||
const t0 = performance.now();
|
||
let timer = null;
|
||
let stepTimer = null;
|
||
|
||
function paintLoading() {
|
||
const sec = Math.max(0, Math.floor((performance.now() - t0) / 1000));
|
||
el.innerHTML = `
|
||
<div class="hw-wait" role="status" aria-live="polite" aria-busy="true">
|
||
<div class="hw-wait-visual">
|
||
<div class="hw-wait-orbit" aria-hidden="true">
|
||
<span class="hw-wait-core">OME</span>
|
||
<span class="hw-wait-ring"></span>
|
||
<span class="hw-wait-dot"></span>
|
||
</div>
|
||
<div class="hw-wait-bars" aria-hidden="true">
|
||
<i></i><i></i><i></i><i></i><i></i>
|
||
</div>
|
||
</div>
|
||
<div class="hw-wait-copy">
|
||
<h3>Fetching hardware from OME</h3>
|
||
<p class="hw-wait-target">${escapeHtml(label)}</p>
|
||
<p class="hw-wait-step" id="hw-wait-step">${escapeHtml(steps[stepIdx])}</p>
|
||
<p class="hw-wait-time">Waiting <strong id="hw-wait-sec">${sec}</strong>s — full inventory can take up to a minute</p>
|
||
<ul class="hw-wait-checklist">
|
||
<li class="${stepIdx >= 0 ? "on" : ""}">Session</li>
|
||
<li class="${stepIdx >= 1 ? "on" : ""}">CPU / RAM</li>
|
||
<li class="${stepIdx >= 2 ? "on" : ""}">Disks</li>
|
||
<li class="${stepIdx >= 3 ? "on" : ""}">OS / NIC</li>
|
||
<li class="${stepIdx >= 4 ? "on" : ""}">Catalog</li>
|
||
</ul>
|
||
</div>
|
||
</div>`;
|
||
}
|
||
|
||
paintLoading();
|
||
timer = setInterval(() => {
|
||
const secEl = el.querySelector("#hw-wait-sec");
|
||
if (secEl) secEl.textContent = String(Math.max(0, Math.floor((performance.now() - t0) / 1000)));
|
||
}, 250);
|
||
stepTimer = setInterval(() => {
|
||
stepIdx = Math.min(steps.length - 1, stepIdx + 1);
|
||
const stepEl = el.querySelector("#hw-wait-step");
|
||
if (stepEl) stepEl.textContent = steps[stepIdx];
|
||
el.querySelectorAll(".hw-wait-checklist li").forEach((li, i) => {
|
||
li.classList.toggle("on", i <= stepIdx);
|
||
});
|
||
}, 4500);
|
||
|
||
try {
|
||
const data = await apiGet(`/api/devices/${deviceId}/expansion${force ? "?force=true" : ""}`);
|
||
// Prefer live fleet node (has DNS-resolved RDP IPs) over detail snapshot
|
||
const live = (state.fleet?.rows || []).find((r) => Number(r.id) === Number(deviceId));
|
||
if (live && data.device) {
|
||
data.device = {
|
||
...data.device,
|
||
rdp_host: live.rdp_host || data.device.rdp_host,
|
||
rdp_ips: live.rdp_ips || data.device.rdp_ips || [],
|
||
os_hostname: live.os_hostname || data.device.os_hostname,
|
||
idrac_ip: live.idrac_ip || data.device.idrac_ip || data.device.ip,
|
||
is_windows: live.is_windows || data.device.is_windows,
|
||
};
|
||
}
|
||
el.innerHTML = renderExpansionHtml(data);
|
||
el.querySelector("#hw-rdp-btn")?.addEventListener("click", () => {
|
||
const host = el.querySelector("#hw-rdp-btn")?.dataset?.rdpHost;
|
||
window.cockpitRdp?.open({
|
||
...(data.device || {}),
|
||
ip: host || data.device?.rdp_host || data.device?.ip,
|
||
host: host || data.device?.rdp_host || data.device?.ip,
|
||
});
|
||
});
|
||
} catch (err) {
|
||
el.innerHTML = `
|
||
<div class="hw-wait hw-wait-error" role="alert">
|
||
<h3>Could not load hardware from OME</h3>
|
||
<p>${escapeHtml(err.message)}</p>
|
||
<button type="button" class="btn" id="hw-wait-retry">Retry</button>
|
||
</div>`;
|
||
el.querySelector("#hw-wait-retry")?.addEventListener("click", () => loadHardwareDetail(deviceId, true));
|
||
} finally {
|
||
clearInterval(timer);
|
||
clearInterval(stepTimer);
|
||
}
|
||
}
|
||
|
||
function renderExpansionHtml(data) {
|
||
const d = data.device || {};
|
||
const x = data.expansion || {};
|
||
const cur = x.current || {};
|
||
const exp = x.expansion || {};
|
||
const findings = x.findings || [];
|
||
const memNow = cur.memory || {};
|
||
const cpuNow = cur.cpu || {};
|
||
const diskNow = cur.disks || {};
|
||
const memExp = exp.memory || {};
|
||
const cpuExp = exp.cpu || {};
|
||
const diskExp = exp.disks || {};
|
||
const pcieExp = exp.pcie || {};
|
||
const modules = memNow.modules || [];
|
||
const processors = cpuNow.processors || [];
|
||
const disks = diskNow.items || [];
|
||
const adapters = cur.adapters || [];
|
||
const osRows = cur.os || [];
|
||
const slotMap = memExp.slot_map || [];
|
||
const banks = [...new Set(slotMap.map((s) => s.cpu_bank).filter(Boolean))];
|
||
const rdpHost = d.rdp_host || (d.rdp_ips && d.rdp_ips[0]) || "";
|
||
const rdpIps = d.rdp_ips || [];
|
||
const idracIp = d.idrac_ip || d.ip || "";
|
||
|
||
const bankHtml = banks
|
||
.map((bank) => {
|
||
const bankSlots = slotMap.filter((s) => s.cpu_bank === bank).sort((a, b) => (a.slot_num || 0) - (b.slot_num || 0));
|
||
const filled = bankSlots.filter((s) => s.populated).length;
|
||
return `<div class="dimm-bank">
|
||
<h4>CPU ${escapeHtml(bank)} · ${filled}/${bankSlots.length} populated (catalog map)</h4>
|
||
<div class="dimm-grid">
|
||
${bankSlots
|
||
.map((s) => {
|
||
const title = s.populated
|
||
? `${s.slot}: ${s.size_gb || "?"} GB @ ${s.operating_mts || s.rated_mts || "?"} MT/s · ${s.part_number || ""}`
|
||
: `${s.slot}: EMPTY (catalog)`;
|
||
return `<div class="dimm-slot ${s.populated ? "full" : "empty"} ${s.rank_color || ""}" title="${escapeHtml(title)}">
|
||
<span class="dimm-lab">${escapeHtml(s.slot)}</span>
|
||
<span class="dimm-val">${s.populated ? escapeHtml(String(s.size_gb ?? "?")) + "G" : "—"}</span>
|
||
<span class="dimm-meta">${s.populated ? escapeHtml(String(s.operating_mts || s.rated_mts || "")) : "empty"}</span>
|
||
</div>`;
|
||
})
|
||
.join("")}
|
||
</div>
|
||
</div>`;
|
||
})
|
||
.join("");
|
||
|
||
const dash = (v) => (v == null || v === "" ? "—" : v);
|
||
const catalogBadge = exp.catalog_known
|
||
? `<span class="badge on">Catalog: ${escapeHtml(exp.catalog_model || "")}</span>`
|
||
: `<span class="badge off">No catalog max for this model</span>`;
|
||
|
||
return `
|
||
<header class="hw-head">
|
||
<div>
|
||
<h2>${escapeHtml(d.name || "Device")}</h2>
|
||
<p><code class="st">${escapeHtml(d.service_tag || "—")}</code> · ${escapeHtml(d.model || "—")}</p>
|
||
<p class="hw-net">
|
||
<span>iDRAC <code>${escapeHtml(idracIp || "—")}</code></span>
|
||
${d.os_hostname ? `<span>OS host <code>${escapeHtml(d.os_hostname)}</code></span>` : ""}
|
||
${rdpIps.length ? `<span>RDP IP(s) <code>${escapeHtml(rdpIps.join(", "))}</code></span>` : `<span>RDP IP <em>not resolved</em></span>`}
|
||
${rdpHost ? `<button type="button" class="btn compact" id="hw-rdp-btn" data-rdp-host="${escapeAttr(rdpHost)}">RDP popout</button>` : ""}
|
||
</p>
|
||
${osRows
|
||
.map(
|
||
(o) =>
|
||
`<p class="hw-os">${escapeHtml([o.os_name, o.os_version, o.hostname].filter(Boolean).join(" · ") || "OS inventory empty")}</p>`
|
||
)
|
||
.join("")}
|
||
</div>
|
||
${catalogBadge}
|
||
</header>
|
||
|
||
<section class="hw-section hw-current">
|
||
<h3>Current state · physical (OME live)</h3>
|
||
<p class="an-note">Only what OME reports as installed — not Dell maximums.</p>
|
||
<div class="hw-limits hw-limits-current">
|
||
<div><em>${dash(memNow.installed_gb)} GB</em><span>RAM installed</span></div>
|
||
<div><em>${dash(memNow.dimm_count)}</em><span>DIMM modules</span></div>
|
||
<div><em>${dash(cpuNow.sockets_populated)}</em><span>CPU sockets filled</span></div>
|
||
<div><em>${dash(cpuNow.cores_total)}</em><span>Cores total</span></div>
|
||
<div><em>${dash(diskNow.count)}</em><span>Disks installed</span></div>
|
||
<div><em>${dash(diskNow.capacity_gb)} GB</em><span>Disk capacity sum</span></div>
|
||
</div>
|
||
|
||
<h4>Memory modules (installed)</h4>
|
||
<p class="an-note">Rated MT/s: ${escapeHtml((memNow.rated_mts || []).join(", ") || "—")} · Operating MT/s: ${escapeHtml((memNow.operating_mts || []).join(", ") || "—")}</p>
|
||
<div class="rpt-table-wrap" style="max-height:240px">
|
||
<table class="rpt-table">
|
||
<thead><tr><th>Slot</th><th>Size</th><th>Rated</th><th>Operating</th><th>Type</th><th>Part</th><th>Serial</th></tr></thead>
|
||
<tbody>
|
||
${modules
|
||
.map(
|
||
(s) => `<tr>
|
||
<td>${escapeHtml(s.slot)}</td>
|
||
<td>${s.size_gb ?? "—"} GB</td>
|
||
<td>${escapeHtml(String(s.rated_mts ?? "—"))}</td>
|
||
<td>${escapeHtml(String(s.operating_mts ?? "—"))}</td>
|
||
<td>${escapeHtml(s.type || "—")}</td>
|
||
<td>${escapeHtml(s.part_number || "—")}</td>
|
||
<td>${escapeHtml(s.serial || "—")}</td>
|
||
</tr>`
|
||
)
|
||
.join("") || "<tr><td colspan='7'>No DIMMs in OME inventory</td></tr>"}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
|
||
<h4>Processors (installed)</h4>
|
||
<ul class="rpt-list">
|
||
${processors
|
||
.map(
|
||
(p) =>
|
||
`<li><strong>${escapeHtml(p.slot || "CPU")}</strong> — ${escapeHtml(p.model || "—")} · ${p.cores ?? "?"} cores · ${p.current_mhz ?? "?"} / ${p.max_mhz ?? "?"} MHz</li>`
|
||
)
|
||
.join("") || "<li>No CPU inventory</li>"}
|
||
</ul>
|
||
|
||
<h4>Disks (installed)</h4>
|
||
<div class="rpt-table-wrap" style="max-height:260px">
|
||
<table class="rpt-table">
|
||
<thead><tr><th>Bay</th><th>Model</th><th>Media</th><th>Size (GB)</th><th>Bus</th><th>Status</th><th>Serial</th></tr></thead>
|
||
<tbody>
|
||
${disks
|
||
.map(
|
||
(disk) => `<tr>
|
||
<td>${escapeHtml(String(disk.bay ?? "—"))}</td>
|
||
<td>${escapeHtml(disk.model || "—")}</td>
|
||
<td>${escapeHtml(disk.media || "—")}</td>
|
||
<td>${escapeHtml(String(disk.size_gb ?? "—"))}</td>
|
||
<td>${escapeHtml(disk.bus || "—")}</td>
|
||
<td>${escapeHtml(String(disk.status || "—"))}</td>
|
||
<td>${escapeHtml(disk.serial || "—")}</td>
|
||
</tr>`
|
||
)
|
||
.join("") || "<tr><td colspan='7'>No disks in inventory</td></tr>"}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
|
||
<h4>Adapters seen by OME</h4>
|
||
<div class="rpt-table-wrap" style="max-height:180px">
|
||
<table class="rpt-table">
|
||
<thead><tr><th>Slot</th><th>Description</th><th>Type</th></tr></thead>
|
||
<tbody>
|
||
${adapters
|
||
.slice(0, 40)
|
||
.map(
|
||
(c) => `<tr>
|
||
<td>${escapeHtml(c.slot || "—")}</td>
|
||
<td>${escapeHtml(c.description || "—")}</td>
|
||
<td>${escapeHtml(c.slot_type || "—")}</td>
|
||
</tr>`
|
||
)
|
||
.join("") || "<tr><td colspan='3'>No adapters</td></tr>"}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
</section>
|
||
|
||
<section class="hw-section hw-expand">
|
||
<h3>Max expansion · recommendations (Dell catalog)</h3>
|
||
<p class="an-note">${escapeHtml(exp.source || "")} — separate from live inventory. Confirm against Tech Specs for this Service Tag BOM.</p>
|
||
<div class="hw-limits">
|
||
<div><em>${memExp.max_capacity_tb != null ? memExp.max_capacity_tb + " TB" : "—"}</em><span>Max memory</span></div>
|
||
<div><em>${memExp.headroom_gb != null ? memExp.headroom_gb + " GB" : "—"}</em><span>Headroom vs installed</span></div>
|
||
<div><em>${memExp.dimms_installed ?? "—"}/${memExp.dimm_slots_max ?? "—"}</em><span>DIMMs used / max slots</span></div>
|
||
<div><em>${cpuExp.sockets_populated ?? "—"}/${cpuExp.sockets_max ?? "—"}</em><span>CPU sockets used / max</span></div>
|
||
<div><em>${diskExp.drives_installed ?? "—"}/${diskExp.bays_max ?? "—"}</em><span>Disks / max bays</span></div>
|
||
<div><em>${pcieExp.slots_max ?? "—"}</em><span>Max PCIe (catalog)</span></div>
|
||
</div>
|
||
<div class="hw-exp-notes">
|
||
<p><strong>Memory:</strong> ${escapeHtml(memExp.note || "—")}</p>
|
||
<p><strong>CPU:</strong> ${escapeHtml(cpuExp.note || "—")}</p>
|
||
<p><strong>Disks:</strong> ${escapeHtml(diskExp.note || "—")}</p>
|
||
<p><strong>PCIe:</strong> ${escapeHtml(pcieExp.note || "—")}</p>
|
||
<p class="an-note">Supported memory: ${escapeHtml((memExp.supported_types || []).join(", ") || "—")} · catalog max rated ${dash(memExp.max_rated_mts)} MT/s</p>
|
||
</div>
|
||
<div class="hw-docs-bar">
|
||
<span class="hw-docs-label">Dell docs</span>
|
||
${(exp.docs || [])
|
||
.map((doc) => {
|
||
const kind = doc.kind || (String(doc.title || "").toLowerCase().includes("spec") ? "spec" : "doc");
|
||
return `<a class="hw-doc-btn ${escapeAttr(kind)}" href="${escapeAttr(doc.url)}" target="_blank" rel="noopener" title="${escapeAttr(doc.title)}">${escapeHtml(doc.title)}</a>`;
|
||
})
|
||
.join("")}
|
||
<a class="hw-doc-btn support" href="https://www.dell.com/support/home/en-us/product-support/servicetag/${encodeURIComponent(d.service_tag || "")}/overview" target="_blank" rel="noopener">Support · ST ${escapeHtml(d.service_tag || "—")}</a>
|
||
</div>
|
||
${
|
||
exp.catalog_known
|
||
? `<h4>Catalog DIMM map (empty = expansion candidate)</h4>${bankHtml || "<p class='hint'>No slot map</p>"}`
|
||
: `<p class="hw-finding warning">No Cockpit catalog for ${escapeHtml(d.model || "this model")} — maxima above are blank on purpose (not guessed).</p>`
|
||
}
|
||
</section>
|
||
|
||
<div class="hw-findings">
|
||
${findings
|
||
.map((f) => `<div class="hw-finding ${f.severity || ""}">${escapeHtml(f.text)}</div>`)
|
||
.join("") || `<div class="hw-finding ok">No population warnings from live inventory vs catalog rules.</div>`}
|
||
</div>
|
||
|
||
<section class="hw-section dell-guidance">
|
||
<h3>Dell guidance (not current inventory)</h3>
|
||
<div class="hw-cols">
|
||
<div>
|
||
<h4>Installation / population</h4>
|
||
<ul>${(exp.best_practices || []).map((t) => `<li>${escapeHtml(t)}</li>`).join("") || "<li>—</li>"}</ul>
|
||
</div>
|
||
<div>
|
||
<h4>Performance</h4>
|
||
<ul>${(exp.performance_notes || []).map((t) => `<li>${escapeHtml(t)}</li>`).join("") || "<li>—</li>"}</ul>
|
||
</div>
|
||
</div>
|
||
</section>`;
|
||
}
|
||
|
||
|
||
function renderFleet(mount) {
|
||
const all = state.fleet?.rows || [];
|
||
const rows = applyBaseFilters(all, { searchFields: ["name", "service_tag", "ip", "model", "subnet"] });
|
||
mount.innerHTML =
|
||
filterBar({
|
||
models: uniqueModels(all),
|
||
connected: true,
|
||
exports: `
|
||
<a class="btn compact" href="${downloadUrl("fleet", "csv")}" title="Download filtered-capable fleet CSV (full export)">CSV</a>
|
||
<a class="btn compact" href="${downloadUrl("fleet", "json")}" title="Download fleet JSON">JSON</a>
|
||
`,
|
||
quick: [
|
||
{ id: "all", label: "All", title: "Clear filters" },
|
||
{ id: "connected", label: "Connected", active: state.filters.connected === "yes" },
|
||
{ id: "offline", label: "Offline", active: state.filters.connected === "no" },
|
||
],
|
||
}) +
|
||
`<div class="rpt-meta">${rows.length} / ${all.length} devices shown · Service Tags + warranty joined</div>
|
||
<div class="rpt-table-wrap"><table class="rpt-table">
|
||
<thead><tr>
|
||
<th>Name</th><th>Service Tag</th><th>Model</th><th>IP</th><th>Connected</th><th>Power</th><th>Warranty end</th><th>Days left</th>
|
||
</tr></thead>
|
||
<tbody>
|
||
${rows
|
||
.map(
|
||
(r) => `<tr data-id="${r.id}" class="an-row-click" title="Open Hardware / Expansion" data-open-hw="${r.id}">
|
||
<td>${escapeHtml(r.name)}</td>
|
||
<td><code class="st">${escapeHtml(r.service_tag || "—")}</code></td>
|
||
<td>${escapeHtml(r.model || "—")}</td>
|
||
<td>${escapeHtml(r.ip || "—")}</td>
|
||
<td>${r.connected ? badge("CONNECTED") : badge("OFFLINE")}</td>
|
||
<td>${r.watts != null ? Math.round(r.watts) + " W" : "—"}</td>
|
||
<td>${escapeHtml(fmtDate(r.warranty_end))}</td>
|
||
<td>${r.warranty_days_remaining ?? "—"}</td>
|
||
</tr>`
|
||
)
|
||
.join("")}
|
||
</tbody>
|
||
</table></div>`;
|
||
wireFilters(mount, () => renderFleet(mount));
|
||
mount.querySelectorAll("[data-open-hw]").forEach((tr) => {
|
||
tr.addEventListener("click", () => {
|
||
state.hwDeviceId = Number(tr.dataset.openHw);
|
||
loadTab("hardware");
|
||
});
|
||
});
|
||
}
|
||
|
||
function renderFirmware(mount) {
|
||
const sum = state.compliance?.summary || {};
|
||
let comps = state.compliance?.components || [];
|
||
const compOpts = [...new Set(comps.map((c) => shortComponent(c.component)))].sort();
|
||
if (state.outdatedOnly) {
|
||
comps = comps.filter(
|
||
(c) =>
|
||
String(c.update_action || "").toUpperCase() === "UPGRADE" ||
|
||
String(c.compliance_status || "").toUpperCase() === "CRITICAL" ||
|
||
String(c.compliance_status || "").toUpperCase() === "WARNING"
|
||
);
|
||
}
|
||
if (state.filters.severity === "critical") {
|
||
comps = comps.filter((c) => String(c.compliance_status || "").toUpperCase() === "CRITICAL");
|
||
} else if (state.filters.severity === "warning") {
|
||
comps = comps.filter((c) => String(c.compliance_status || "").toUpperCase() === "WARNING");
|
||
}
|
||
if (state.filters.component && state.filters.component !== "all") {
|
||
comps = comps.filter((c) => shortComponent(c.component) === state.filters.component);
|
||
}
|
||
if (state.filters.model) {
|
||
comps = comps.filter((c) => c.model === state.filters.model);
|
||
}
|
||
comps = comps.filter((c) => matchSearch(c, ["device_name", "service_tag", "model", "component", "ip"]));
|
||
|
||
mount.innerHTML =
|
||
filterBar({
|
||
models: uniqueModels(state.compliance?.devices || []),
|
||
connected: false,
|
||
severity: true,
|
||
componentOptions: compOpts,
|
||
outdatedToggle: true,
|
||
exports: `
|
||
<a class="btn compact" href="${downloadUrl("firmware", "csv")}" title="Export firmware compliance CSV">CSV</a>
|
||
<a class="btn compact" href="${downloadUrl("firmware", "json")}" title="Export firmware compliance JSON">JSON</a>
|
||
`,
|
||
quick: [
|
||
{ id: "all", label: "All", title: "Clear severity/component filters" },
|
||
{ id: "bios", label: "BIOS", title: "Only PowerEdge BIOS rows", active: state.filters.component === "BIOS" },
|
||
{ id: "idrac", label: "iDRAC", title: "Only iDRAC / Lifecycle Controller", active: state.filters.component === "iDRAC" },
|
||
{ id: "outdated", label: "Critical", title: "Critical only", active: state.filters.severity === "critical" },
|
||
],
|
||
}) +
|
||
`<div class="rpt-meta">
|
||
Baseline <strong>${escapeHtml(sum.baseline_name || "—")}</strong>
|
||
· showing ${comps.length} rows · outdated devices ${sum.outdated_devices ?? "—"}
|
||
</div>
|
||
<div class="rpt-table-wrap"><table class="rpt-table">
|
||
<thead><tr>
|
||
<th>Service Tag</th><th>Device</th><th>Component</th><th>Current</th><th>Dell catalog</th><th>Action</th><th>Status</th><th>Dell.com</th>
|
||
</tr></thead>
|
||
<tbody>
|
||
${comps
|
||
.map(
|
||
(c) => `<tr>
|
||
<td><code class="st">${escapeHtml(c.service_tag || "—")}</code></td>
|
||
<td>${escapeHtml(c.device_name || "—")}<div class="sub">${escapeHtml(c.model || "")}</div></td>
|
||
<td>${escapeHtml(c.component || "—")}</td>
|
||
<td>${escapeHtml(c.current_version || "—")}</td>
|
||
<td>${escapeHtml(c.catalog_version || "—")}</td>
|
||
<td>${escapeHtml(c.update_action || "—")}</td>
|
||
<td>${badge(c.compliance_status || c.status_badge)}</td>
|
||
<td>${c.dell_uri ? `<a class="rpt-link" href="${escapeHtml(c.dell_uri)}" target="_blank" rel="noopener" title="Open Dell driver page for this package">Driver</a>` : "—"}</td>
|
||
</tr>`
|
||
)
|
||
.join("")}
|
||
</tbody>
|
||
</table></div>`;
|
||
wireFilters(mount, () => renderFirmware(mount));
|
||
}
|
||
|
||
function renderWarranty(mount) {
|
||
let items = state.warranties?.items || [];
|
||
if (state.filters.warranty === "expired") items = items.filter((w) => intOr(w.days_remaining) <= 0);
|
||
else if (state.filters.warranty === "d30")
|
||
items = items.filter((w) => {
|
||
const d = intOr(w.days_remaining);
|
||
return d > 0 && d <= 30;
|
||
});
|
||
else if (state.filters.warranty === "d90")
|
||
items = items.filter((w) => {
|
||
const d = intOr(w.days_remaining);
|
||
return d > 30 && d <= 90;
|
||
});
|
||
else if (state.filters.warranty === "ok") items = items.filter((w) => intOr(w.days_remaining) > 90);
|
||
if (state.filters.model) items = items.filter((w) => w.model === state.filters.model);
|
||
items = items.filter((w) => matchSearch(w, ["device_name", "service_tag", "model", "service_level"]));
|
||
|
||
mount.innerHTML =
|
||
filterBar({
|
||
models: uniqueModels(state.warranties?.items || []),
|
||
connected: false,
|
||
warranty: true,
|
||
exports: `
|
||
<a class="btn compact" href="${downloadUrl("warranty", "csv")}" title="Export warranties CSV">CSV</a>
|
||
<a class="btn compact" href="${downloadUrl("warranty", "json")}" title="Export warranties JSON">JSON</a>
|
||
`,
|
||
quick: [
|
||
{ id: "all", label: "All", title: "Clear warranty filter" },
|
||
{ id: "warranty-risk", label: "Expired", title: "Expired only", active: state.filters.warranty === "expired" },
|
||
],
|
||
}) +
|
||
`<div class="rpt-meta">${items.length} warranty records shown</div>
|
||
<div class="rpt-table-wrap"><table class="rpt-table">
|
||
<thead><tr>
|
||
<th>Service Tag</th><th>Device</th><th>Model</th><th>Service level</th><th>Start</th><th>End</th><th>Days left</th>
|
||
</tr></thead>
|
||
<tbody>
|
||
${items
|
||
.map(
|
||
(w) => `<tr>
|
||
<td><code class="st">${escapeHtml(w.service_tag || "—")}</code></td>
|
||
<td>${escapeHtml(w.device_name || "—")}</td>
|
||
<td>${escapeHtml(w.model || "—")}</td>
|
||
<td>${escapeHtml(w.service_level || "—")}</td>
|
||
<td>${escapeHtml(fmtDate(w.start_date))}</td>
|
||
<td>${escapeHtml(fmtDate(w.end_date))}</td>
|
||
<td>${w.days_remaining ?? "—"}</td>
|
||
</tr>`
|
||
)
|
||
.join("")}
|
||
</tbody>
|
||
</table></div>`;
|
||
wireFilters(mount, () => renderWarranty(mount));
|
||
}
|
||
|
||
function renderOme(mount, baselines) {
|
||
const defs = state.reportDefs?.items || [];
|
||
const jobs = state.jobs?.items || [];
|
||
const cats = state.catalogs?.items || [];
|
||
const filtered = defs.filter((d) => matchSearch(d, ["name", "description", "category"]));
|
||
const categories = [...new Set(defs.map((d) => d.category).filter(Boolean))].sort();
|
||
const shown = state.filters.category
|
||
? filtered.filter((d) => d.category === state.filters.category)
|
||
: filtered;
|
||
|
||
mount.innerHTML = `
|
||
${filterBar({
|
||
models: [],
|
||
connected: false,
|
||
searchPlaceholder: "Filter OME report definitions…",
|
||
exports: `<button type="button" class="btn ghost compact" id="rpt-refresh-ome" title="Reload ReportDefs, catalogs, baselines and jobs from OME">Refresh OME data</button>`,
|
||
quick: categories.slice(0, 8).map((c) => ({
|
||
id: `cat:${c}`,
|
||
label: c,
|
||
title: `Show ReportDefs in category ${c}`,
|
||
active: state.filters.category === c,
|
||
})),
|
||
})}
|
||
<div class="rpt-grid-2">
|
||
<section class="rpt-panel">
|
||
<h3 title="Dell update catalogs registered in OME">Dell catalogs</h3>
|
||
<ul class="rpt-list">
|
||
${cats
|
||
.map(
|
||
(c) =>
|
||
`<li><strong>${escapeHtml(c.repository_name || c.filename || "#" + c.id)}</strong>
|
||
<span class="sub">${escapeHtml(c.repository_type || "")} · ${escapeHtml(fmtDate(c.last_updated))}</span></li>`
|
||
)
|
||
.join("") || "<li class='hint'>No catalogs</li>"}
|
||
</ul>
|
||
<h3 title="Firmware baselines compared against Dell catalogs">Baselines</h3>
|
||
<ul class="rpt-list">
|
||
${(baselines?.items || [])
|
||
.map(
|
||
(b) =>
|
||
`<li><strong>${escapeHtml(b.name)}</strong> ${badge(b.compliance_status)}
|
||
<span class="sub">critical ${b.critical ?? 0} · last ${escapeHtml(fmtDate(b.last_run))}</span></li>`
|
||
)
|
||
.join("")}
|
||
</ul>
|
||
</section>
|
||
<section class="rpt-panel">
|
||
<h3 title="Recent JobService entries (read-only)">Recent OME jobs</h3>
|
||
<ul class="rpt-list">
|
||
${jobs
|
||
.slice(0, 20)
|
||
.map(
|
||
(j) =>
|
||
`<li><strong>${escapeHtml(j.name || "#" + j.id)}</strong> ${badge(j.status)}
|
||
<span class="sub">${escapeHtml(j.job_type || "")}</span></li>`
|
||
)
|
||
.join("") || "<li class='hint'>No jobs returned</li>"}
|
||
</ul>
|
||
</section>
|
||
</div>
|
||
<h3 class="rpt-h">OME ReportDefs (${shown.length})</h3>
|
||
<div class="rpt-table-wrap"><table class="rpt-table">
|
||
<thead><tr><th>Name</th><th>Category</th><th>Columns</th><th>Last run</th><th></th></tr></thead>
|
||
<tbody>
|
||
${shown
|
||
.map(
|
||
(d) => `<tr>
|
||
<td><strong>${escapeHtml(d.name)}</strong><div class="sub">${escapeHtml(d.description || "")}</div></td>
|
||
<td>${escapeHtml(d.category || "—")}</td>
|
||
<td>${escapeHtml((d.columns || []).slice(0, 4).join(", "))}${(d.columns || []).length > 4 ? "…" : ""}</td>
|
||
<td>${escapeHtml(fmtDate(d.last_run))}</td>
|
||
<td>
|
||
<button type="button" class="btn compact" data-run-report="${d.id}" title="Start this report job inside OME (read/generate)">Run</button>
|
||
<button type="button" class="btn ghost compact" data-fetch-report="${d.id}" title="Fetch last results JSON if available">Results</button>
|
||
</td>
|
||
</tr>`
|
||
)
|
||
.join("")}
|
||
</tbody>
|
||
</table></div>
|
||
<pre class="rpt-results hidden" id="rpt-ome-results"></pre>`;
|
||
|
||
wireFilters(mount, () => renderOme(mount, baselines));
|
||
$("#rpt-refresh-ome")?.addEventListener("click", () => loadTab("ome"));
|
||
mount.querySelectorAll("[data-run-report]").forEach((btn) => {
|
||
btn.addEventListener("click", async () => {
|
||
const id = Number(btn.dataset.runReport);
|
||
btn.disabled = true;
|
||
try {
|
||
const res = await fetch("/api/ome/reports/run", {
|
||
method: "POST",
|
||
headers: { "Content-Type": "application/json" },
|
||
body: JSON.stringify({ report_def_id: id }),
|
||
});
|
||
const data = await res.json();
|
||
if (!res.ok) throw new Error(data.detail || res.statusText);
|
||
alert(`OME report job started: ${data.job_id}`);
|
||
} catch (err) {
|
||
alert("Run failed: " + err.message);
|
||
} finally {
|
||
btn.disabled = false;
|
||
}
|
||
});
|
||
});
|
||
mount.querySelectorAll("[data-fetch-report]").forEach((btn) => {
|
||
btn.addEventListener("click", async () => {
|
||
const id = Number(btn.dataset.fetchReport);
|
||
const out = $("#rpt-ome-results");
|
||
out?.classList.remove("hidden");
|
||
if (out) out.textContent = "Loading…";
|
||
try {
|
||
const data = await apiGet(`/api/ome/reports/${id}/results`);
|
||
if (out) out.textContent = JSON.stringify(data, null, 2);
|
||
} catch (err) {
|
||
if (out) out.textContent = String(err.message || err);
|
||
}
|
||
});
|
||
});
|
||
}
|
||
|
||
function renderBrief(mount) {
|
||
const b = state.brief || {};
|
||
const sum = b.summary || {};
|
||
const cs = b.compliance_summary || {};
|
||
const ome = b.ome || {};
|
||
mount.innerHTML = `
|
||
<div class="rpt-toolbar">
|
||
<a class="btn compact" href="${downloadUrl("brief", "json")}">JSON brief</a>
|
||
<a class="btn compact" href="${downloadUrl("fleet", "csv")}">Fleet CSV</a>
|
||
<a class="btn compact" href="${downloadUrl("firmware", "csv")}">Firmware CSV</a>
|
||
<a class="btn compact" href="${downloadUrl("warranty", "csv")}">Warranty CSV</a>
|
||
<button type="button" class="btn primary compact" id="rpt-print">Print customer PDF</button>
|
||
</div>
|
||
<article class="customer-brief" id="customer-brief">
|
||
<header class="brief-head">
|
||
<img src="/dell.png" alt="Dell" width="48" height="48" />
|
||
<div>
|
||
<p class="modal-kicker">Dell ATC · OpenManage Cockpit</p>
|
||
<h2>Customer fleet brief</h2>
|
||
<p>${escapeHtml(ome.name || "OM Enterprise")} ${escapeHtml(ome.version || "")} · ${escapeHtml(ome.fqdn || "")}</p>
|
||
<p class="sub">Generated ${escapeHtml(new Date((b.generated_at || 0) * 1000).toLocaleString())}</p>
|
||
</div>
|
||
</header>
|
||
<section>
|
||
<h3>Executive summary</h3>
|
||
<div class="brief-kpis">
|
||
<div><em>${sum.total ?? "—"}</em><span>Devices</span></div>
|
||
<div><em>${sum.connected ?? "—"}</em><span>Connected</span></div>
|
||
<div><em>${cs.outdated_devices ?? "—"}</em><span>Outdated vs Dell catalog</span></div>
|
||
<div><em>${sum.alerts_critical ?? "—"}</em><span>Critical alerts</span></div>
|
||
</div>
|
||
<p>Baseline: <strong>${escapeHtml(cs.baseline_name || "—")}</strong>
|
||
(${escapeHtml(cs.compliance_status || "—")}) · last run ${escapeHtml(fmtDate(cs.last_run))}</p>
|
||
</section>
|
||
<section>
|
||
<h3>Critical alerts</h3>
|
||
<ul>${(b.critical_alerts || [])
|
||
.map(
|
||
(a) =>
|
||
`<li><strong>${escapeHtml(a.device || "—")}</strong> ${escapeHtml(a.ip || "")} — ${escapeHtml((a.message || "").slice(0, 160))}</li>`
|
||
)
|
||
.join("") || "<li>None in snapshot</li>"}</ul>
|
||
</section>
|
||
<section>
|
||
<h3>Systems out of date (Dell catalog)</h3>
|
||
<ul>${(b.outdated_devices || [])
|
||
.map(
|
||
(d) =>
|
||
`<li><code class="st">${escapeHtml(d.service_tag || "—")}</code> ${escapeHtml(d.name || "")} · ${escapeHtml(d.model || "")} · ${escapeHtml(d.compliance_status || d.firmware_status || "")}</li>`
|
||
)
|
||
.join("") || "<li>No outdated devices in baseline report</li>"}</ul>
|
||
</section>
|
||
<section>
|
||
<h3>Warranty attention (≤ 90 days)</h3>
|
||
<ul>${(b.warranty_expiring || [])
|
||
.map(
|
||
(w) =>
|
||
`<li><code class="st">${escapeHtml(w.service_tag || "—")}</code> ${escapeHtml(w.device_name || "")} · ends ${escapeHtml(fmtDate(w.end_date))} · ${w.days_remaining} days</li>`
|
||
)
|
||
.join("") || "<li>No warranties expiring within 90 days</li>"}</ul>
|
||
</section>
|
||
<section>
|
||
<h3>Device appendix (Service Tags)</h3>
|
||
<div class="rpt-table-wrap"><table class="rpt-table">
|
||
<thead><tr><th>Service Tag</th><th>Name</th><th>Model</th><th>IP</th><th>Connected</th></tr></thead>
|
||
<tbody>
|
||
${(b.fleet || [])
|
||
.map(
|
||
(r) => `<tr>
|
||
<td><code class="st">${escapeHtml(r.service_tag || "—")}</code></td>
|
||
<td>${escapeHtml(r.name || "—")}</td>
|
||
<td>${escapeHtml(r.model || "—")}</td>
|
||
<td>${escapeHtml(r.ip || "—")}</td>
|
||
<td>${r.connected ? "Yes" : "No"}</td>
|
||
</tr>`
|
||
)
|
||
.join("")}
|
||
</tbody>
|
||
</table></div>
|
||
</section>
|
||
</article>`;
|
||
wireCommon(mount);
|
||
}
|
||
|
||
function wireCommon(mount) {
|
||
// legacy no-op kept for brief; prefer wireFilters
|
||
$("#rpt-print")?.addEventListener("click", () => window.print());
|
||
}
|
||
|
||
function init() {
|
||
$("#btn-reports")?.addEventListener("click", openReports);
|
||
$("#btn-reports-close")?.addEventListener("click", closeReports);
|
||
$("#reports-tabs")?.addEventListener("click", (e) => {
|
||
const chip = e.target.closest("[data-tab]");
|
||
if (!chip) return;
|
||
state.search = "";
|
||
loadTab(chip.dataset.tab);
|
||
});
|
||
$("#scrim")?.addEventListener("click", () => {
|
||
const mode = $("#scrim")?.dataset.mode;
|
||
if (mode === "an-context") closeAnContext();
|
||
else if (mode === "reports-drawer") closeReports();
|
||
});
|
||
// Extend ops closeDrawers awareness via custom event
|
||
window.addEventListener("cockpit-close-drawers", closeReports);
|
||
}
|
||
|
||
window.cockpitReports = { open: openReports, close: closeReports, loadTab, closeAnContext };
|
||
if (document.readyState === "loading") document.addEventListener("DOMContentLoaded", init);
|
||
else init();
|
||
})();
|