Files
ome-cockpit/ui/reports.js
T
mo 115b6e39aa Add Reports drawer, Dell catalog compliance, and Service Tag chat context.
Exposes OME warranties, baselines, report defs, and CSV/JSON exports for customer-ready fleet briefs.
2026-07-17 15:15:57 +02:00

477 lines
19 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: "fleet",
fleet: null,
compliance: null,
warranties: null,
reportDefs: null,
jobs: null,
catalogs: null,
brief: null,
outdatedOnly: true,
search: "",
};
function escapeHtml(s) {
return String(s ?? "")
.replace(/&/g, "&")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
.replace(/"/g, "&quot;");
}
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"].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 === "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 toolbar(exports) {
return `
<div class="rpt-toolbar">
<input type="search" id="rpt-search" class="search" placeholder="Filter name, service tag, IP, model…" value="${escapeHtml(state.search)}" />
<div class="rpt-exports">
${exports}
<button type="button" class="btn ghost compact" id="rpt-print">Print / PDF</button>
</div>
</div>`;
}
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 renderFleet(mount) {
const rows = (state.fleet?.rows || []).filter((r) =>
matchSearch(r, ["name", "service_tag", "ip", "model", "subnet"])
);
mount.innerHTML =
toolbar(`
<a class="btn compact" href="${downloadUrl("fleet", "csv")}">CSV</a>
<a class="btn compact" href="${downloadUrl("fleet", "json")}">JSON</a>
`) +
`<div class="rpt-meta">${rows.length} devices · 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}">
<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>`;
wireCommon(mount);
}
function renderFirmware(mount) {
const sum = state.compliance?.summary || {};
let comps = state.compliance?.components || [];
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"
);
}
comps = comps.filter((c) =>
matchSearch(c, ["device_name", "service_tag", "model", "component", "ip"])
);
mount.innerHTML =
toolbar(`
<a class="btn compact" href="${downloadUrl("firmware", "csv")}">CSV</a>
<a class="btn compact" href="${downloadUrl("firmware", "json")}">JSON</a>
`) +
`<div class="rpt-meta">
Baseline <strong>${escapeHtml(sum.baseline_name || "—")}</strong>
· Dell catalog compare · outdated devices ${sum.outdated_devices ?? "—"}
· components ${comps.length}
<label class="rpt-check"><input type="checkbox" id="rpt-outdated-only" ${state.outdatedOnly ? "checked" : ""}/> Outdated only</label>
</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 href="${escapeHtml(c.dell_uri)}" target="_blank" rel="noopener">Driver</a>` : "—"}</td>
</tr>`
)
.join("")}
</tbody>
</table></div>`;
wireCommon(mount);
$("#rpt-outdated-only")?.addEventListener("change", (e) => {
state.outdatedOnly = e.target.checked;
renderFirmware(mount);
});
}
function renderWarranty(mount) {
const items = (state.warranties?.items || []).filter((w) =>
matchSearch(w, ["device_name", "service_tag", "model", "service_level"])
);
mount.innerHTML =
toolbar(`
<a class="btn compact" href="${downloadUrl("warranty", "csv")}">CSV</a>
<a class="btn compact" href="${downloadUrl("warranty", "json")}">JSON</a>
`) +
`<div class="rpt-meta">${items.length} warranty records from OME WarrantyService</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>`;
wireCommon(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"]));
mount.innerHTML = `
<div class="rpt-toolbar">
<input type="search" id="rpt-search" class="search" placeholder="Filter OME report definitions…" value="${escapeHtml(state.search)}" />
<button type="button" class="btn ghost compact" id="rpt-refresh-ome">Refresh</button>
</div>
<div class="rpt-grid-2">
<section class="rpt-panel">
<h3>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>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>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 (${filtered.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>
${filtered
.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}">Run</button>
<button type="button" class="btn ghost compact" data-fetch-report="${d.id}">Results</button></td>
</tr>`
)
.join("")}
</tbody>
</table></div>
<pre class="rpt-results hidden" id="rpt-ome-results"></pre>`;
wireCommon(mount);
$("#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 (e) {
alert("Run failed: " + e.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 (e) {
if (out) out.textContent = String(e.message || e);
}
});
});
}
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) {
$("#rpt-search")?.addEventListener("input", (e) => {
state.search = e.target.value;
// re-render current tab with filter
if (state.tab === "fleet" && state.fleet) renderFleet(mount);
else if (state.tab === "firmware" && state.compliance) renderFirmware(mount);
else if (state.tab === "warranty" && state.warranties) renderWarranty(mount);
else if (state.tab === "ome" && state.reportDefs) loadTab("ome");
});
$("#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", () => {
if ($("#scrim")?.dataset.mode === "reports-drawer") closeReports();
});
// Extend ops closeDrawers awareness via custom event
window.addEventListener("cockpit-close-drawers", closeReports);
}
window.cockpitReports = { open: openReports, close: closeReports, loadTab };
if (document.readyState === "loading") document.addEventListener("DOMContentLoaded", init);
else init();
})();