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.
This commit is contained in:
mo
2026-07-17 15:15:57 +02:00
parent c2bb0465d0
commit 115b6e39aa
11 changed files with 1393 additions and 36 deletions
+79 -3
View File
@@ -747,6 +747,7 @@
${node.is_server ? `<span class="badge">SERVER</span>` : ""}
</div>
<div class="kpi-kv">
<div class="row"><span class="k">Service Tag</span><span class="v"><code>${escapeHtml(node.service_tag || "—")}</code></span></div>
<div class="row"><span class="k">IP</span><span class="v">${escapeHtml(node.ip || "—")}</span></div>
<div class="row"><span class="k">Subnet</span><span class="v">${escapeHtml(node.subnet || "—")}</span></div>
<div class="row"><span class="k">Status</span><span class="v">${escapeHtml(node.status || "—")}</span></div>
@@ -1424,7 +1425,7 @@
const watts = node.watts != null ? `${Math.round(node.watts)} W` : "no power sample";
tip.innerHTML = `
<p class="nt-name">${escapeHtml(node.name || "node")}</p>
<p class="nt-meta">${escapeHtml(node.model || "—")}<br/>
<p class="nt-meta">${escapeHtml(node.model || "—")} · ST ${escapeHtml(node.service_tag || "—")}<br/>
${node.connected ? "connected" : "offline"} · ${node.powered_on ? "powered on" : "power n/a"} · ${escapeHtml(watts)}<br/>
${escapeHtml(node.ip || "no IP")} · ${escapeHtml(node.subnet || "")}</p>
<p class="nt-hint">Double-click · Quick Connect</p>`;
@@ -1582,7 +1583,62 @@
updateFocusContext();
}
async function showInspector(node) {
async function loadWarrantyCompliance(deviceId) {
const mount = $("#warranty-compliance-mount");
if (!mount) return;
try {
const [wRes, cRes] = await Promise.all([
fetch(`/api/devices/${deviceId}/warranty`),
fetch(`/api/devices/${deviceId}/compliance`),
]);
const w = wRes.ok ? await wRes.json() : { items: [] };
const c = cRes.ok ? await cRes.json() : { components: [] };
const primary = w.primary;
const comps = (c.components || []).filter(
(x) =>
String(x.update_action || "").toUpperCase() === "UPGRADE" ||
String(x.compliance_status || "").toUpperCase() === "CRITICAL"
);
let html = `<div class="inv-section"><h3>Warranty</h3>`;
if (primary) {
html += `<div class="inv-line">ST ${escapeHtml(primary.service_tag || "—")} · ${escapeHtml(primary.service_level || "—")}</div>
<div class="inv-line">Ends ${escapeHtml(primary.end_date || "—")} · ${primary.days_remaining ?? "—"} days left</div>`;
} else if ((w.items || []).length) {
html += (w.items || [])
.slice(0, 3)
.map(
(x) =>
`<div class="inv-line">${escapeHtml(x.service_level || "—")} · ends ${escapeHtml(x.end_date || "—")} · ${x.days_remaining ?? "—"}d</div>`
)
.join("");
} else {
html += `<div class="inv-line">No warranty records in OME</div>`;
}
html += `</div><div class="inv-section"><h3>Dell catalog compliance</h3>`;
if (c.device) {
html += `<div class="inv-line">${escapeHtml(c.device.compliance_status || c.device.firmware_status || "—")} · baseline ${escapeHtml((c.baseline || {}).name || "—")}</div>`;
}
if (comps.length) {
html += comps
.slice(0, 8)
.map(
(x) =>
`<div class="inv-line">${escapeHtml(x.component || "component")}: ${escapeHtml(x.current_version || "?")}${escapeHtml(x.catalog_version || "?")} ${x.dell_uri ? `<a href="${escapeAttr(x.dell_uri)}" target="_blank" rel="noopener">dell.com</a>` : ""}</div>`
)
.join("");
} else if (c.device) {
html += `<div class="inv-line">No upgrade components in baseline report</div>`;
} else {
html += `<div class="inv-line">Not in active Dell baseline report</div>`;
}
html += `</div>`;
mount.innerHTML = html;
} catch (err) {
mount.innerHTML = `<p class="hint">Warranty/compliance unavailable</p>`;
}
}
async function showInspector(node) {
state.selectedId = node?.id ?? null;
const empty = $("#inspector-empty");
const body = $("#inspector-body");
@@ -1596,7 +1652,7 @@
body.innerHTML = `
<div class="insp-head">
<h2>${escapeHtml(node.name)}</h2>
<p class="meta">${escapeHtml(node.model || "—")} · ${escapeHtml(node.service_tag || "no tag")}</p>
<p class="meta">${escapeHtml(node.model || "—")} · id ${node.id}</p>
</div>
<div class="badge-row">
<span class="badge ${node.connected ? "on" : "off"}">${node.connected ? "CONNECTED" : "OFFLINE"}</span>
@@ -1606,8 +1662,12 @@
${node.is_server ? `<span class="badge">SERVER</span>` : ""}
</div>
<div class="kv">
<div class="kv-row st-row"><span class="k">Service Tag</span><span class="v st-value"><code>${escapeHtml(node.service_tag || "—")}</code>
${node.service_tag ? `<button type="button" class="btn ghost compact" id="btn-copy-st" title="Copy Service Tag">Copy</button>` : ""}
</span></div>
<div class="kv-row"><span class="k">IP</span><span class="v">${escapeHtml(node.ip || "—")}</span></div>
<div class="kv-row"><span class="k">Subnet</span><span class="v"><button type="button" class="linkish" data-jump-subnet="${escapeAttr(node.subnet)}">${escapeHtml(node.subnet)}</button></span></div>
<div class="kv-row"><span class="k">Model</span><span class="v">${escapeHtml(node.model || "—")}</span></div>
<div class="kv-row"><span class="k">Status</span><span class="v">${escapeHtml(node.status)}</span></div>
<div class="kv-row"><span class="k">Avg W</span><span class="v">${node.avg_watts != null ? Math.round(node.avg_watts) + " W" : "—"}</span></div>
<div class="kv-row"><span class="k">Peak W</span><span class="v">${node.peak_watts != null ? Math.round(node.peak_watts) + " W" : "—"}</span></div>
@@ -1616,6 +1676,7 @@
<div class="kv-row"><span class="k">Last status</span><span class="v">${escapeHtml(node.last_status_time || "—")}</span></div>
<div class="kv-row"><span class="k">Inventory</span><span class="v">${escapeHtml(node.last_inventory_time || "—")}</span></div>
</div>
<div id="warranty-compliance-mount" class="wc-mount"><p class="hint">Loading warranty &amp; Dell compliance…</p></div>
<div class="action-stack">
<button type="button" class="btn primary" id="btn-quick-connect">Quick Connect · iDRAC / SSH</button>
${node.idrac_url ? `<a class="btn conn-link" href="${escapeAttr(node.idrac_url)}" target="_blank" rel="noopener">iDRAC Web</a>` : ""}
@@ -1637,10 +1698,25 @@
refreshLists();
layout();
});
$("#btn-copy-st")?.addEventListener("click", async () => {
try {
await navigator.clipboard.writeText(node.service_tag);
const btn = $("#btn-copy-st");
if (btn) {
btn.textContent = "Copied";
setTimeout(() => {
btn.textContent = "Copy";
}, 1200);
}
} catch (_) {
/* ignore */
}
});
updateFocusContext();
$("#btn-quick-connect")?.addEventListener("click", () => openConnect(node));
$("#btn-ssh-term")?.addEventListener("click", () => window.cockpitSsh?.open(node));
$("#btn-ask-ai")?.addEventListener("click", () => openAi(node));
loadWarrantyCompliance(node.id);
const cachedInv = state.inventoryCache[node.id];
if (cachedInv) {
const mount = $("#inv-mount");
+31 -7
View File
@@ -7,7 +7,7 @@
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@400;500;600&family=IBM+Plex+Sans:wght@400;500;600;700&display=swap" rel="stylesheet" />
<link rel="stylesheet" href="/styles.css?v=kpi1" />
<link rel="stylesheet" href="/styles.css?v=reports1" />
<link rel="icon" href="/dell.png" type="image/png" />
<link rel="stylesheet" href="/vendor/xterm/xterm.css?v=home1" />
</head>
@@ -28,6 +28,7 @@
<div class="top-actions">
<button type="button" class="btn ghost" id="btn-reset-view" title="Fit fleet in view">Reset view</button>
<button type="button" class="btn ghost" id="btn-ome-console" title="Open OME console">OME console</button>
<button type="button" class="btn ghost" id="btn-reports" title="Fleet reports &amp; Dell compliance">Reports</button>
<button type="button" class="btn ghost" id="btn-ops" title="ATC admin tickets">Ops desk</button>
<button type="button" class="btn primary" id="btn-chat">Cockpit chat</button>
</div>
@@ -211,9 +212,10 @@
<span class="chat-ctx-pill" id="chat-ctx-focus">focus: none</span>
</div>
<div class="chat-quick" id="chat-quick">
<button type="button" data-q="What is the current fleet health? Name critical systems.">Fleet health</button>
<button type="button" data-q="List all critical alerts with system name and IP.">Critical alerts</button>
<button type="button" data-q="Which connected nodes use the most power right now?">Top power</button>
<button type="button" data-q="List every device with its Service Tag, model, and IP.">All service tags</button>
<button type="button" data-q="What is the current fleet health? Include Service Tags for critical systems.">Fleet health</button>
<button type="button" data-q="List all critical alerts with system name, Service Tag, and IP.">Critical alerts</button>
<button type="button" data-q="Which systems are firmware outdated vs the Dell catalog? Include Service Tags and BIOS/iDRAC versions if known.">Outdated firmware</button>
<button type="button" data-q="Summarize V100 GPU utilization on atc-gpu-prod.">GPU status</button>
</div>
<div class="chat-body" id="chat-body"></div>
@@ -276,6 +278,27 @@
<iframe id="ai-frame" title="OpenManage AI" src="about:blank"></iframe>
</div>
<!-- Reports / compliance / export -->
<div class="drawer wide reports-drawer" id="reports-drawer" aria-hidden="true">
<div class="drawer-head">
<div class="drawer-brand">
<img src="/dell.png" alt="Dell" height="22" />
<span>Fleet reports · Dell compliance</span>
</div>
<button type="button" class="btn ghost" id="btn-reports-close">Close</button>
</div>
<div class="chip-grid reports-tabs" id="reports-tabs">
<button type="button" class="chip active" data-tab="fleet">Fleet inventory</button>
<button type="button" class="chip" data-tab="firmware">Firmware / BIOS</button>
<button type="button" class="chip" data-tab="warranty">Warranty</button>
<button type="button" class="chip" data-tab="ome">OME reports</button>
<button type="button" class="chip" data-tab="brief">Customer brief</button>
</div>
<div class="reports-body" id="reports-mount">
<p class="hint">Select a report tab to load live OME data.</p>
</div>
</div>
<div class="scrim" id="scrim"></div>
<div class="node-tip hidden" id="node-tip" role="tooltip"></div>
@@ -443,8 +466,9 @@
<script src="/vendor/xterm/xterm.min.js?v=home1"></script>
<script src="/vendor/xterm/xterm-addon-fit.min.js?v=home1"></script>
<script src="/app.js?v=kpi1"></script>
<script src="/ops.js?v=kpi1"></script>
<script src="/ssh.js?v=kpi1"></script>
<script src="/app.js?v=reports1"></script>
<script src="/ops.js?v=reports1"></script>
<script src="/ssh.js?v=reports1"></script>
<script src="/reports.js?v=reports1"></script>
</body>
</html>
+4 -3
View File
@@ -92,13 +92,14 @@
}
function closeDrawers() {
["#chat-drawer", "#ops-drawer", "#ai-drawer"].forEach((id) => {
["#chat-drawer", "#ops-drawer", "#ai-drawer", "#reports-drawer"].forEach((id) => {
const el = $(id);
el?.classList.remove("open");
el?.setAttribute("aria-hidden", "true");
});
window.dispatchEvent(new CustomEvent("cockpit-close-drawers"));
const scrim = $("#scrim");
if (scrim && ["chat-drawer", "ops-drawer", "ai-drawer"].includes(scrim.dataset.mode)) {
if (scrim && ["chat-drawer", "ops-drawer", "ai-drawer", "reports-drawer"].includes(scrim.dataset.mode)) {
scrim.classList.remove("open");
delete scrim.dataset.mode;
}
@@ -854,7 +855,7 @@
const scrim = $("#scrim");
scrim?.addEventListener("click", () => {
const mode = scrim.dataset.mode;
if (mode === "chat-drawer" || mode === "ops-drawer" || mode === "ai-drawer") closeDrawers();
if (mode === "chat-drawer" || mode === "ops-drawer" || mode === "ai-drawer" || mode === "reports-drawer") closeDrawers();
});
initFeedDrag();
+476
View File
@@ -0,0 +1,476 @@
/* 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, "&amp;")
.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();
})();
+56
View File
@@ -1705,3 +1705,59 @@ a.conn-link:hover {
.kpi-layout { grid-template-columns: 1fr; }
.kpi-toolbar { grid-template-columns: 1fr 1fr; }
}
/* —— Reports drawer —— */
.reports-drawer { width: min(1120px, 96vw); }
.reports-tabs { padding: 0.65rem 1rem 0; gap: 0.4rem; }
.reports-body { padding: 0.75rem 1rem 1.25rem; overflow: auto; height: calc(100% - 96px); }
.rpt-toolbar {
display: flex; flex-wrap: wrap; gap: 0.5rem; align-items: center;
justify-content: space-between; margin-bottom: 0.65rem;
}
.rpt-toolbar .search { flex: 1; min-width: 180px; }
.rpt-exports { display: flex; flex-wrap: wrap; gap: 0.35rem; }
.rpt-meta { color: var(--muted); font-size: 0.78rem; margin-bottom: 0.55rem; }
.rpt-check { margin-left: 0.75rem; display: inline-flex; gap: 0.35rem; align-items: center; }
.rpt-table-wrap { overflow: auto; max-height: calc(100vh - 220px); border: 1px solid rgba(255,255,255,0.08); border-radius: 8px; }
.rpt-table { width: 100%; border-collapse: collapse; font-size: 0.78rem; }
.rpt-table th, .rpt-table td { padding: 0.45rem 0.55rem; text-align: left; border-bottom: 1px solid rgba(255,255,255,0.06); vertical-align: top; }
.rpt-table th { position: sticky; top: 0; background: #0e1620; color: var(--dell-bright); font-weight: 600; z-index: 1; }
.rpt-table tr:hover td { background: rgba(61,255,224,0.04); }
.rpt-table .sub { color: var(--muted); font-size: 0.7rem; margin-top: 0.15rem; }
code.st { font-family: var(--mono); color: #7ef0d8; font-weight: 600; letter-spacing: 0.02em; }
.rpt-badge { display: inline-block; padding: 0.1rem 0.4rem; border-radius: 4px; font-size: 0.68rem; font-weight: 600; text-transform: uppercase; }
.rpt-badge.ok { background: rgba(61,255,224,0.15); color: #3dffe0; }
.rpt-badge.bad { background: rgba(255,92,92,0.18); color: #ff8a8a; }
.rpt-badge.warn { background: rgba(255,184,77,0.18); color: #ffb84d; }
.rpt-badge.unk { background: rgba(255,255,255,0.08); color: var(--muted); }
.rpt-grid-2 { display: grid; grid-template-columns: 1fr 1fr; gap: 0.85rem; margin-bottom: 1rem; }
.rpt-panel { border: 1px solid rgba(255,255,255,0.08); border-radius: 8px; padding: 0.75rem; background: rgba(0,0,0,0.18); }
.rpt-panel h3, .rpt-h { margin: 0 0 0.5rem; font-size: 0.9rem; color: var(--dell-bright); }
.rpt-list { list-style: none; margin: 0 0 0.85rem; padding: 0; }
.rpt-list li { padding: 0.35rem 0; border-bottom: 1px solid rgba(255,255,255,0.05); font-size: 0.78rem; }
.rpt-list .sub { display: block; color: var(--muted); font-size: 0.7rem; }
.rpt-results { background: #0a1018; color: #c8d6e5; padding: 0.75rem; border-radius: 8px; max-height: 280px; overflow: auto; font-size: 0.72rem; }
.st-row .st-value { display: flex; align-items: center; gap: 0.4rem; flex-wrap: wrap; }
.wc-mount { margin: 0.65rem 0; }
.customer-brief { background: #0b121a; border: 1px solid rgba(255,255,255,0.08); border-radius: 10px; padding: 1.25rem; }
.brief-head { display: flex; gap: 0.85rem; align-items: flex-start; margin-bottom: 1rem; }
.brief-head h2 { margin: 0.15rem 0; }
.brief-kpis { display: grid; grid-template-columns: repeat(4, 1fr); gap: 0.55rem; margin: 0.75rem 0 1rem; }
.brief-kpis div { background: rgba(61,255,224,0.06); border: 1px solid rgba(61,255,224,0.12); border-radius: 8px; padding: 0.65rem; text-align: center; }
.brief-kpis em { display: block; font-style: normal; font-size: 1.35rem; font-weight: 700; color: var(--dell-bright); }
.brief-kpis span { font-size: 0.72rem; color: var(--muted); }
@media (max-width: 900px) {
.rpt-grid-2, .brief-kpis { grid-template-columns: 1fr 1fr; }
}
@media print {
body * { visibility: hidden !important; }
#reports-drawer, #reports-drawer * { visibility: visible !important; }
#reports-drawer {
position: absolute !important; left: 0; top: 0; width: 100% !important;
height: auto !important; background: white !important; color: #111 !important;
box-shadow: none !important; overflow: visible !important;
}
.drawer-head, .reports-tabs, .rpt-toolbar, .scrim { display: none !important; }
.customer-brief, .rpt-table { color: #111 !important; background: white !important; }
code.st { color: #0a5 !important; }
}