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` : ""}
${escapeHtml(node.service_tag || "—")}${escapeHtml(node.name || "node")}
-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/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.service_tag || "—")}
+ ${node.service_tag ? `` : ""}
+ Loading warranty & Dell compliance…