diff --git a/.env.example b/.env.example deleted file mode 100644 index a5d0ff6..0000000 --- a/.env.example +++ /dev/null @@ -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 diff --git a/DEMO.md b/DEMO.md index d21fcf7..e2439ee 100644 --- a/DEMO.md +++ b/DEMO.md @@ -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”. diff --git a/README.md b/README.md index 038ae3e..7189777 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/api/main.py b/api/main.py index 42e6b2f..b5f0040 100644 --- a/api/main.py +++ b/api/main.py @@ -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") diff --git a/docs/API.md b/docs/API.md index 219c2bf..2b47189 100644 --- a/docs/API.md +++ b/docs/API.md @@ -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 | diff --git a/docs/FEATURES.md b/docs/FEATURES.md index f492df4..8b476a9 100644 --- a/docs/FEATURES.md +++ b/docs/FEATURES.md @@ -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. diff --git a/ui/app.js b/ui/app.js index 18d0aaa..53879f6 100644 --- a/ui/app.js +++ b/ui/app.js @@ -747,6 +747,7 @@ ${node.is_server ? `SERVER` : ""}
+
Service Tag${escapeHtml(node.service_tag || "—")}
IP${escapeHtml(node.ip || "—")}
Subnet${escapeHtml(node.subnet || "—")}
Status${escapeHtml(node.status || "—")}
@@ -1424,7 +1425,7 @@ const watts = node.watts != null ? `${Math.round(node.watts)} W` : "no power sample"; tip.innerHTML = `

${escapeHtml(node.name || "node")}

-

${escapeHtml(node.model || "—")}
+

${escapeHtml(node.model || "—")} · ST ${escapeHtml(node.service_tag || "—")}
${node.connected ? "connected" : "offline"} · ${node.powered_on ? "powered on" : "power n/a"} · ${escapeHtml(watts)}
${escapeHtml(node.ip || "no IP")} · ${escapeHtml(node.subnet || "")}

Double-click · Quick Connect

`; @@ -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 = `

Warranty

`; + if (primary) { + html += `
ST ${escapeHtml(primary.service_tag || "—")} · ${escapeHtml(primary.service_level || "—")}
+
Ends ${escapeHtml(primary.end_date || "—")} · ${primary.days_remaining ?? "—"} days left
`; + } else if ((w.items || []).length) { + html += (w.items || []) + .slice(0, 3) + .map( + (x) => + `
${escapeHtml(x.service_level || "—")} · ends ${escapeHtml(x.end_date || "—")} · ${x.days_remaining ?? "—"}d
` + ) + .join(""); + } else { + html += `
No warranty records in OME
`; + } + html += `

Dell catalog compliance

`; + if (c.device) { + html += `
${escapeHtml(c.device.compliance_status || c.device.firmware_status || "—")} · baseline ${escapeHtml((c.baseline || {}).name || "—")}
`; + } + if (comps.length) { + html += comps + .slice(0, 8) + .map( + (x) => + `
${escapeHtml(x.component || "component")}: ${escapeHtml(x.current_version || "?")} → ${escapeHtml(x.catalog_version || "?")} ${x.dell_uri ? `dell.com` : ""}
` + ) + .join(""); + } else if (c.device) { + html += `
No upgrade components in baseline report
`; + } else { + html += `
Not in active Dell baseline report
`; + } + html += `
`; + mount.innerHTML = html; + } catch (err) { + mount.innerHTML = `

Warranty/compliance unavailable

`; + } + } + + async function showInspector(node) { state.selectedId = node?.id ?? null; const empty = $("#inspector-empty"); const body = $("#inspector-body"); @@ -1596,7 +1652,7 @@ body.innerHTML = `

${escapeHtml(node.name)}

-

${escapeHtml(node.model || "—")} · ${escapeHtml(node.service_tag || "no tag")}

+

${escapeHtml(node.model || "—")} · id ${node.id}

${node.connected ? "CONNECTED" : "OFFLINE"} @@ -1606,8 +1662,12 @@ ${node.is_server ? `SERVER` : ""}
+
Service Tag${escapeHtml(node.service_tag || "—")} + ${node.service_tag ? `` : ""} +
IP${escapeHtml(node.ip || "—")}
Subnet
+
Model${escapeHtml(node.model || "—")}
Status${escapeHtml(node.status)}
Avg W${node.avg_watts != null ? Math.round(node.avg_watts) + " W" : "—"}
Peak W${node.peak_watts != null ? Math.round(node.peak_watts) + " W" : "—"}
@@ -1616,6 +1676,7 @@
Last status${escapeHtml(node.last_status_time || "—")}
Inventory${escapeHtml(node.last_inventory_time || "—")}
+

Loading warranty & Dell compliance…

${node.idrac_url ? `iDRAC Web` : ""} @@ -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"); diff --git a/ui/index.html b/ui/index.html index 1e8f5ef..43d1be2 100644 --- a/ui/index.html +++ b/ui/index.html @@ -7,7 +7,7 @@ - + @@ -28,6 +28,7 @@
+
@@ -211,9 +212,10 @@ focus: none
- - - + + + +
@@ -276,6 +278,27 @@
+ + +
@@ -443,8 +466,9 @@ - - - + + + + diff --git a/ui/ops.js b/ui/ops.js index 9c7e803..44aa982 100644 --- a/ui/ops.js +++ b/ui/ops.js @@ -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(); diff --git a/ui/reports.js b/ui/reports.js new file mode 100644 index 0000000..f4dec05 --- /dev/null +++ b/ui/reports.js @@ -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, """); + } + + 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 `${escapeHtml(status || "—")}`; + } + + 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 = `

Loading ${escapeHtml(tab)}…

`; + 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 = `

Failed: ${escapeHtml(err.message)}

`; + } + } + + function toolbar(exports) { + return ` +
+ +
+ ${exports} + +
+
`; + } + + 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(` + CSV + JSON + `) + + `
${rows.length} devices · Service Tags + warranty joined
+
+ + + + + ${rows + .map( + (r) => ` + + + + + + + + + ` + ) + .join("")} + +
NameService TagModelIPConnectedPowerWarranty endDays left
${escapeHtml(r.name)}${escapeHtml(r.service_tag || "—")}${escapeHtml(r.model || "—")}${escapeHtml(r.ip || "—")}${r.connected ? badge("CONNECTED") : badge("OFFLINE")}${r.watts != null ? Math.round(r.watts) + " W" : "—"}${escapeHtml(fmtDate(r.warranty_end))}${r.warranty_days_remaining ?? "—"}
`; + 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(` + CSV + JSON + `) + + `
+ Baseline ${escapeHtml(sum.baseline_name || "—")} + · Dell catalog compare · outdated devices ${sum.outdated_devices ?? "—"} + · components ${comps.length} + +
+
+ + + + + ${comps + .map( + (c) => ` + + + + + + + + + ` + ) + .join("")} + +
Service TagDeviceComponentCurrentDell catalogActionStatusDell.com
${escapeHtml(c.service_tag || "—")}${escapeHtml(c.device_name || "—")}
${escapeHtml(c.model || "")}
${escapeHtml(c.component || "—")}${escapeHtml(c.current_version || "—")}${escapeHtml(c.catalog_version || "—")}${escapeHtml(c.update_action || "—")}${badge(c.compliance_status || c.status_badge)}${c.dell_uri ? `Driver` : "—"}
`; + 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(` + CSV + JSON + `) + + `
${items.length} warranty records from OME WarrantyService
+
+ + + + + ${items + .map( + (w) => ` + + + + + + + + ` + ) + .join("")} + +
Service TagDeviceModelService levelStartEndDays left
${escapeHtml(w.service_tag || "—")}${escapeHtml(w.device_name || "—")}${escapeHtml(w.model || "—")}${escapeHtml(w.service_level || "—")}${escapeHtml(fmtDate(w.start_date))}${escapeHtml(fmtDate(w.end_date))}${w.days_remaining ?? "—"}
`; + 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 = ` +
+ + +
+
+
+

Dell catalogs

+ +

Baselines

+ +
+
+

Recent OME jobs

+ +
+
+

OME ReportDefs (${filtered.length})

+
+ + + ${filtered + .map( + (d) => ` + + + + + + ` + ) + .join("")} + +
NameCategoryColumnsLast run
${escapeHtml(d.name)}
${escapeHtml(d.description || "")}
${escapeHtml(d.category || "—")}${escapeHtml((d.columns || []).slice(0, 4).join(", "))}${(d.columns || []).length > 4 ? "…" : ""}${escapeHtml(fmtDate(d.last_run))} +
+ `; + 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 = ` +
+ JSON brief + Fleet CSV + Firmware CSV + Warranty CSV + +
+
+
+ Dell +
+ +

Customer fleet brief

+

${escapeHtml(ome.name || "OM Enterprise")} ${escapeHtml(ome.version || "")} · ${escapeHtml(ome.fqdn || "")}

+

Generated ${escapeHtml(new Date((b.generated_at || 0) * 1000).toLocaleString())}

+
+
+
+

Executive summary

+
+
${sum.total ?? "—"}Devices
+
${sum.connected ?? "—"}Connected
+
${cs.outdated_devices ?? "—"}Outdated vs Dell catalog
+
${sum.alerts_critical ?? "—"}Critical alerts
+
+

Baseline: ${escapeHtml(cs.baseline_name || "—")} + (${escapeHtml(cs.compliance_status || "—")}) · last run ${escapeHtml(fmtDate(cs.last_run))}

+
+
+

Critical alerts

+ +
+
+

Systems out of date (Dell catalog)

+ +
+
+

Warranty attention (≤ 90 days)

+ +
+
+

Device appendix (Service Tags)

+
+ + + ${(b.fleet || []) + .map( + (r) => ` + + + + + + ` + ) + .join("")} + +
Service TagNameModelIPConnected
${escapeHtml(r.service_tag || "—")}${escapeHtml(r.name || "—")}${escapeHtml(r.model || "—")}${escapeHtml(r.ip || "—")}${r.connected ? "Yes" : "No"}
+
+
`; + 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(); +})(); diff --git a/ui/styles.css b/ui/styles.css index 067d05b..118059d 100644 --- a/ui/styles.css +++ b/ui/styles.css @@ -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; } +}