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:
@@ -1,14 +0,0 @@
|
||||
OME_URL=https://cov-omeprod01.dell-atc.lan
|
||||
OME_USER=admin
|
||||
OME_PASSWORD=CHANGE_ME
|
||||
POLL_INTERVAL=12
|
||||
OPENWEBUI_URL=http://atc-portal01.dell-atc.lan:3080
|
||||
CORS_ORIGINS=*
|
||||
GPU_METRICS_URL=http://10.0.10.106:9110
|
||||
VLLM_URL=http://10.0.10.106:8000/v1
|
||||
VLLM_MODEL=llama3-70b-gptq
|
||||
COCKPIT_DATA=/data
|
||||
OPENWEBUI_EMAIL=mohamed.el.kadi@dell.com
|
||||
OPENWEBUI_PASSWORD=CHANGE_ME
|
||||
VLLM_MAX_TOKENS=700
|
||||
CHAT_SYSTEM_CHARS=5500
|
||||
@@ -25,3 +25,9 @@
|
||||
- [ARCHITECTURE.md](docs/ARCHITECTURE.md)
|
||||
- [DESIGN.md](docs/DESIGN.md)
|
||||
- [FEATURES.md](docs/FEATURES.md)
|
||||
|
||||
## Reports demo
|
||||
|
||||
1. Open **Reports** → Firmware / BIOS (outdated only) — show Service Tag + current vs Dell catalog.
|
||||
2. Customer brief → Print / PDF for stakeholder handoff.
|
||||
3. Cockpit chat → “List every device with its Service Tag”.
|
||||
|
||||
@@ -92,3 +92,6 @@ ome-cockpit/
|
||||
## License / ownership
|
||||
|
||||
Internal Dell ATC lab tooling. Not for public redistribution of credentials or production secrets.
|
||||
|
||||
### Reports
|
||||
Open **Reports** for fleet CSV/JSON export, Dell catalog firmware compliance (BIOS/iDRAC), warranties, OME ReportDefs, and a printable customer brief. Chat always receives a Service Tag index.
|
||||
|
||||
+712
-9
@@ -35,10 +35,11 @@ class Settings(BaseSettings):
|
||||
vllm_model: str = "llama3-70b-gptq"
|
||||
vllm_max_model_len: int = 4096
|
||||
vllm_max_tokens: int = 700
|
||||
chat_system_chars: int = 5500
|
||||
chat_system_chars: int = 9000
|
||||
openwebui_email: str = ""
|
||||
openwebui_password: str = ""
|
||||
cockpit_data: str = "/data"
|
||||
reports_cache_ttl: float = 600.0
|
||||
|
||||
class Config:
|
||||
env_file = ".env"
|
||||
@@ -78,6 +79,77 @@ _lock = asyncio.Lock()
|
||||
DETAIL_CACHE: dict[int, dict] = {}
|
||||
PREV_DEVICES: dict[int, dict] = {}
|
||||
EVENT_FEED: list[dict] = []
|
||||
REPORT_CACHE: dict[str, Any] = {
|
||||
"warranties": None,
|
||||
"warranties_ts": 0.0,
|
||||
"compliance": None,
|
||||
"compliance_ts": 0.0,
|
||||
"baselines": None,
|
||||
"baselines_ts": 0.0,
|
||||
"catalogs": None,
|
||||
"catalogs_ts": 0.0,
|
||||
"report_defs": None,
|
||||
"report_defs_ts": 0.0,
|
||||
"jobs": None,
|
||||
"jobs_ts": 0.0,
|
||||
}
|
||||
|
||||
|
||||
async def ome_session(client: httpx.AsyncClient) -> tuple[str, dict, str]:
|
||||
"""Create an OME API session; returns (base, headers, session_id)."""
|
||||
base = settings.ome_url.rstrip("/")
|
||||
r = await client.post(
|
||||
f"{base}/api/SessionService/Sessions",
|
||||
json={
|
||||
"UserName": settings.ome_user,
|
||||
"Password": settings.ome_password,
|
||||
"SessionType": "API",
|
||||
},
|
||||
)
|
||||
r.raise_for_status()
|
||||
token = r.headers.get("X-Auth-Token")
|
||||
sid = str((r.json() or {}).get("Id") or "")
|
||||
headers = {"X-Auth-Token": token, "Accept": "application/json"}
|
||||
return base, headers, sid
|
||||
|
||||
|
||||
async def ome_session_delete(client: httpx.AsyncClient, base: str, headers: dict, sid: str) -> None:
|
||||
if not sid:
|
||||
return
|
||||
try:
|
||||
await client.delete(f"{base}/api/SessionService/Sessions('{sid}')", headers=headers)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def _cache_get(key: str) -> Any | None:
|
||||
ts = float(REPORT_CACHE.get(f"{key}_ts") or 0)
|
||||
if REPORT_CACHE.get(key) is not None and time.time() - ts < settings.reports_cache_ttl:
|
||||
return REPORT_CACHE.get(key)
|
||||
return None
|
||||
|
||||
|
||||
def _cache_set(key: str, value: Any) -> Any:
|
||||
REPORT_CACHE[key] = value
|
||||
REPORT_CACHE[f"{key}_ts"] = time.time()
|
||||
return value
|
||||
|
||||
|
||||
def csv_escape(val: Any) -> str:
|
||||
s = "" if val is None else str(val)
|
||||
if any(c in s for c in (",", '"', "\n", "\r")):
|
||||
return '"' + s.replace('"', '""') + '"'
|
||||
return s
|
||||
|
||||
|
||||
def rows_to_csv(rows: list[dict], columns: list[str] | None = None) -> str:
|
||||
if not rows and not columns:
|
||||
return ""
|
||||
cols = columns or list(rows[0].keys())
|
||||
lines = [",".join(cols)]
|
||||
for row in rows:
|
||||
lines.append(",".join(csv_escape(row.get(c)) for c in cols))
|
||||
return "\n".join(lines) + "\n"
|
||||
|
||||
|
||||
def subnet_of(ip: str | None) -> str:
|
||||
@@ -235,7 +307,9 @@ async def ome_fetch() -> dict:
|
||||
"id": d.get("Id"),
|
||||
"name": d.get("DeviceName") or f"device-{d.get('Id')}",
|
||||
"model": model,
|
||||
"service_tag": d.get("DeviceServiceTag"),
|
||||
"service_tag": d.get("DeviceServiceTag")
|
||||
or d.get("Identifier")
|
||||
or d.get("ChassisServiceTag"),
|
||||
"type": dtype,
|
||||
"sub_type": sub,
|
||||
"connected": conn,
|
||||
@@ -601,6 +675,22 @@ async def startup():
|
||||
init_db()
|
||||
asyncio.create_task(poll_loop())
|
||||
asyncio.create_task(gpu_loop())
|
||||
asyncio.create_task(warm_reports_cache())
|
||||
|
||||
|
||||
async def warm_reports_cache():
|
||||
"""Background warm of warranty/compliance for chat + inspector."""
|
||||
await asyncio.sleep(8)
|
||||
try:
|
||||
await fetch_warranties()
|
||||
await fetch_compliance()
|
||||
log.info(
|
||||
"Reports cache warmed: warranties=%s compliance_outdated=%s",
|
||||
(REPORT_CACHE.get("warranties") or {}).get("count"),
|
||||
((REPORT_CACHE.get("compliance") or {}).get("summary") or {}).get("outdated_devices"),
|
||||
)
|
||||
except Exception as e:
|
||||
log.warning("Reports cache warm failed: %s", e)
|
||||
|
||||
|
||||
@app.get("/api/health")
|
||||
@@ -793,11 +883,32 @@ def build_fleet_context(focus_device_id: int | None = None, max_chars: int | Non
|
||||
critical = [a for a in alerts if a.get("severity") == "Critical"][:8]
|
||||
warnings = [a for a in alerts if a.get("severity") == "Warning"][:5]
|
||||
hottest = (ctx.get("hottest") or [])[:5]
|
||||
compliance = _cache_get("compliance") or REPORT_CACHE.get("compliance") or {}
|
||||
comp_sum = (compliance or {}).get("summary") or {}
|
||||
|
||||
# SERVICE TAG INDEX first — must survive truncation
|
||||
st_lines = [
|
||||
"SERVICE TAG INDEX (use these Service Tags in every node answer):",
|
||||
]
|
||||
for d in sorted(devices, key=lambda x: (x.get("name") or "").lower()):
|
||||
st = (d.get("service_tag") or "").strip() or "NONE"
|
||||
st_lines.append(
|
||||
"- ST={st} | {name} | ip={ip} | model={model} | connected={conn}".format(
|
||||
st=st,
|
||||
name=(d.get("name") or "")[:40],
|
||||
ip=d.get("ip") or "—",
|
||||
model=(d.get("model") or "")[:28],
|
||||
conn="yes" if d.get("connected") else "no",
|
||||
)
|
||||
)
|
||||
st_block = "\n".join(st_lines)
|
||||
|
||||
lines = [
|
||||
"You are OpenManage Cockpit Copilot for Dell ATC. Be concise and operational.",
|
||||
"Admins: Jody van Dongen, Laurens Rammers, Mohamed El Kadi.",
|
||||
"Use ONLY this live snapshot. Unknown => say unknown.",
|
||||
"ALWAYS include Service Tag (ST=...), model, and IP when discussing any system.",
|
||||
"If a Service Tag is missing in the index, say so explicitly.",
|
||||
"",
|
||||
"FLEET: total={t} connected={c} offline={o} watts={w} alerts_total={a}".format(
|
||||
t=summary.get("total"),
|
||||
@@ -806,11 +917,21 @@ def build_fleet_context(focus_device_id: int | None = None, max_chars: int | Non
|
||||
w=summary.get("total_watts"),
|
||||
a=summary.get("alerts_total"),
|
||||
),
|
||||
"CONNECTED:",
|
||||
]
|
||||
if comp_sum:
|
||||
lines.append(
|
||||
"FIRMWARE COMPLIANCE (Dell catalog baseline): outdated_devices={od} critical_components={cc} baseline={bn}".format(
|
||||
od=comp_sum.get("outdated_devices"),
|
||||
cc=comp_sum.get("critical_components"),
|
||||
bn=(comp_sum.get("baseline_name") or "")[:40],
|
||||
)
|
||||
)
|
||||
|
||||
lines.append("CONNECTED:")
|
||||
for d in connected[:18]:
|
||||
lines.append(
|
||||
"- {name} ip={ip} model={model} W={watts} st={status}".format(
|
||||
"- ST={st} {name} ip={ip} model={model} W={watts} status={status}".format(
|
||||
st=(d.get("service_tag") or "NONE"),
|
||||
name=(d.get("name") or "")[:36],
|
||||
ip=d.get("ip"),
|
||||
model=(d.get("model") or "")[:28],
|
||||
@@ -885,24 +1006,33 @@ def build_fleet_context(focus_device_id: int | None = None, max_chars: int | Non
|
||||
lines.append("FOCUS:")
|
||||
if node:
|
||||
lines.append(
|
||||
"{name} id={id} ip={ip} model={model} W={watts} connected={conn} status={st}".format(
|
||||
"ST={st} {name} id={id} ip={ip} model={model} W={watts} connected={conn} status={stt}".format(
|
||||
st=(node.get("service_tag") or "NONE"),
|
||||
name=node.get("name"),
|
||||
id=node.get("id"),
|
||||
ip=node.get("ip"),
|
||||
model=node.get("model"),
|
||||
watts=node.get("watts"),
|
||||
conn=node.get("connected"),
|
||||
st=node.get("status"),
|
||||
stt=node.get("status"),
|
||||
)
|
||||
)
|
||||
related = [a for a in alerts if a.get("device_id") == focus_device_id][:5]
|
||||
for a in related:
|
||||
lines.append("alert {sev}: {msg}".format(sev=a.get("severity"), msg=(a.get("message") or "")[:100]))
|
||||
lines.append(
|
||||
"alert {sev}: {msg}".format(sev=a.get("severity"), msg=(a.get("message") or "")[:100])
|
||||
)
|
||||
else:
|
||||
lines.append("device_id=%s missing" % focus_device_id)
|
||||
|
||||
lines.append("Reply with concrete names, IPs, and next actions.")
|
||||
out = "\n".join(lines)
|
||||
lines.append(
|
||||
"Reply with concrete Service Tags, names, IPs, models, and next actions."
|
||||
)
|
||||
body = "\n".join(lines)
|
||||
budget = max(800, limit - len(st_block) - 40)
|
||||
if len(body) > budget:
|
||||
body = body[: budget - 20] + "\n…[truncated]"
|
||||
out = st_block + "\n\n" + body
|
||||
if len(out) > limit:
|
||||
out = out[: limit - 20] + "\n…[truncated]"
|
||||
return out
|
||||
@@ -1489,6 +1619,574 @@ async def ws_ssh(ws: WebSocket):
|
||||
log.info("SSH session end id=%s target=%s active=%s", session_id, client_host, len(SSH_SESSIONS))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Reports / compliance / warranty / export (read-only OME surfaces)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
async def fetch_warranties(force: bool = False) -> dict:
|
||||
if not force:
|
||||
cached = _cache_get("warranties")
|
||||
if cached is not None:
|
||||
return cached
|
||||
rows: list[dict] = []
|
||||
async with httpx.AsyncClient(verify=False, timeout=60.0) as client:
|
||||
base, headers, sid = await ome_session(client)
|
||||
try:
|
||||
skip = 0
|
||||
while True:
|
||||
r = await client.get(
|
||||
f"{base}/api/WarrantyService/Warranties",
|
||||
headers=headers,
|
||||
params={"$top": 200, "$skip": skip},
|
||||
)
|
||||
r.raise_for_status()
|
||||
chunk = (r.json() or {}).get("value") or []
|
||||
if not chunk:
|
||||
break
|
||||
for w in chunk:
|
||||
rows.append(
|
||||
{
|
||||
"id": w.get("Id"),
|
||||
"device_id": w.get("DeviceId"),
|
||||
"service_tag": w.get("DeviceIdentifier"),
|
||||
"device_name": w.get("DeviceName"),
|
||||
"model": w.get("DeviceModel"),
|
||||
"service_level": w.get("ServiceLevelDescription") or w.get("ServiceLevelCode"),
|
||||
"start_date": w.get("StartDate"),
|
||||
"end_date": w.get("EndDate"),
|
||||
"days_remaining": w.get("DaysRemaining"),
|
||||
"ship_date": w.get("SystemShipDate"),
|
||||
"state": w.get("State"),
|
||||
"country": w.get("CountryLookupCode"),
|
||||
}
|
||||
)
|
||||
skip += len(chunk)
|
||||
if len(chunk) < 200:
|
||||
break
|
||||
finally:
|
||||
await ome_session_delete(client, base, headers, sid)
|
||||
# Prefer longest-remaining warranty per device
|
||||
by_device: dict[int, dict] = {}
|
||||
for w in rows:
|
||||
did = w.get("device_id")
|
||||
if did is None:
|
||||
continue
|
||||
prev = by_device.get(did)
|
||||
if not prev or int(w.get("days_remaining") or 0) > int(prev.get("days_remaining") or 0):
|
||||
by_device[did] = w
|
||||
elif int(w.get("days_remaining") or 0) == int(prev.get("days_remaining") or 0):
|
||||
# keep later end date
|
||||
if str(w.get("end_date") or "") > str(prev.get("end_date") or ""):
|
||||
by_device[did] = w
|
||||
payload = {
|
||||
"updated_at": time.time(),
|
||||
"count": len(rows),
|
||||
"items": rows,
|
||||
"by_device": {str(k): v for k, v in by_device.items()},
|
||||
}
|
||||
return _cache_set("warranties", payload)
|
||||
|
||||
|
||||
async def fetch_baselines(force: bool = False) -> dict:
|
||||
if not force:
|
||||
cached = _cache_get("baselines")
|
||||
if cached is not None:
|
||||
return cached
|
||||
async with httpx.AsyncClient(verify=False, timeout=60.0) as client:
|
||||
base, headers, sid = await ome_session(client)
|
||||
try:
|
||||
r = await client.get(f"{base}/api/UpdateService/Baselines", headers=headers)
|
||||
r.raise_for_status()
|
||||
items = []
|
||||
for b in (r.json() or {}).get("value") or []:
|
||||
summary = b.get("ComplianceSummary") or {}
|
||||
items.append(
|
||||
{
|
||||
"id": b.get("Id"),
|
||||
"name": b.get("Name"),
|
||||
"description": b.get("Description"),
|
||||
"catalog_id": b.get("CatalogId"),
|
||||
"catalog_type": b.get("CatalogType"),
|
||||
"last_run": b.get("LastRun"),
|
||||
"task_id": b.get("TaskId"),
|
||||
"compliance_status": summary.get("ComplianceStatus"),
|
||||
"critical": summary.get("NumberOfCritical"),
|
||||
"warning": summary.get("NumberOfWarning"),
|
||||
"normal": summary.get("NumberOfNormal"),
|
||||
"downgrade": summary.get("NumberOfDowngrade"),
|
||||
"unknown": summary.get("NumberOfUnknown"),
|
||||
}
|
||||
)
|
||||
finally:
|
||||
await ome_session_delete(client, base, headers, sid)
|
||||
return _cache_set("baselines", {"updated_at": time.time(), "items": items})
|
||||
|
||||
|
||||
async def fetch_catalogs(force: bool = False) -> dict:
|
||||
if not force:
|
||||
cached = _cache_get("catalogs")
|
||||
if cached is not None:
|
||||
return cached
|
||||
async with httpx.AsyncClient(verify=False, timeout=60.0) as client:
|
||||
base, headers, sid = await ome_session(client)
|
||||
try:
|
||||
r = await client.get(f"{base}/api/UpdateService/Catalogs", headers=headers)
|
||||
r.raise_for_status()
|
||||
items = []
|
||||
for c in (r.json() or {}).get("value") or []:
|
||||
repo = c.get("Repository") or {}
|
||||
items.append(
|
||||
{
|
||||
"id": c.get("Id"),
|
||||
"filename": c.get("Filename") or c.get("SourcePath"),
|
||||
"source": repo.get("Source") or c.get("Source"),
|
||||
"repository_name": repo.get("Name"),
|
||||
"repository_type": repo.get("RepositoryType") or c.get("RepositoryType"),
|
||||
"owner": c.get("Owner"),
|
||||
"status": (c.get("Status") or {}).get("Name") if isinstance(c.get("Status"), dict) else c.get("Status"),
|
||||
"last_updated": c.get("LastUpdated") or c.get("BundlesUpdateTime"),
|
||||
}
|
||||
)
|
||||
finally:
|
||||
await ome_session_delete(client, base, headers, sid)
|
||||
return _cache_set("catalogs", {"updated_at": time.time(), "items": items})
|
||||
|
||||
|
||||
def _pick_primary_baseline(baselines: list[dict]) -> dict | None:
|
||||
if not baselines:
|
||||
return None
|
||||
for b in baselines:
|
||||
name = (b.get("name") or "").lower()
|
||||
if "dell" in name and "online" in name:
|
||||
return b
|
||||
# most critical first
|
||||
return sorted(baselines, key=lambda x: int(x.get("critical") or 0), reverse=True)[0]
|
||||
|
||||
|
||||
async def fetch_compliance(force: bool = False, baseline_id: int | None = None) -> dict:
|
||||
if not force and baseline_id is None:
|
||||
cached = _cache_get("compliance")
|
||||
if cached is not None:
|
||||
return cached
|
||||
bl = await fetch_baselines(force=force)
|
||||
primary = None
|
||||
if baseline_id is not None:
|
||||
primary = next((b for b in bl["items"] if b.get("id") == baseline_id), None)
|
||||
if primary is None:
|
||||
primary = _pick_primary_baseline(bl["items"])
|
||||
if not primary:
|
||||
payload = {
|
||||
"updated_at": time.time(),
|
||||
"summary": {"outdated_devices": 0, "critical_components": 0, "baseline_name": None},
|
||||
"devices": [],
|
||||
"components": [],
|
||||
"baselines": bl["items"],
|
||||
}
|
||||
return _cache_set("compliance", payload)
|
||||
|
||||
bid = primary["id"]
|
||||
devices_out: list[dict] = []
|
||||
components_out: list[dict] = []
|
||||
fleet_by_id = {d.get("id"): d for d in STATE.get("devices") or []}
|
||||
async with httpx.AsyncClient(verify=False, timeout=120.0) as client:
|
||||
base, headers, sid = await ome_session(client)
|
||||
try:
|
||||
r = await client.get(
|
||||
f"{base}/api/UpdateService/Baselines({bid})/DeviceComplianceReports",
|
||||
headers=headers,
|
||||
)
|
||||
r.raise_for_status()
|
||||
for dcr in (r.json() or {}).get("value") or []:
|
||||
did = dcr.get("DeviceId")
|
||||
fleet = fleet_by_id.get(did) or {}
|
||||
st = dcr.get("ServiceTag") or fleet.get("service_tag")
|
||||
comps = dcr.get("ComponentComplianceReports") or []
|
||||
device_row = {
|
||||
"device_id": did,
|
||||
"service_tag": st,
|
||||
"name": fleet.get("name") or dcr.get("DeviceName") or st,
|
||||
"model": dcr.get("DeviceModel") or fleet.get("model"),
|
||||
"ip": fleet.get("ip"),
|
||||
"firmware_status": dcr.get("FirmwareStatus"),
|
||||
"compliance_status": dcr.get("ComplianceStatus"),
|
||||
"reboot_required": dcr.get("RebootRequired"),
|
||||
"component_count": len(comps),
|
||||
"dell_uri": None,
|
||||
}
|
||||
devices_out.append(device_row)
|
||||
for comp in comps:
|
||||
uri = comp.get("Uri")
|
||||
if uri and not device_row["dell_uri"]:
|
||||
device_row["dell_uri"] = uri
|
||||
components_out.append(
|
||||
{
|
||||
"device_id": did,
|
||||
"service_tag": st,
|
||||
"device_name": device_row["name"],
|
||||
"model": device_row["model"],
|
||||
"ip": device_row["ip"],
|
||||
"component": comp.get("Name"),
|
||||
"component_type": comp.get("ComponentType"),
|
||||
"current_version": comp.get("CurrentVersion"),
|
||||
"catalog_version": comp.get("Version"),
|
||||
"update_action": comp.get("UpdateAction"),
|
||||
"criticality": comp.get("Criticality"),
|
||||
"compliance_status": comp.get("ComplianceStatus"),
|
||||
"reboot_required": comp.get("RebootRequired"),
|
||||
"dell_uri": uri,
|
||||
"path": comp.get("Path"),
|
||||
"baseline_id": bid,
|
||||
"baseline_name": primary.get("name"),
|
||||
"status_badge": (
|
||||
"outdated"
|
||||
if str(comp.get("UpdateAction") or "").upper() == "UPGRADE"
|
||||
or str(comp.get("ComplianceStatus") or "").upper()
|
||||
in ("CRITICAL", "WARNING", "NONCOMPLIANT", "NON-COMPLIANT")
|
||||
else "current"
|
||||
if str(comp.get("UpdateAction") or "").upper() in ("EQUAL", "NONE", "")
|
||||
and str(comp.get("ComplianceStatus") or "").upper()
|
||||
in ("", "OK", "COMPLIANT", "NORMAL", "DOWNGRADE")
|
||||
else "unknown"
|
||||
),
|
||||
}
|
||||
)
|
||||
finally:
|
||||
await ome_session_delete(client, base, headers, sid)
|
||||
|
||||
outdated = [
|
||||
d
|
||||
for d in devices_out
|
||||
if str(d.get("compliance_status") or "").upper() in ("CRITICAL", "WARNING")
|
||||
or str(d.get("firmware_status") or "").lower() in ("non-compliant", "noncompliant")
|
||||
]
|
||||
crit_comps = [
|
||||
c
|
||||
for c in components_out
|
||||
if str(c.get("compliance_status") or "").upper() == "CRITICAL"
|
||||
or str(c.get("update_action") or "").upper() == "UPGRADE"
|
||||
]
|
||||
payload = {
|
||||
"updated_at": time.time(),
|
||||
"baseline": primary,
|
||||
"baselines": bl["items"],
|
||||
"summary": {
|
||||
"baseline_id": bid,
|
||||
"baseline_name": primary.get("name"),
|
||||
"outdated_devices": len(outdated),
|
||||
"devices_in_report": len(devices_out),
|
||||
"critical_components": len(crit_comps),
|
||||
"components_total": len(components_out),
|
||||
"compliance_status": primary.get("compliance_status"),
|
||||
"last_run": primary.get("last_run"),
|
||||
},
|
||||
"devices": devices_out,
|
||||
"components": components_out,
|
||||
}
|
||||
if baseline_id is None:
|
||||
return _cache_set("compliance", payload)
|
||||
return payload
|
||||
|
||||
|
||||
async def fetch_report_defs(force: bool = False) -> dict:
|
||||
if not force:
|
||||
cached = _cache_get("report_defs")
|
||||
if cached is not None:
|
||||
return cached
|
||||
items = []
|
||||
async with httpx.AsyncClient(verify=False, timeout=60.0) as client:
|
||||
base, headers, sid = await ome_session(client)
|
||||
try:
|
||||
r = await client.get(f"{base}/api/ReportService/ReportDefs", headers=headers)
|
||||
r.raise_for_status()
|
||||
for d in (r.json() or {}).get("value") or []:
|
||||
cols = [c.get("Name") for c in (d.get("ColumnNames") or []) if c.get("Name")]
|
||||
items.append(
|
||||
{
|
||||
"id": d.get("Id"),
|
||||
"name": d.get("Name"),
|
||||
"description": d.get("Description"),
|
||||
"category": d.get("Category") or d.get("FilterGroupName"),
|
||||
"is_builtin": d.get("IsBuiltIn"),
|
||||
"last_run": d.get("LastRunDate"),
|
||||
"last_run_by": d.get("LastRunBy"),
|
||||
"columns": cols,
|
||||
}
|
||||
)
|
||||
finally:
|
||||
await ome_session_delete(client, base, headers, sid)
|
||||
items.sort(key=lambda x: ((x.get("category") or ""), (x.get("name") or "").lower()))
|
||||
return _cache_set("report_defs", {"updated_at": time.time(), "items": items})
|
||||
|
||||
|
||||
async def fetch_jobs(force: bool = False, top: int = 40) -> dict:
|
||||
if not force:
|
||||
cached = _cache_get("jobs")
|
||||
if cached is not None:
|
||||
return cached
|
||||
items = []
|
||||
async with httpx.AsyncClient(verify=False, timeout=60.0) as client:
|
||||
base, headers, sid = await ome_session(client)
|
||||
try:
|
||||
r = await client.get(
|
||||
f"{base}/api/JobService/Jobs",
|
||||
headers=headers,
|
||||
params={"$top": top},
|
||||
)
|
||||
r.raise_for_status()
|
||||
for j in (r.json() or {}).get("value") or []:
|
||||
status = j.get("LastRunStatus") or {}
|
||||
items.append(
|
||||
{
|
||||
"id": j.get("Id"),
|
||||
"name": j.get("JobName") or j.get("Name"),
|
||||
"status": status.get("Name") if isinstance(status, dict) else status,
|
||||
"job_type": (j.get("JobType") or {}).get("Name")
|
||||
if isinstance(j.get("JobType"), dict)
|
||||
else j.get("JobType"),
|
||||
"last_run": j.get("LastRunStatus") and (j.get("LastRunDate") or j.get("StartTime")),
|
||||
"progress": j.get("Progress") or j.get("PercentComplete"),
|
||||
"created_by": j.get("CreatedBy"),
|
||||
}
|
||||
)
|
||||
finally:
|
||||
await ome_session_delete(client, base, headers, sid)
|
||||
return _cache_set("jobs", {"updated_at": time.time(), "items": items})
|
||||
|
||||
|
||||
def build_fleet_report_rows(warranties: dict | None = None) -> list[dict]:
|
||||
by_dev = (warranties or {}).get("by_device") or {}
|
||||
rows = []
|
||||
for d in STATE.get("devices") or []:
|
||||
w = by_dev.get(str(d.get("id"))) or {}
|
||||
rows.append(
|
||||
{
|
||||
"id": d.get("id"),
|
||||
"name": d.get("name"),
|
||||
"service_tag": d.get("service_tag"),
|
||||
"model": d.get("model"),
|
||||
"ip": d.get("ip"),
|
||||
"subnet": d.get("subnet"),
|
||||
"type": d.get("type"),
|
||||
"sub_type": d.get("sub_type"),
|
||||
"is_server": d.get("is_server"),
|
||||
"is_idrac": d.get("is_idrac"),
|
||||
"connected": d.get("connected"),
|
||||
"powered_on": d.get("powered_on"),
|
||||
"power_state": d.get("power_state"),
|
||||
"status": d.get("status"),
|
||||
"watts": d.get("watts"),
|
||||
"avg_watts": d.get("avg_watts"),
|
||||
"peak_watts": d.get("peak_watts"),
|
||||
"last_status_time": d.get("last_status_time"),
|
||||
"last_inventory_time": d.get("last_inventory_time"),
|
||||
"warranty_end": w.get("end_date"),
|
||||
"warranty_days_remaining": w.get("days_remaining"),
|
||||
"warranty_service_level": w.get("service_level"),
|
||||
}
|
||||
)
|
||||
rows.sort(key=lambda r: (r.get("name") or "").lower())
|
||||
return rows
|
||||
|
||||
|
||||
@app.get("/api/warranties")
|
||||
async def api_warranties(force: bool = False):
|
||||
return await fetch_warranties(force=force)
|
||||
|
||||
|
||||
@app.get("/api/compliance")
|
||||
async def api_compliance(force: bool = False, baseline_id: int | None = None):
|
||||
return await fetch_compliance(force=force, baseline_id=baseline_id)
|
||||
|
||||
|
||||
@app.get("/api/baselines")
|
||||
async def api_baselines(force: bool = False):
|
||||
return await fetch_baselines(force=force)
|
||||
|
||||
|
||||
@app.get("/api/catalogs")
|
||||
async def api_catalogs(force: bool = False):
|
||||
return await fetch_catalogs(force=force)
|
||||
|
||||
|
||||
@app.get("/api/ome/report-defs")
|
||||
async def api_report_defs(force: bool = False):
|
||||
return await fetch_report_defs(force=force)
|
||||
|
||||
|
||||
@app.get("/api/ome/jobs")
|
||||
async def api_ome_jobs(force: bool = False):
|
||||
return await fetch_jobs(force=force)
|
||||
|
||||
|
||||
class ReportRunIn(BaseModel):
|
||||
report_def_id: int
|
||||
|
||||
|
||||
@app.post("/api/ome/reports/run")
|
||||
async def api_ome_report_run(payload: ReportRunIn):
|
||||
async with httpx.AsyncClient(verify=False, timeout=60.0) as client:
|
||||
base, headers, sid = await ome_session(client)
|
||||
try:
|
||||
r = await client.post(
|
||||
f"{base}/api/ReportService/Actions/ReportService.RunReport",
|
||||
headers=headers,
|
||||
json={"ReportDefId": payload.report_def_id},
|
||||
)
|
||||
if r.status_code >= 400:
|
||||
raise HTTPException(r.status_code, r.text[:500])
|
||||
job_id = r.json() if isinstance(r.json(), (int, str)) else (r.json() or {}).get("Id") or r.text
|
||||
return {
|
||||
"job_id": job_id,
|
||||
"report_def_id": payload.report_def_id,
|
||||
"message": "Report job started in OME. Poll results shortly.",
|
||||
"results_url": f"/api/ome/reports/{payload.report_def_id}/results",
|
||||
}
|
||||
finally:
|
||||
await ome_session_delete(client, base, headers, sid)
|
||||
|
||||
|
||||
@app.get("/api/ome/reports/{report_def_id}/results")
|
||||
async def api_ome_report_results(report_def_id: int):
|
||||
async with httpx.AsyncClient(verify=False, timeout=60.0) as client:
|
||||
base, headers, sid = await ome_session(client)
|
||||
try:
|
||||
r = await client.get(
|
||||
f"{base}/api/ReportService/ReportDefs({report_def_id})/ReportResults",
|
||||
headers=headers,
|
||||
)
|
||||
if r.status_code >= 400:
|
||||
raise HTTPException(
|
||||
r.status_code,
|
||||
(r.json().get("error", {}) or {}).get("message")
|
||||
if r.headers.get("content-type", "").startswith("application/json")
|
||||
else r.text[:500],
|
||||
)
|
||||
return r.json()
|
||||
finally:
|
||||
await ome_session_delete(client, base, headers, sid)
|
||||
|
||||
|
||||
@app.get("/api/reports/fleet")
|
||||
async def api_reports_fleet(force: bool = False):
|
||||
warranties = await fetch_warranties(force=force)
|
||||
rows = build_fleet_report_rows(warranties)
|
||||
return {
|
||||
"updated_at": STATE.get("updated_at"),
|
||||
"summary": STATE.get("summary"),
|
||||
"ome": STATE.get("ome"),
|
||||
"count": len(rows),
|
||||
"rows": rows,
|
||||
}
|
||||
|
||||
|
||||
@app.get("/api/reports/firmware")
|
||||
async def api_reports_firmware(force: bool = False, baseline_id: int | None = None):
|
||||
return await fetch_compliance(force=force, baseline_id=baseline_id)
|
||||
|
||||
|
||||
@app.get("/api/reports/brief")
|
||||
async def api_reports_brief(force: bool = False):
|
||||
warranties = await fetch_warranties(force=force)
|
||||
compliance = await fetch_compliance(force=force)
|
||||
fleet_rows = build_fleet_report_rows(warranties)
|
||||
critical = [a for a in (STATE.get("alerts") or []) if a.get("severity") == "Critical"][:15]
|
||||
expiring = sorted(
|
||||
[
|
||||
w
|
||||
for w in (warranties.get("items") or [])
|
||||
if w.get("days_remaining") is not None and int(w.get("days_remaining") or 0) <= 90
|
||||
],
|
||||
key=lambda x: int(x.get("days_remaining") or 0),
|
||||
)[:25]
|
||||
outdated = [
|
||||
d
|
||||
for d in (compliance.get("devices") or [])
|
||||
if str(d.get("compliance_status") or "").upper() in ("CRITICAL", "WARNING")
|
||||
or str(d.get("firmware_status") or "").lower() in ("non-compliant", "noncompliant")
|
||||
]
|
||||
return {
|
||||
"generated_at": time.time(),
|
||||
"ome": STATE.get("ome"),
|
||||
"summary": STATE.get("summary"),
|
||||
"compliance_summary": compliance.get("summary"),
|
||||
"critical_alerts": critical,
|
||||
"outdated_devices": outdated[:40],
|
||||
"warranty_expiring": expiring,
|
||||
"fleet": fleet_rows,
|
||||
"baseline": compliance.get("baseline"),
|
||||
}
|
||||
|
||||
|
||||
@app.get("/api/export/{kind}")
|
||||
async def api_export(kind: str, fmt: str = "csv", force: bool = False, baseline_id: int | None = None):
|
||||
kind = kind.lower().strip()
|
||||
fmt = fmt.lower().strip()
|
||||
if fmt not in ("csv", "json"):
|
||||
raise HTTPException(400, "fmt must be csv or json")
|
||||
|
||||
if kind in ("fleet", "inventory"):
|
||||
data = await api_reports_fleet(force=force)
|
||||
rows = data["rows"]
|
||||
filename = f"ome-fleet-{int(time.time())}"
|
||||
elif kind in ("firmware", "compliance"):
|
||||
data = await fetch_compliance(force=force, baseline_id=baseline_id)
|
||||
rows = data.get("components") or []
|
||||
filename = f"ome-firmware-compliance-{int(time.time())}"
|
||||
elif kind == "warranty":
|
||||
data = await fetch_warranties(force=force)
|
||||
rows = data.get("items") or []
|
||||
filename = f"ome-warranties-{int(time.time())}"
|
||||
elif kind == "brief":
|
||||
data = await api_reports_brief(force=force)
|
||||
if fmt == "json":
|
||||
return Response(
|
||||
content=json.dumps(data, indent=2, default=str),
|
||||
media_type="application/json",
|
||||
headers={"Content-Disposition": f'attachment; filename="ome-customer-brief-{int(time.time())}.json"'},
|
||||
)
|
||||
# flatten brief as fleet CSV appendix
|
||||
rows = data.get("fleet") or []
|
||||
filename = f"ome-customer-brief-fleet-{int(time.time())}"
|
||||
else:
|
||||
raise HTTPException(404, "Unknown export kind. Use fleet|firmware|warranty|brief")
|
||||
|
||||
if fmt == "json":
|
||||
body = json.dumps({"kind": kind, "count": len(rows), "rows": rows}, indent=2, default=str)
|
||||
return Response(
|
||||
content=body,
|
||||
media_type="application/json",
|
||||
headers={"Content-Disposition": f'attachment; filename="{filename}.json"'},
|
||||
)
|
||||
csv_body = rows_to_csv(rows)
|
||||
return Response(
|
||||
content=csv_body,
|
||||
media_type="text/csv",
|
||||
headers={"Content-Disposition": f'attachment; filename="{filename}.csv"'},
|
||||
)
|
||||
|
||||
|
||||
@app.get("/api/devices/{device_id}/warranty")
|
||||
async def device_warranty(device_id: int, force: bool = False):
|
||||
warranties = await fetch_warranties(force=force)
|
||||
items = [w for w in warranties.get("items") or [] if w.get("device_id") == device_id]
|
||||
primary = (warranties.get("by_device") or {}).get(str(device_id))
|
||||
return {"device_id": device_id, "primary": primary, "items": items}
|
||||
|
||||
|
||||
@app.get("/api/devices/{device_id}/compliance")
|
||||
async def device_compliance(device_id: int, force: bool = False):
|
||||
compliance = await fetch_compliance(force=force)
|
||||
device = next((d for d in compliance.get("devices") or [] if d.get("device_id") == device_id), None)
|
||||
comps = [c for c in compliance.get("components") or [] if c.get("device_id") == device_id]
|
||||
return {
|
||||
"device_id": device_id,
|
||||
"baseline": compliance.get("baseline"),
|
||||
"device": device,
|
||||
"components": comps,
|
||||
}
|
||||
|
||||
|
||||
@app.get("/api/ssh/sessions")
|
||||
async def api_ssh_sessions():
|
||||
"""Ops visibility: active SSH bridges (no secrets)."""
|
||||
@@ -1561,6 +2259,11 @@ async def vendor_xterm_fit_js():
|
||||
)
|
||||
|
||||
|
||||
@app.get("/reports.js")
|
||||
async def reports_js():
|
||||
return FileResponse(STATIC_DIR / "reports.js", media_type="application/javascript")
|
||||
|
||||
|
||||
@app.get("/dell.png")
|
||||
async def dell_png():
|
||||
return FileResponse(STATIC_DIR / "dell.png", media_type="image/png")
|
||||
|
||||
+19
@@ -201,3 +201,22 @@ Ops visibility (no secrets):
|
||||
Mapped via `pydantic-settings` from `.env` (see `.env.example`):
|
||||
|
||||
`OME_URL`, `OME_USER`, `OME_PASSWORD`, `POLL_INTERVAL`, `OPENWEBUI_URL`, `OPENWEBUI_EMAIL`, `OPENWEBUI_PASSWORD`, `GPU_METRICS_URL`, `VLLM_URL`, `VLLM_MODEL`, `VLLM_MAX_TOKENS`, `CHAT_SYSTEM_CHARS`, `COCKPIT_DATA`, `CORS_ORIGINS`
|
||||
|
||||
## Reports / compliance
|
||||
|
||||
| Method | Path | Notes |
|
||||
|--------|------|-------|
|
||||
| GET | `/api/reports/fleet` | Fleet matrix + warranty join |
|
||||
| GET | `/api/reports/firmware` | Alias of compliance components |
|
||||
| GET | `/api/reports/brief` | Customer brief payload |
|
||||
| GET | `/api/compliance` | Dell baseline device/component compliance |
|
||||
| GET | `/api/warranties` | WarrantyService dump |
|
||||
| GET | `/api/baselines` | UpdateService baselines |
|
||||
| GET | `/api/catalogs` | UpdateService catalogs |
|
||||
| GET | `/api/ome/report-defs` | ReportService definitions |
|
||||
| POST | `/api/ome/reports/run` | `{report_def_id}` starts OME report job |
|
||||
| GET | `/api/ome/reports/{id}/results` | Report results when available |
|
||||
| GET | `/api/ome/jobs` | Recent jobs (read-only) |
|
||||
| GET | `/api/export/{kind}?fmt=csv\|json` | Downloads |
|
||||
| GET | `/api/devices/{id}/warranty` | Per-device warranty |
|
||||
| GET | `/api/devices/{id}/compliance` | Per-device Dell catalog deltas |
|
||||
|
||||
@@ -79,3 +79,10 @@ Every top KPI tile opens an interactive modal:
|
||||
- Dell logo → cockpit home
|
||||
- Ticker with pulse, power, alerts, visible count
|
||||
- Drawers: Chat, Ops, OpenManage AI iframe
|
||||
|
||||
## Reports & Dell compliance (read-only)
|
||||
|
||||
- **Reports** drawer: Fleet inventory, Firmware/BIOS vs Dell catalog baseline, Warranty, OME ReportDefs, Customer brief (print/PDF).
|
||||
- Exports: CSV/JSON via `/api/export/{fleet|firmware|warranty|brief}`.
|
||||
- Compliance sourced from OME `UpdateService` baselines (Dell Online Catalog) with dell.com driver URIs.
|
||||
- Chat includes a full **Service Tag index** and must cite ST + model + IP.
|
||||
|
||||
@@ -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 & 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
@@ -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 & 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>
|
||||
|
||||
@@ -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
@@ -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, "&")
|
||||
.replace(/</g, "<")
|
||||
.replace(/>/g, ">")
|
||||
.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"].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();
|
||||
})();
|
||||
@@ -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; }
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user