import urllib.parse import asyncio import json import re import os import socket import sqlite3 import ipaddress import logging import time from collections import defaultdict from pathlib import Path from typing import Any import httpx import asyncssh from fastapi import FastAPI, WebSocket, WebSocketDisconnect, HTTPException, Request from fastapi.middleware.cors import CORSMiddleware from fastapi.responses import FileResponse, Response, StreamingResponse from pydantic import BaseModel, Field from pydantic_settings import BaseSettings logging.basicConfig(level=logging.INFO) log = logging.getLogger("ome-cockpit") class Settings(BaseSettings): ome_url: str = "https://cov-omeprod01.dell-atc.lan" ome_user: str = "admin" ome_password: str = "" poll_interval: float = 15.0 openwebui_url: str = "http://atc-portal01.dell-atc.lan:3080" cors_origins: str = "*" power_fetch_limit: int = 40 gpu_metrics_url: str = "http://10.0.10.106:9110" vllm_url: str = "http://10.0.10.106:8000/v1" vllm_model: str = "llama3-70b-gptq" vllm_max_model_len: int = 4096 vllm_max_tokens: int = 1100 chat_system_chars: int = 12000 openwebui_email: str = "" openwebui_password: str = "" cockpit_data: str = "/data" reports_cache_ttl: float = 600.0 class Config: env_file = ".env" settings = Settings() app = FastAPI(title="OME Cockpit API", version="1.2.0") app.add_middleware( CORSMiddleware, allow_origins=[o.strip() for o in settings.cors_origins.split(",")], allow_credentials=True, allow_methods=["*"], allow_headers=["*"], ) STATE: dict[str, Any] = { "updated_at": 0, "ome": {}, "summary": {}, "subnets": [], "devices": [], "groups": [], "models": [], "alerts": [], "events": [], "context": {}, "gpu": {}, "pulse": 0, "openwebui_url": settings.openwebui_url, } CLIENTS: set[WebSocket] = set() SSH_SESSIONS: dict[str, dict] = {} SSH_MAX_SESSIONS = 25 SSH_MAX_PER_CLIENT = 3 _ssh_sem: asyncio.Semaphore | None = None _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" # --------------------------------------------------------------------------- # Dell PowerEdge expansion / memory population catalog (from Dell Tech Guides # + Installation & Service Manuals / KB 000141539). Used for empty-slot maps, # max capacity, and best-practice guidance — not a live scrape. # --------------------------------------------------------------------------- _DELL_LINKS = { # Verified live PDFs / product docs (checked 2026-07-17: %PDF or HTTP 200 HTML). "spec_r740xd": "https://i.dell.com/sites/csdocuments/Product_Docs/en/poweredge-r740xd-spec-sheet.pdf", "spec_r740": "https://i.dell.com/sites/csdocuments/Product_Docs/en/poweredge-r740-spec-sheet.pdf", "tech_r740": "https://i.dell.com/sites/csdocuments/Shared-Content_data-Sheets_Documents/en/aa/PowerEdge_R740_R740xd_Technical_Guide.pdf", "own_r740xd": "https://dl.dell.com/topicspdf/poweredge-r740xd_owners-manual_en-us.pdf", "docs_r740xd": "https://www.dell.com/support/product-details/en-us/product/poweredge-r740xd/docs", "docs_r740": "https://www.dell.com/support/product-details/en-us/product/poweredge-r740/docs", "spec_r640": "https://i.dell.com/sites/csdocuments/Product_Docs/en/poweredge-r640-spec-sheet.pdf", "own_r640": "https://dl.dell.com/topicspdf/poweredge-r640_owners-manual_en-us.pdf", "docs_r640": "https://www.dell.com/support/product-details/en-us/product/poweredge-r640/docs", "spec_r440": "https://i.dell.com/sites/csdocuments/Product_Docs/en/poweredge-r440-spec-sheet.pdf", "docs_r440": "https://www.dell.com/support/product-details/en-us/product/poweredge-r440/docs", "spec_r6415": "https://i.dell.com/sites/csdocuments/Shared-Content_data-Sheets_Documents/en/aa/PowerEdge_R6415_Spec_Sheet_EN.pdf", "ref_r6415": "https://dl.dell.com/topicspdf/poweredge-r6415_reference-guide_en-us.pdf", "own_r6415": "https://dl.dell.com/topicspdf/poweredge-r6415_owners-manual_en-us.pdf", "docs_r6415": "https://www.dell.com/support/product-details/en-us/product/poweredge-r6415/docs", "spec_r7425": "https://i.dell.com/sites/csdocuments/Shared-Content_data-Sheets_Documents/en/aa/PowerEdge-R7425-Spec-Sheet.pdf", "own_r7425": "https://dl.dell.com/topicspdf/poweredge-r7425_owners-manual_en-us.pdf", "docs_r7425": "https://www.dell.com/support/product-details/en-us/product/poweredge-r7425/docs", "spec_r660": "https://www.delltechnologies.com/asset/en-us/products/servers/technical-support/poweredge-r660-spec-sheet.pdf", "tech_r660": "https://www.delltechnologies.com/asset/en-us/products/servers/technical-support/poweredge-r660-technical-guide.pdf", "docs_r660": "https://www.dell.com/support/product-details/en-us/product/poweredge-r660/docs", "spec_xr5610": "https://www.delltechnologies.com/asset/en-us/products/servers/technical-support/poweredge-xr5610-spec-sheet.pdf", "tech_xr5610": "https://www.delltechnologies.com/asset/en-us/products/servers/technical-support/poweredge-xr5610-technical-guide.pdf", "docs_xr5610": "https://www.dell.com/support/product-details/en-us/product/poweredge-xr5610/docs", "support_home": "https://www.dell.com/support/home", } # 14G dual-socket channel map (R640/R740/R740xd family) _CH14 = { "A": {1: 0, 7: 0, 2: 1, 8: 1, 3: 2, 9: 2, 4: 3, 10: 3, 5: 4, 11: 4, 6: 5, 12: 5}, "B": {1: 0, 7: 0, 2: 1, 8: 1, 3: 2, 9: 2, 4: 3, 10: 3, 5: 4, 11: 4, 6: 5, 12: 5}, } # Dell datacenter rack catalog — visual U heights + face styles for rack designer DELL_RACK_CATALOG: list[dict[str, Any]] = [ # --- PowerEdge 1U --- {"sku": "PE-R440", "name": "PowerEdge R440", "family": "PowerEdge", "category": "server", "u_height": 1, "face": "server_1u", "match": ["r440"]}, {"sku": "PE-R640", "name": "PowerEdge R640", "family": "PowerEdge", "category": "server", "u_height": 1, "face": "server_1u", "match": ["r640"]}, {"sku": "PE-R650", "name": "PowerEdge R650", "family": "PowerEdge", "category": "server", "u_height": 1, "face": "server_1u", "match": ["r650"]}, {"sku": "PE-R650xs", "name": "PowerEdge R650xs", "family": "PowerEdge", "category": "server", "u_height": 1, "face": "server_1u", "match": ["r650xs"]}, {"sku": "PE-R660", "name": "PowerEdge R660", "family": "PowerEdge", "category": "server", "u_height": 1, "face": "server_1u", "match": ["r660"]}, {"sku": "PE-R660xs", "name": "PowerEdge R660xs", "family": "PowerEdge", "category": "server", "u_height": 1, "face": "server_1u", "match": ["r660xs"]}, {"sku": "PE-R6615", "name": "PowerEdge R6615", "family": "PowerEdge", "category": "server", "u_height": 1, "face": "server_1u", "match": ["r6615"]}, {"sku": "PE-R6415", "name": "PowerEdge R6415", "family": "PowerEdge", "category": "server", "u_height": 1, "face": "server_1u", "match": ["r6415"]}, {"sku": "PE-R6515", "name": "PowerEdge R6515", "family": "PowerEdge", "category": "server", "u_height": 1, "face": "server_1u", "match": ["r6515"]}, {"sku": "PE-R6525", "name": "PowerEdge R6525", "family": "PowerEdge", "category": "server", "u_height": 1, "face": "server_1u", "match": ["r6525"]}, {"sku": "PE-R6715", "name": "PowerEdge R6715", "family": "PowerEdge", "category": "server", "u_height": 1, "face": "server_1u", "match": ["r6715"]}, {"sku": "PE-R670", "name": "PowerEdge R670", "family": "PowerEdge", "category": "server", "u_height": 1, "face": "server_1u", "match": ["r670"]}, {"sku": "PE-XR5610", "name": "PowerEdge XR5610", "family": "PowerEdge", "category": "server", "u_height": 1, "face": "server_1u", "match": ["xr5610"]}, {"sku": "PE-C6520", "name": "PowerEdge C6520", "family": "PowerEdge", "category": "server", "u_height": 2, "face": "server_2u", "match": ["c6520"]}, {"sku": "PE-C6525", "name": "PowerEdge C6525", "family": "PowerEdge", "category": "server", "u_height": 1, "face": "server_1u", "match": ["c6525"]}, {"sku": "PE-C4140", "name": "PowerEdge C4140", "family": "PowerEdge", "category": "server", "u_height": 1, "face": "server_1u", "match": ["c4140"]}, # --- PowerEdge 2U --- {"sku": "PE-R740", "name": "PowerEdge R740", "family": "PowerEdge", "category": "server", "u_height": 2, "face": "server_2u", "match": ["r740"]}, {"sku": "PE-R740xd", "name": "PowerEdge R740xd", "family": "PowerEdge", "category": "server", "u_height": 2, "face": "server_2u", "match": ["r740xd"]}, {"sku": "PE-R740xd2", "name": "PowerEdge R740xd2", "family": "PowerEdge", "category": "server", "u_height": 2, "face": "server_2u", "match": ["r740xd2"]}, {"sku": "PE-R7425", "name": "PowerEdge R7425", "family": "PowerEdge", "category": "server", "u_height": 2, "face": "server_2u", "match": ["r7425"]}, {"sku": "PE-R750", "name": "PowerEdge R750", "family": "PowerEdge", "category": "server", "u_height": 2, "face": "server_2u", "match": ["r750"]}, {"sku": "PE-R750xa", "name": "PowerEdge R750xa", "family": "PowerEdge", "category": "server", "u_height": 2, "face": "server_2u", "match": ["r750xa"]}, {"sku": "PE-R7515", "name": "PowerEdge R7515", "family": "PowerEdge", "category": "server", "u_height": 2, "face": "server_2u", "match": ["r7515"]}, {"sku": "PE-R7525", "name": "PowerEdge R7525", "family": "PowerEdge", "category": "server", "u_height": 2, "face": "server_2u", "match": ["r7525"]}, {"sku": "PE-R760", "name": "PowerEdge R760", "family": "PowerEdge", "category": "server", "u_height": 2, "face": "server_2u", "match": ["r760"]}, {"sku": "PE-R760xd2", "name": "PowerEdge R760xd2", "family": "PowerEdge", "category": "server", "u_height": 2, "face": "server_2u", "match": ["r760xd2"]}, {"sku": "PE-R760xa", "name": "PowerEdge R760xa", "family": "PowerEdge", "category": "server", "u_height": 2, "face": "server_2u", "match": ["r760xa"]}, {"sku": "PE-R770", "name": "PowerEdge R770", "family": "PowerEdge", "category": "server", "u_height": 2, "face": "server_2u", "match": ["r770"]}, {"sku": "PE-R840", "name": "PowerEdge R840", "family": "PowerEdge", "category": "server", "u_height": 2, "face": "server_2u", "match": ["r840"]}, {"sku": "PE-R940", "name": "PowerEdge R940", "family": "PowerEdge", "category": "server", "u_height": 3, "face": "server_4u", "match": ["r940"]}, {"sku": "PE-R940xa", "name": "PowerEdge R940xa", "family": "PowerEdge", "category": "server", "u_height": 4, "face": "server_4u", "match": ["r940xa"]}, {"sku": "PE-R340", "name": "PowerEdge R340", "family": "PowerEdge", "category": "server", "u_height": 1, "face": "server_1u", "match": ["r340"]}, {"sku": "PE-R450", "name": "PowerEdge R450", "family": "PowerEdge", "category": "server", "u_height": 1, "face": "server_1u", "match": ["r450"]}, {"sku": "PE-R550", "name": "PowerEdge R550", "family": "PowerEdge", "category": "server", "u_height": 2, "face": "server_2u", "match": ["r550"]}, {"sku": "PE-T640", "name": "PowerEdge T640", "family": "PowerEdge", "category": "server", "u_height": 5, "face": "server_4u", "match": ["t640"]}, {"sku": "PE-DSS8440", "name": "DSS8440", "family": "PowerEdge", "category": "server", "u_height": 4, "face": "server_4u", "match": ["dss8440"]}, {"sku": "PE-XE9680", "name": "PowerEdge XE9680", "family": "PowerEdge", "category": "server", "u_height": 6, "face": "server_4u", "match": ["xe9680"]}, {"sku": "PE-XE8640", "name": "PowerEdge XE8640", "family": "PowerEdge", "category": "server", "u_height": 4, "face": "server_4u", "match": ["xe8640"]}, # --- MX / modular --- {"sku": "PE-MX7000", "name": "PowerEdge MX7000", "family": "PowerEdge MX", "category": "chassis", "u_height": 7, "face": "chassis_mx", "match": ["mx7000"]}, {"sku": "PE-MX740c", "name": "PowerEdge MX740c", "family": "PowerEdge MX", "category": "blade", "u_height": 0, "face": "blade", "match": ["mx740c"]}, {"sku": "PE-MX750c", "name": "PowerEdge MX750c", "family": "PowerEdge MX", "category": "blade", "u_height": 0, "face": "blade", "match": ["mx750c"]}, {"sku": "PE-M640", "name": "PowerEdge M640", "family": "PowerEdge", "category": "blade", "u_height": 0, "face": "blade", "match": ["m640"]}, # --- Networking / PowerSwitch / Force10 --- {"sku": "PS-S4048", "name": "PowerSwitch S4048-ON", "family": "PowerSwitch", "category": "switch", "u_height": 1, "face": "switch_1u", "match": ["s4048", "force10"]}, {"sku": "PS-S4148", "name": "PowerSwitch S4148-ON", "family": "PowerSwitch", "category": "switch", "u_height": 1, "face": "switch_1u", "match": ["s4148"]}, {"sku": "PS-S5248", "name": "PowerSwitch S5248F-ON", "family": "PowerSwitch", "category": "switch", "u_height": 1, "face": "switch_1u", "match": ["s5248"]}, {"sku": "PS-S5296", "name": "PowerSwitch S5296F-ON", "family": "PowerSwitch", "category": "switch", "u_height": 1, "face": "switch_1u", "match": ["s5296"]}, {"sku": "PS-N3248", "name": "PowerSwitch N3248TE-ON", "family": "PowerSwitch", "category": "switch", "u_height": 1, "face": "switch_1u", "match": ["n3248"]}, {"sku": "PS-Z9264", "name": "PowerSwitch Z9264F-ON", "family": "PowerSwitch", "category": "switch", "u_height": 2, "face": "switch_2u", "match": ["z9264"]}, {"sku": "PS-Z9432", "name": "PowerSwitch Z9432F-ON", "family": "PowerSwitch", "category": "switch", "u_height": 1, "face": "switch_1u", "match": ["z9432"]}, {"sku": "PC-5548", "name": "PowerConnect 5548", "family": "PowerConnect", "category": "switch", "u_height": 1, "face": "switch_1u", "match": ["powerconnect 5548", "pc5548"]}, {"sku": "PC-6248", "name": "PowerConnect 6248", "family": "PowerConnect", "category": "switch", "u_height": 1, "face": "switch_1u", "match": ["powerconnect 6248", "pc6248"]}, {"sku": "PC-7048", "name": "PowerConnect 7048", "family": "PowerConnect", "category": "switch", "u_height": 1, "face": "switch_1u", "match": ["powerconnect 7048", "pc7048"]}, {"sku": "PC-8024", "name": "PowerConnect 8024F", "family": "PowerConnect", "category": "switch", "u_height": 1, "face": "switch_1u", "match": ["powerconnect 8024", "pc8024"]}, {"sku": "PC-8132", "name": "PowerConnect 8132F", "family": "PowerConnect", "category": "switch", "u_height": 1, "face": "switch_1u", "match": ["powerconnect 8132", "pc8132"]}, {"sku": "MX-9116n", "name": "MX9116n Fabric Switching Engine", "family": "PowerEdge MX", "category": "switch", "u_height": 0, "face": "fabric", "match": ["mx9116"]}, {"sku": "MX-5108n", "name": "MX5108n Ethernet Switch", "family": "PowerEdge MX", "category": "switch", "u_height": 0, "face": "fabric", "match": ["mx5108"]}, # --- Storage --- {"sku": "ME-4012", "name": "PowerVault ME4012", "family": "PowerVault", "category": "storage", "u_height": 2, "face": "storage_2u", "match": ["me4012"]}, {"sku": "ME-4024", "name": "PowerVault ME4024", "family": "PowerVault", "category": "storage", "u_height": 2, "face": "storage_2u", "match": ["me4024"]}, {"sku": "ME-5012", "name": "PowerVault ME5012", "family": "PowerVault", "category": "storage", "u_height": 2, "face": "storage_2u", "match": ["me5012"]}, {"sku": "ME-5024", "name": "PowerVault ME5024", "family": "PowerVault", "category": "storage", "u_height": 2, "face": "storage_2u", "match": ["me5024"]}, {"sku": "SC-AllFlash", "name": "SC Series Array", "family": "PowerStore/SC", "category": "storage", "u_height": 3, "face": "storage_3u", "match": ["sc "]}, {"sku": "PST-5000T", "name": "PowerStore 5000T", "family": "PowerStore/SC", "category": "storage", "u_height": 2, "face": "storage_2u", "match": ["powerstore 5000"]}, {"sku": "PST-1200T", "name": "PowerStore 1200T", "family": "PowerStore/SC", "category": "storage", "u_height": 2, "face": "storage_2u", "match": ["powerstore 1200"]}, {"sku": "PST-3200T", "name": "PowerStore 3200T", "family": "PowerStore/SC", "category": "storage", "u_height": 2, "face": "storage_2u", "match": ["powerstore 3200"]}, {"sku": "DD-6900", "name": "PowerProtect DD6900", "family": "PowerProtect", "category": "storage", "u_height": 4, "face": "storage_3u", "match": ["dd6900"]}, {"sku": "DD-9400", "name": "PowerProtect DD9400", "family": "PowerProtect", "category": "storage", "u_height": 4, "face": "storage_3u", "match": ["dd9400"]}, {"sku": "UNITY-380", "name": "Unity XT 380", "family": "Unity", "category": "storage", "u_height": 2, "face": "storage_2u", "match": ["unity"]}, # --- VxRail appliances --- {"sku": "VX-E460F", "name": "VxRail E460F", "family": "VxRail", "category": "server", "u_height": 1, "face": "server_1u", "match": ["e460f", "vxrail e"]}, {"sku": "VX-V470F", "name": "VxRail V470F", "family": "VxRail", "category": "server", "u_height": 1, "face": "server_1u", "match": ["v470f"]}, {"sku": "VX-P570F", "name": "VxRail P570F", "family": "VxRail", "category": "server", "u_height": 1, "face": "server_1u", "match": ["p570f"]}, {"sku": "VX-V670F", "name": "VxRail V670F", "family": "VxRail", "category": "server", "u_height": 2, "face": "server_2u", "match": ["v670f"]}, {"sku": "VX-P670F", "name": "VxRail P670F", "family": "VxRail", "category": "server", "u_height": 2, "face": "server_2u", "match": ["p670f"]}, {"sku": "VX-S570", "name": "VxRail S570", "family": "VxRail", "category": "server", "u_height": 1, "face": "server_1u", "match": ["s570"]}, # --- Networking extras --- {"sku": "PS-S5232", "name": "PowerSwitch S5232F-ON", "family": "PowerSwitch", "category": "switch", "u_height": 1, "face": "switch_1u", "match": ["s5232"]}, {"sku": "PS-S5212", "name": "PowerSwitch S5212F-ON", "family": "PowerSwitch", "category": "switch", "u_height": 1, "face": "switch_1u", "match": ["s5212"]}, {"sku": "PS-N3224", "name": "PowerSwitch N3224T-ON", "family": "PowerSwitch", "category": "switch", "u_height": 1, "face": "switch_1u", "match": ["n3224"]}, {"sku": "PS-Z9100", "name": "PowerSwitch Z9100-ON", "family": "PowerSwitch", "category": "switch", "u_height": 1, "face": "switch_1u", "match": ["z9100"]}, {"sku": "PC-7024", "name": "PowerConnect 7024", "family": "PowerConnect", "category": "switch", "u_height": 1, "face": "switch_1u", "match": ["powerconnect 7024"]}, {"sku": "PC-8100", "name": "PowerConnect 8100", "family": "PowerConnect", "category": "switch", "u_height": 1, "face": "switch_1u", "match": ["powerconnect 8100"]}, # --- PDU / misc --- {"sku": "PDU-Geist-0U", "name": "Geist Rack PDU (0U vertical)", "family": "PDU", "category": "pdu", "u_height": 0, "face": "pdu_0u", "match": ["geist", "mg03", "mgs3"]}, {"sku": "PDU-1U", "name": "Rack PDU 1U horizontal", "family": "PDU", "category": "pdu", "u_height": 1, "face": "pdu_1u", "match": []}, {"sku": "PDU-APC-0U", "name": "APC Rack PDU 0U", "family": "PDU", "category": "pdu", "u_height": 0, "face": "pdu_0u", "match": ["apc"]}, {"sku": "PANEL-1U", "name": "Blanking panel 1U", "family": "Accessory", "category": "blank", "u_height": 1, "face": "blank_1u", "match": []}, {"sku": "PANEL-2U", "name": "Blanking panel 2U", "family": "Accessory", "category": "blank", "u_height": 2, "face": "blank_2u", "match": []}, {"sku": "CABLE-MGMT-1U", "name": "Cable management arm 1U", "family": "Accessory", "category": "blank", "u_height": 1, "face": "blank_1u", "match": []}, {"sku": "KVM-1U", "name": "Rack KVM 1U", "family": "Accessory", "category": "custom", "u_height": 1, "face": "custom", "match": ["kvm"]}, {"sku": "CUSTOM", "name": "Custom device", "family": "Custom", "category": "custom", "u_height": 1, "face": "custom", "match": []}, ] FACE_IMAGE_MAP: dict[str, str] = { "server_1u": "/assets/dell/face-server-1u.jpg?v=bezel2", "server_2u": "/assets/dell/face-server-2u.jpg?v=bezel2", "server_4u": "/assets/dell/face-server-4u.jpg?v=bezel2", "switch_1u": "/assets/dell/face-switch-1u.jpg?v=bezel2", "switch_2u": "/assets/dell/face-switch-1u.jpg?v=bezel2", "storage_2u": "/assets/dell/face-storage-2u.jpg?v=bezel2", "storage_3u": "/assets/dell/face-storage-2u.jpg?v=bezel2", "chassis_mx": "/assets/dell/face-chassis-mx.jpg?v=bezel2", "pdu_1u": "/assets/dell/face-pdu-1u.jpg?v=bezel2", "pdu_0u": "/assets/dell/face-pdu-1u.jpg?v=bezel2", "blank_1u": "/assets/dell/face-blank-1u.jpg?v=bezel2", "blank_2u": "/assets/dell/face-blank-1u.jpg?v=bezel2", "blade": "/assets/dell/face-chassis-mx.jpg?v=bezel2", "fabric": "/assets/dell/face-switch-1u.jpg?v=bezel2", "custom": "/assets/dell/face-server-1u.jpg?v=bezel2", } # Official Dell product shots by SKU (processed to dark background to match faces) SKU_IMAGE_MAP: dict[str, str] = { # Prefer shared dark face art for visual consistency across all PowerEdge 1U/2U } def catalog_image_for(item: dict) -> str: sku = item.get("sku") or "" if sku in SKU_IMAGE_MAP: return SKU_IMAGE_MAP[sku] face = item.get("face") or "custom" return FACE_IMAGE_MAP.get(face, FACE_IMAGE_MAP["custom"]) def enrich_catalog_images() -> None: for item in DELL_RACK_CATALOG: item["image"] = catalog_image_for(item) enrich_catalog_images() def match_rack_catalog(model: str | None, role: str | None = None) -> dict | None: m = (model or "").lower() best = None for item in DELL_RACK_CATALOG: for token in item.get("match") or []: if token and token in m: best = item break if best: break if best: return best if role == "switch": return next((x for x in DELL_RACK_CATALOG if x["sku"] == "PS-S4048"), None) if role == "storage": return next((x for x in DELL_RACK_CATALOG if x["sku"] == "ME-4024"), None) if role == "chassis": return next((x for x in DELL_RACK_CATALOG if x["sku"] == "PE-MX7000"), None) if role == "pdu": return next((x for x in DELL_RACK_CATALOG if x["sku"] == "PDU-Geist-0U"), None) if role == "server": return next((x for x in DELL_RACK_CATALOG if x["sku"] == "PE-R740"), None) return None MODEL_EXPANSION: dict[str, dict[str, Any]] = { "PowerEdge R740xd": { "generation": "14G", "dimm_slots": 24, "dimms_per_cpu": 12, "channels_per_cpu": 6, "max_memory_tb": 3.0, "max_memory_note": "Up to 3 TB with LRDIMM (+ DCPMM configs); typical RDIMM max lower per CPU SKU", "memory_types": ["DDR4 RDIMM", "DDR4 LRDIMM", "NVDIMM-N", "DCPMM/PMem"], "max_dimm_speed_mts": 2666, "pcie_slots_max": 8, "pcie_note": "Up to 8× PCIe 3.0 via riser options; slot count depends on riser kit + CPU count", "drive_bays_max": 24, "drive_note": "Chassis-dependent: up to 24× 2.5\" (or mixed 3.5\"/NVMe mid-tray options on xd)", "cpu_sockets": 2, "best_practices": [ "Populate identical DIMMs across all six channels per CPU (6 or 12 DIMMs/CPU) for best bandwidth.", "White release-tab sockets first, then black (channel rank 1 then rank 2).", "Do not mix RDIMM and LRDIMM in the same system.", "Unbalanced channel population reduces memory bandwidth / can lower effective speed.", "Performance Optimized (4 DIMMs/CPU): slots 1,2,4,5 — (8 DIMMs/CPU): 1,2,4,5,7,8,10,11 (Dell KB 000141539).", ], "performance_notes": [ "Peak memory bandwidth needs one matched DIMM in every channel (6 per CPU) before adding the second rank (slots 7–12).", "Mixing capacities: place largest DIMMs in the first populated sockets of each channel.", "DCPMM: up to 6 PMem + 6 DRAM per CPU; full 12-slot population recommended for App Direct / Memory Mode.", ], "docs": [ {"title": "R740xd Spec Sheet (PDF)", "url": _DELL_LINKS["spec_r740xd"], "kind": "spec"}, {"title": "R740/R740xd Technical Guide (PDF)", "url": _DELL_LINKS["tech_r740"], "kind": "tech"}, {"title": "R740xd Owner's Manual (PDF)", "url": _DELL_LINKS["own_r740xd"], "kind": "manual"}, {"title": "Dell Support · R740xd docs", "url": _DELL_LINKS["docs_r740xd"], "kind": "support"}, ], "channel_map": _CH14, "slot_labels": [f"A{i}" for i in range(1, 13)] + [f"B{i}" for i in range(1, 13)], }, "PowerEdge R740": { "generation": "14G", "dimm_slots": 24, "dimms_per_cpu": 12, "channels_per_cpu": 6, "max_memory_tb": 3.0, "max_memory_note": "Up to 3 TB with LRDIMM/DCPMM depending on CPU", "memory_types": ["DDR4 RDIMM", "DDR4 LRDIMM", "NVDIMM-N", "DCPMM/PMem"], "max_dimm_speed_mts": 2666, "pcie_slots_max": 8, "pcie_note": "Up to 8× PCIe 3.0 (riser dependent)", "drive_bays_max": 16, "drive_note": "Up to 16× 2.5\" or 8× 3.5\" depending on chassis kit", "cpu_sockets": 2, "best_practices": [ "Same 14G rules as R740xd: balance channels; white tabs before black.", "Performance Optimized 4/8 DIMM/CPU populations per Dell KB 000141539.", ], "performance_notes": [ "Populate 6 identical DIMMs per CPU before doubling up channels for max throughput.", ], "docs": [ {"title": "R740 Spec Sheet (PDF)", "url": _DELL_LINKS["spec_r740"], "kind": "spec"}, {"title": "R740/R740xd Technical Guide (PDF)", "url": _DELL_LINKS["tech_r740"], "kind": "tech"}, {"title": "Dell Support · R740 docs", "url": _DELL_LINKS["docs_r740"], "kind": "support"}, ], "channel_map": _CH14, "slot_labels": [f"A{i}" for i in range(1, 13)] + [f"B{i}" for i in range(1, 13)], }, "PowerEdge R640": { "generation": "14G", "dimm_slots": 24, "dimms_per_cpu": 12, "channels_per_cpu": 6, "max_memory_tb": 3.0, "memory_types": ["DDR4 RDIMM", "DDR4 LRDIMM", "DCPMM/PMem"], "max_dimm_speed_mts": 2666, "pcie_slots_max": 3, "pcie_note": "1U: up to 3× PCIe 3.0 (+ rNDC)", "drive_bays_max": 10, "drive_note": "Up to 10× 2.5\" (NVMe options vary)", "cpu_sockets": 2, "best_practices": [ "14G dual-socket population: identical DIMMs across 6 channels per CPU.", "White-tab sockets first; avoid RDIMM+LRDIMM mix.", ], "performance_notes": [ "1U thermal/airflow limits denser GPU/NVMe kits — verify riser/backplane BOM.", ], "docs": [ {"title": "R640 Spec Sheet (PDF)", "url": _DELL_LINKS["spec_r640"], "kind": "spec"}, {"title": "R640 Owner's Manual (PDF)", "url": _DELL_LINKS["own_r640"], "kind": "manual"}, {"title": "Dell Support · R640 docs", "url": _DELL_LINKS["docs_r640"], "kind": "support"}, ], "channel_map": _CH14, "slot_labels": [f"A{i}" for i in range(1, 13)] + [f"B{i}" for i in range(1, 13)], }, "PowerEdge R440": { "generation": "14G", "dimm_slots": 16, "dimms_per_cpu": 8, "channels_per_cpu": 6, "max_memory_tb": 0.512, "memory_types": ["DDR4 RDIMM"], "max_dimm_speed_mts": 2666, "pcie_slots_max": 3, "drive_bays_max": 10, "cpu_sockets": 2, "best_practices": ["Populate channels evenly; match DIMM size/speed/rank per Dell 14G guidance."], "performance_notes": ["Entry 14G — keep DIMM speeds matched to CPU QPI/UPI memory controller limits."], "docs": [ {"title": "R440 Spec Sheet (PDF)", "url": _DELL_LINKS["spec_r440"], "kind": "spec"}, {"title": "Dell Support · R440 docs", "url": _DELL_LINKS["docs_r440"], "kind": "support"}, ], "slot_labels": [f"A{i}" for i in range(1, 9)] + [f"B{i}" for i in range(1, 9)], }, "PowerEdge R6415": { "generation": "14G-AMD", "dimm_slots": 16, "dimms_per_cpu": 16, "channels_per_cpu": 8, "max_memory_tb": 1.0, "memory_types": ["DDR4 RDIMM", "DDR4 LRDIMM"], "max_dimm_speed_mts": 2666, "pcie_slots_max": 3, "drive_bays_max": 10, "cpu_sockets": 1, "best_practices": [ "AMD EPYC Naples/Rome 14G: for 4 DIMMs use slots 1,3,5,7 (Dell KB 000141539).", "Populate evenly across memory channels for Infinity Fabric bandwidth.", ], "performance_notes": [ "EPYC memory performance scales strongly with channels populated — prefer 8 identical DIMMs when possible.", ], "docs": [ {"title": "R6415 Spec Sheet (PDF)", "url": _DELL_LINKS["spec_r6415"], "kind": "spec"}, {"title": "R6415 Tech Specs / Reference (PDF)", "url": _DELL_LINKS["ref_r6415"], "kind": "tech"}, {"title": "R6415 Owner's Manual (PDF)", "url": _DELL_LINKS["own_r6415"], "kind": "manual"}, {"title": "Dell Support · R6415 docs", "url": _DELL_LINKS["docs_r6415"], "kind": "support"}, ], "slot_labels": [f"A{i}" for i in range(1, 17)], }, "PowerEdge R7425": { "generation": "14G-AMD", "dimm_slots": 32, "dimms_per_cpu": 16, "channels_per_cpu": 8, "max_memory_tb": 2.0, "memory_types": ["DDR4 RDIMM", "DDR4 LRDIMM"], "max_dimm_speed_mts": 2666, "pcie_slots_max": 8, "drive_bays_max": 24, "cpu_sockets": 2, "best_practices": [ "Dual EPYC: mirror CPU1 and CPU2 DIMM populations.", "4 DIMMs/CPU → slots 1,3,5,7 per Dell AMD 14G guidance.", ], "performance_notes": ["Balance both sockets identically to avoid NUMA imbalance."], "docs": [ {"title": "R7425 Spec Sheet (PDF)", "url": _DELL_LINKS["spec_r7425"], "kind": "spec"}, {"title": "R7425 Owner's Manual (PDF)", "url": _DELL_LINKS["own_r7425"], "kind": "manual"}, {"title": "Dell Support · R7425 docs", "url": _DELL_LINKS["docs_r7425"], "kind": "support"}, ], "slot_labels": [f"A{i}" for i in range(1, 17)] + [f"B{i}" for i in range(1, 17)], }, "PowerEdge R660": { "generation": "16G", "dimm_slots": 32, "dimms_per_cpu": 16, "channels_per_cpu": 8, "max_memory_tb": 8.0, "memory_types": ["DDR5 RDIMM"], "max_dimm_speed_mts": 4800, "pcie_slots_max": 3, "drive_bays_max": 10, "cpu_sockets": 2, "best_practices": [ "16G DDR5: populate all memory channels evenly; follow white/black tab order in the ISM for the SKU.", "Use matched DDR5 RDIMM kits; mixing ranks/speeds can downclock the entire domain.", ], "performance_notes": [ "Sapphire Rapids / Emerald Rapids memory bandwidth benefits strongly from full channel population.", ], "docs": [ {"title": "R660 Spec Sheet (PDF)", "url": _DELL_LINKS["spec_r660"], "kind": "spec"}, {"title": "R660 Technical Guide (PDF)", "url": _DELL_LINKS["tech_r660"], "kind": "tech"}, {"title": "Dell Support · R660 docs", "url": _DELL_LINKS["docs_r660"], "kind": "support"}, ], "slot_labels": [f"A{i}" for i in range(1, 17)] + [f"B{i}" for i in range(1, 17)], }, "PowerEdge XR5610": { "generation": "16G-XR", "dimm_slots": 8, "dimms_per_cpu": 8, "channels_per_cpu": 8, "max_memory_tb": 1.0, "max_memory_note": "Dell Spec Sheet: 8× DDR5 RDIMM, up to 1 TB, up to 5600 MT/s (CPU/SKU dependent)", "memory_types": ["DDR5 RDIMM"], "max_dimm_speed_mts": 5600, "pcie_slots_max": 2, "pcie_note": "Spec Sheet: up to 2× PCIe Gen5 x16 (+ OCP 3.0) — riser/GPU option dependent", "drive_bays_max": 4, "drive_note": "Spec Sheet: up to 4× 2.5\" SAS/SATA/NVMe front; BOSS-N1 M.2 is separate boot — not front-bay count", "cpu_sockets": 1, "best_practices": [ "Populate DDR5 channels evenly with matched RDIMMs.", "Confirm exact DIMM map in the XR5610 Technical Guide / ISM for this BOM.", ], "performance_notes": [ "Single-socket edge platform: memory bandwidth scales with channels populated.", ], "docs": [ {"title": "XR5610 Spec Sheet (PDF)", "url": _DELL_LINKS["spec_xr5610"], "kind": "spec"}, {"title": "XR5610 Technical Guide (PDF)", "url": _DELL_LINKS["tech_xr5610"], "kind": "tech"}, {"title": "Dell Support · XR5610 docs", "url": _DELL_LINKS["docs_xr5610"], "kind": "support"}, ], "slot_labels": [f"A{i}" for i in range(1, 9)], }, } def _normalize_model_key(model: str | None) -> str: m = (model or "").strip() if not m: return "" if m in MODEL_EXPANSION: return m # fuzzy family match for key in MODEL_EXPANSION: if key.lower() in m.lower() or m.lower() in key.lower(): return key # strip suffixes like "vSAN Ready Node" for key in MODEL_EXPANSION: short = key.replace("PowerEdge ", "") if short.lower() in m.lower(): return key return m def _parse_dimm_socket(name: str | None) -> tuple[str | None, int | None]: import re s = name or "" m = re.search(r"(?:DIMM\.Socket\.|DIMM\s*)([A-D])(\d{1,2})", s, re.I) if not m: m = re.search(r"\b([A-D])(\d{1,2})\b", s) if not m: return None, None return m.group(1).upper(), int(m.group(2)) def _disk_size_gb(raw: Any) -> float | None: """OME serverArrayDisks.Size is already capacity in GB (e.g. '446.62').""" if raw is None or raw == "": return None try: return round(float(str(raw).strip()), 2) except Exception: return None def _mem_size_gb(raw: Any) -> float | None: """OME serverMemoryDevices.Size is capacity in MB.""" if raw is None or raw == "": return None try: return round(float(raw) / 1024.0, 1) except Exception: return None def build_expansion_report(model: str | None, inventory: dict) -> dict: """Split live OME facts (current) from Dell catalog max expansion (recommendations).""" key = _normalize_model_key(model) catalog_known = bool(MODEL_EXPANSION.get(key)) spec = dict(MODEL_EXPANSION.get(key) or {}) if not catalog_known: spec = { "generation": None, "dimm_slots": None, "max_memory_tb": None, "pcie_slots_max": None, "drive_bays_max": None, "cpu_sockets": None, "best_practices": [ "No Cockpit catalog entry for this model — verify DIMM/CPU/drive maxima in the Dell Tech Specs / ISM for the exact Service Tag BOM.", ], "performance_notes": [], "docs": [{"title": "Dell Support home", "url": _DELL_LINKS["support_home"]}], "slot_labels": [], "channel_map": {}, "memory_types": [], "max_memory_note": "Maximum not asserted — catalog unknown for this model.", "drive_note": "Bay maximum not asserted — catalog unknown for this model.", "pcie_note": "PCIe maximum not asserted — catalog unknown for this model.", } mem_raw = inventory.get("serverMemoryDevices") or [] cpu_raw = inventory.get("serverProcessors") or [] disk_raw = inventory.get("serverArrayDisks") or [] cards_raw = inventory.get("serverDeviceCards") or [] raid_raw = inventory.get("serverRaidControllers") or [] os_raw = inventory.get("serverOperatingSystems") or [] dimm_modules: list[dict] = [] for dimm in mem_raw: bank, num = _parse_dimm_socket( dimm.get("Name") or dimm.get("InstanceId") or dimm.get("DeviceDescription") ) if not bank or not num: continue label = f"{bank}{num}" size_gb = _mem_size_gb(dimm.get("Size")) dimm_modules.append( { "slot": label, "cpu_bank": bank, "slot_num": num, "size_gb": size_gb, "size_mb": dimm.get("Size"), "rated_mts": dimm.get("Speed"), "operating_mts": dimm.get("CurrentOperatingSpeed"), "rank": dimm.get("Rank"), "type": dimm.get("TypeDetails") or dimm.get("MemoryType") or "DDR", "manufacturer": dimm.get("Manufacturer"), "part_number": dimm.get("PartNumber"), "serial": dimm.get("SerialNumber"), "status": dimm.get("Status"), } ) dimm_modules.sort(key=lambda s: (s.get("cpu_bank") or "", s.get("slot_num") or 0)) mem_total_gb = round(sum(s.get("size_gb") or 0 for s in dimm_modules), 1) rated_speeds = sorted({s["rated_mts"] for s in dimm_modules if s.get("rated_mts") is not None}) op_speeds = sorted({s["operating_mts"] for s in dimm_modules if s.get("operating_mts") is not None}) cpus: list[dict] = [] for p in cpu_raw: cpus.append( { "slot": p.get("SlotNumber") or p.get("InstanceId"), "model": p.get("ModelName") or p.get("BrandName"), "cores": p.get("NumberOfEnabledCores") or p.get("NumberOfCores"), "threads": p.get("NumberOfEnabledThreads") or p.get("NumberOfThreads"), "current_mhz": p.get("CurrentSpeed"), "max_mhz": p.get("MaxSpeed"), "status": p.get("Status"), } ) cpu_cores_total = sum(int(c.get("cores") or 0) for c in cpus) disks: list[dict] = [] for d in disk_raw: disks.append( { "bay": d.get("SlotNumber"), "model": d.get("ModelNumber"), "vendor": d.get("VendorName"), "media": d.get("MediaType"), "size_gb": _disk_size_gb(d.get("Size")), "bus": d.get("BusType"), "status": d.get("StatusString") or d.get("Status"), "serial": d.get("SerialNumber"), "enclosure": d.get("EnclosureId"), "form_factor": d.get("FormFactor"), } ) disks.sort(key=lambda x: (str(x.get("bus") or ""), x.get("bay") is None, x.get("bay") or 0)) disk_total_gb = round(sum(d.get("size_gb") or 0 for d in disks), 2) adapters: list[dict] = [] for c in cards_raw: adapters.append( { "slot": str(c.get("SlotNumber") or ""), "description": c.get("Description"), "manufacturer": c.get("Manufacturer"), "slot_type": c.get("SlotType"), "width": c.get("DatabusWidth"), } ) for r in raid_raw: adapters.append( { "slot": str(r.get("PciSlot") or r.get("Fqdd") or "RAID"), "description": r.get("Name") or r.get("DeviceDescription"), "manufacturer": "Dell PERC", "slot_type": "RAID", "firmware": r.get("FirmwareVersion"), "cache_mb": r.get("CacheSizeInMb"), } ) os_info = [] for o in os_raw: os_info.append( { "os_name": (o.get("OsName") or "").strip() or None, "os_version": (o.get("OsVersion") or "").strip() or None, "hostname": (o.get("Hostname") or "").strip() or None, } ) current = { "source": "OME DeviceService inventory (live)", "memory": { "installed_gb": mem_total_gb, "dimm_count": len(dimm_modules), "modules": dimm_modules, "rated_mts": rated_speeds, "operating_mts": op_speeds, "banks_seen": sorted({m["cpu_bank"] for m in dimm_modules if m.get("cpu_bank")}), }, "cpu": { "sockets_populated": len(cpus), "cores_total": cpu_cores_total, "processors": cpus, }, "disks": { "count": len(disks), "capacity_gb": disk_total_gb, "items": disks, }, "adapters": adapters, "os": os_info, } populated_map = {m["slot"]: m for m in dimm_modules} labels = list(spec.get("slot_labels") or []) slot_map: list[dict] = [] for label in labels: bank = label[0] num = int(label[1:] or 0) ch_map = (spec.get("channel_map") or {}).get(bank) or {} if label in populated_map: row = dict(populated_map[label]) row["populated"] = True row["channel"] = ch_map.get(num) row["rank_color"] = "white" if num <= 6 else "black" row["speed_mts"] = row.get("operating_mts") or row.get("rated_mts") slot_map.append(row) else: slot_map.append( { "slot": label, "cpu_bank": bank, "slot_num": num, "channel": ch_map.get(num), "rank_color": "white" if num <= 6 else "black", "populated": False, } ) for label, row in populated_map.items(): if label not in labels: extra = dict(row) extra["populated"] = True extra["speed_mts"] = extra.get("operating_mts") or extra.get("rated_mts") slot_map.append(extra) empty_n = sum(1 for s in slot_map if not s.get("populated")) if labels else None dimm_slots_catalog = spec.get("dimm_slots") max_tb = spec.get("max_memory_tb") headroom_gb = None if max_tb is not None: try: headroom_gb = round(float(max_tb) * 1024 - mem_total_gb, 1) except Exception: headroom_gb = None drive_max = spec.get("drive_bays_max") free_bays = (int(drive_max) - len(disks)) if catalog_known and isinstance(drive_max, int) else None cpu_max = spec.get("cpu_sockets") free_cpu = (int(cpu_max) - len(cpus)) if catalog_known and isinstance(cpu_max, int) else None findings: list[dict] = [] if catalog_known and labels: by_bank: dict[str, list] = {} for s in slot_map: by_bank.setdefault(s.get("cpu_bank") or "?", []).append(s) for bank, bank_slots in by_bank.items(): ch_counts: dict[int, int] = {} for s in bank_slots: if s.get("populated") and s.get("channel") is not None: ch_counts[int(s["channel"])] = ch_counts.get(int(s["channel"]), 0) + 1 if ch_counts and spec.get("channels_per_cpu"): vals = list(ch_counts.values()) if max(vals) - min(vals) > 0 or len(ch_counts) < int(spec["channels_per_cpu"]): findings.append( { "severity": "warning", "code": "unbalanced_channels", "text": f"Bank {bank}: channel population {dict(sorted(ch_counts.items()))} — Dell guidance prefers even channel fill for bandwidth.", } ) if len(op_speeds) > 1: findings.append( { "severity": "warning", "code": "mixed_operating_speeds", "text": f"OME reports mixed operating speeds {op_speeds} MT/s — controller usually clocks all DIMMs to the lowest.", } ) if rated_speeds and op_speeds and max(op_speeds) < max(rated_speeds): findings.append( { "severity": "info", "code": "downclocked", "text": f"DIMMs rated {rated_speeds} MT/s, operating at {op_speeds} MT/s (CPU/population limited).", } ) expansion = { "source": "Dell model catalog in Cockpit (not live measurement)" if catalog_known else "Catalog unknown", "catalog_known": catalog_known, "catalog_model": key if catalog_known else None, "generation": spec.get("generation"), "memory": { "max_capacity_tb": max_tb, "max_capacity_gb": round(float(max_tb) * 1024, 1) if max_tb is not None else None, "dimm_slots_max": dimm_slots_catalog, "dimms_installed": len(dimm_modules), "empty_slots_estimate": empty_n if catalog_known else None, "headroom_gb": headroom_gb if catalog_known else None, "supported_types": spec.get("memory_types") or [], "max_rated_mts": spec.get("max_dimm_speed_mts"), "note": spec.get("max_memory_note") or ( f"Catalog max {max_tb} TB / {dimm_slots_catalog} DIMM slots" if catalog_known else "No catalog max — do not treat empty map as measured free slots." ), "slot_map": slot_map if catalog_known else [], }, "cpu": { "sockets_max": cpu_max, "sockets_populated": len(cpus), "sockets_free_estimate": free_cpu, "note": ( f"Chassis supports up to {cpu_max} CPU socket(s); OME sees {len(cpus)} populated." if catalog_known and cpu_max is not None else "CPU socket maximum not in Cockpit catalog for this model." ), }, "disks": { "bays_max": drive_max if catalog_known else None, "drives_installed": len(disks), "bays_free_estimate": free_bays, "note": spec.get("drive_note") or ( "Bay maximum from Dell chassis options — verify BOM; M.2/BOSS may not count as front bays." if catalog_known else "Drive bay maximum unknown for this model." ), }, "pcie": { "slots_max": spec.get("pcie_slots_max") if catalog_known else None, "note": spec.get("pcie_note") or ("PCIe max depends on riser kit." if catalog_known else "PCIe maximum unknown for this model."), }, "best_practices": spec.get("best_practices") or [], "performance_notes": spec.get("performance_notes") or [], "docs": spec.get("docs") or [], } return { "model": model, "catalog_key": key if catalog_known else None, "generic": not catalog_known, "current": current, "expansion": expansion, "findings": findings, "spec": { "generation": expansion.get("generation"), "dimm_slots": expansion["memory"].get("dimm_slots_max"), "max_memory_tb": expansion["memory"].get("max_capacity_tb"), "max_memory_note": expansion["memory"].get("note"), "memory_types": expansion["memory"].get("supported_types"), "max_dimm_speed_mts": expansion["memory"].get("max_rated_mts"), "pcie_slots_max": expansion["pcie"].get("slots_max"), "pcie_note": expansion["pcie"].get("note"), "drive_bays_max": expansion["disks"].get("bays_max"), "drive_note": expansion["disks"].get("note"), "cpu_sockets": expansion["cpu"].get("sockets_max"), "best_practices": expansion.get("best_practices") or [], "performance_notes": expansion.get("performance_notes") or [], "docs": expansion.get("docs") or [], }, "memory": { "total_gb": mem_total_gb, "populated": len(dimm_modules), "empty": empty_n, "headroom_gb": headroom_gb, "slots": slot_map if slot_map else [{**m, "populated": True, "speed_mts": m.get("operating_mts") or m.get("rated_mts")} for m in dimm_modules], "banks": current["memory"]["banks_seen"], }, "processors": cpus, "disks": { "installed": len(disks), "max_bays": drive_max if catalog_known else None, "free_bays_estimate": free_bays, "items": [{**d, "slot": d.get("bay"), "name": d.get("model")} for d in disks], }, "pcie": { "max_slots": expansion["pcie"].get("slots_max"), "items": adapters, "note": expansion["pcie"].get("note"), }, } def subnet_of(ip: str | None) -> str: if not ip or ip.count(".") != 3: return "unknown" try: return str(ipaddress.ip_network(f"{ip}/24", strict=False)) except Exception: parts = ip.split(".") return f"{parts[0]}.{parts[1]}.{parts[2]}.0/24" def pick_ip(device: dict) -> str | None: for m in device.get("DeviceManagement") or []: addr = m.get("NetworkAddress") if addr and addr.count(".") == 3: return addr return None def pick_management(device: dict) -> dict: """Extract iDRAC/management IP plus OS hostname hints from OME DeviceManagement.""" mgmt = (device.get("DeviceManagement") or [{}])[0] or {} idrac_ip = None for m in device.get("DeviceManagement") or []: addr = m.get("NetworkAddress") if addr and str(addr).count(".") == 3: idrac_ip = addr mgmt = m break os_hostname = (mgmt.get("InstrumentationName") or "").strip() or None dns_name = (mgmt.get("DnsName") or "").strip() or None return { "idrac_ip": idrac_ip, "os_hostname": os_hostname, "mgmt_dns_name": dns_name, "mac": mgmt.get("MacAddress"), } async def resolve_host_ips(hostname: str | None) -> list[str]: if not hostname or hostname.count(".") == 3: return [] loop = asyncio.get_running_loop() try: infos = await loop.getaddrinfo(hostname, None, family=socket.AF_INET) return sorted({i[4][0] for i in infos}) except Exception: return [] async def enrich_rdp_targets(devices: list[dict]) -> None: """DNS-resolve OS hostnames from OME InstrumentationName → RDP candidate IPs.""" hostnames: set[str] = set() for n in devices: h = (n.get("os_hostname") or "").strip() if h and h.count(".") != 3: hostnames.add(h) # also try short name if "." in h: hostnames.add(h.split(".", 1)[0]) resolved: dict[str, list[str]] = {} sem = asyncio.Semaphore(20) async def one(h: str): async with sem: resolved[h] = await resolve_host_ips(h) if hostnames: await asyncio.gather(*[one(h) for h in hostnames]) for n in devices: idrac = n.get("idrac_ip") or n.get("ip") h = (n.get("os_hostname") or "").strip() ips: list[str] = [] seen: set[str] = set() for candidate in (h, h.split(".", 1)[0] if h and "." in h else None): if not candidate: continue for ip in resolved.get(candidate) or []: if ip not in seen: seen.add(ip) ips.append(ip) # Prefer OS IPs that differ from iDRAC os_ips = [ip for ip in ips if ip != idrac] rdp_ips = os_ips or ips n["rdp_ips"] = rdp_ips n["rdp_host"] = rdp_ips[0] if rdp_ips else None n["os_ips"] = os_ips # Windows hint from name / later OS inventory blob = f"{n.get('name') or ''} {n.get('os_hostname') or ''} {n.get('os_name') or ''}".lower() n["is_windows"] = any(k in blob for k in ("windows", "hyperv", "hyper-v", "-hv", "hv0")) or bool( n.get("os_name") and "windows" in str(n.get("os_name")).lower() ) async def fetch_power(client: httpx.AsyncClient, base: str, headers: dict, device_id: int) -> dict: try: r = await client.get(f"{base}/api/DeviceService/Devices({device_id})/Power", headers=headers) if r.status_code != 200: return {} p = r.json() def num(key): v = p.get(key) try: return float(v) if v not in (None, "") else None except Exception: return None return { "watts": num("power"), "avg_watts": num("avgPower"), "peak_watts": num("peakPower"), "min_watts": num("minimumPower"), "energy_kwh": num("systemEnergyConsumption"), "power_unit": p.get("powerUnit") or "watt", } except Exception: return {} async def ome_fetch() -> dict: base = settings.ome_url.rstrip("/") async with httpx.AsyncClient(verify=False, timeout=60.0) as client: 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 = r.json().get("Id") headers = {"X-Auth-Token": token, "Accept": "application/json"} try: info = (await client.get(f"{base}/api/ApplicationService/Info", headers=headers)).json() devices_resp = ( await client.get(f"{base}/api/DeviceService/Devices?$top=5000", headers=headers) ).json() groups_resp = ( await client.get(f"{base}/api/GroupService/Groups?$top=200", headers=headers) ).json() alerts_raw = [] alerts_total = None try: alerts_resp = ( await client.get( f"{base}/api/AlertService/Alerts?$top=200", headers=headers, ) ).json() alerts_raw = alerts_resp.get("value") or [] alerts_total = alerts_resp.get("@odata.count") except Exception as e: log.warning("OME alerts fetch failed: %s", e) devices_raw = devices_resp.get("value") or [] # power for connected servers first power_targets = [ d.get("Id") for d in devices_raw if d.get("Type") == 1000 and d.get("ConnectionState") and d.get("Id") ][: settings.power_fetch_limit] # also a few offline servers for comparison offline_ids = [ d.get("Id") for d in devices_raw if d.get("Type") == 1000 and not d.get("ConnectionState") and d.get("Id") ][:5] power_targets = list(dict.fromkeys(power_targets + offline_ids)) sem = asyncio.Semaphore(8) async def limited(did): async with sem: return did, await fetch_power(client, base, headers, did) power_map = {} if power_targets: results = await asyncio.gather(*[limited(i) for i in power_targets]) power_map = {i: p for i, p in results if p} finally: if sid: try: await client.delete( f"{base}/api/SessionService/Sessions('{sid}')", headers=headers, ) except Exception: pass devices = [] model_counts: dict[str, int] = defaultdict(int) idrac_count = server_count = connected = powered = 0 switch_count = chassis_count = pdu_count = storage_count = 0 total_watts = 0.0 watts_samples = 0 status_counts: dict[str, int] = defaultdict(int) for d in devices_raw: mgmt = pick_management(d) ip = mgmt.get("idrac_ip") or pick_ip(d) subnet = subnet_of(ip) dtype = d.get("Type") sub = d.get("SubDeviceType") or "" model = d.get("Model") or "Unknown" is_server = dtype == 1000 is_idrac = sub == "iDRAC" is_switch = dtype == 7000 or "switch" in (sub or "").lower() or "switch" in model.lower() is_chassis = dtype == 2000 or "chassis" in (sub or "").lower() is_pdu = dtype == 100 is_storage = dtype == 5000 or "storage" in (sub or "").lower() if is_switch: role = "switch" elif is_chassis: role = "chassis" elif is_pdu: role = "pdu" elif is_storage: role = "storage" elif is_server: role = "server" else: role = "other" conn = bool(d.get("ConnectionState")) power_state = d.get("PowerState") status = str(d.get("Status") or "") status_counts[status] += 1 if is_server: model_counts[model] += 1 if is_idrac: idrac_count += 1 if is_switch: switch_count += 1 if is_chassis: chassis_count += 1 if is_pdu: pdu_count += 1 if is_storage: storage_count += 1 if is_server: server_count += 1 if conn: connected += 1 if power_state == 17: powered += 1 pwr = power_map.get(d.get("Id")) or {} watts = pwr.get("watts") if watts is not None: total_watts += watts watts_samples += 1 node = { "id": d.get("Id"), "name": d.get("DeviceName") or f"device-{d.get('Id')}", "model": model, "service_tag": d.get("DeviceServiceTag") or d.get("Identifier") or d.get("ChassisServiceTag"), "type": dtype, "sub_type": sub, "connected": conn, "power_state": power_state, "powered_on": power_state == 17, "status": status, "ip": ip, "idrac_ip": mgmt.get("idrac_ip") or ip, "os_hostname": mgmt.get("os_hostname"), "mgmt_dns_name": mgmt.get("mgmt_dns_name"), "rdp_host": None, "rdp_ips": [], "os_ips": [], "is_windows": False, "subnet": subnet, "role": role, "is_server": is_server, "is_idrac": is_idrac, "is_switch": is_switch, "is_chassis": is_chassis, "is_pdu": is_pdu, "is_storage": is_storage, "chassis_service_tag": d.get("ChassisServiceTag"), "idrac_url": f"https://{ip}" if ip else None, "watts": watts, "avg_watts": pwr.get("avg_watts"), "peak_watts": pwr.get("peak_watts"), "energy_kwh": pwr.get("energy_kwh"), "last_status_time": d.get("LastStatusTime"), "last_inventory_time": d.get("LastInventoryTime"), } node["map_cidrs"] = [subnet] if subnet and subnet != "unknown" else [] node["extra_ips"] = [] devices.append(node) await enrich_rdp_targets(devices) sync_endpoints_from_fleet(devices) apply_network_endpoints(devices) subnets = build_subnet_summaries(devices) groups = [ {"id": g.get("Id"), "name": g.get("Name")} for g in (groups_resp.get("value") or []) if g.get("Name") ][:60] models = [ {"name": m, "count": c} for m, c in sorted(model_counts.items(), key=lambda x: -x[1]) ] # --- realtime context: fleet deltas + alerts --- global PREV_DEVICES, EVENT_FEED now = time.time() new_events: list[dict] = [] first_poll = not PREV_DEVICES current_ids: set[int] = set() def _role(n: dict) -> str: if n.get("is_switch"): return "switch" if n.get("is_chassis"): return "chassis" if n.get("is_idrac"): return "iDRAC" if n.get("is_server"): return "server" if n.get("is_storage"): return "storage" if n.get("is_pdu"): return "PDU" return n.get("role") or "device" for n in devices: did = n.get("id") if did is None: continue current_ids.add(did) prev = PREV_DEVICES.get(did) if not prev: # First poll only seeds baseline; later polls raise new-node alerts if not first_poll and (n.get("is_server") or n.get("is_idrac")): role = _role(n) new_events.append({ "ts": now, "kind": "device_new", "severity": "critical", "device_id": did, "title": f"New {role} discovered", "text": ( f"{n.get('name')} · {n.get('ip') or 'no IP'} · " f"{n.get('model') or 'unknown model'} · {n.get('subnet') or 'unknown subnet'}" ), "notify": True, "role": role, "ip": n.get("ip"), "model": n.get("model"), "subnet": n.get("subnet"), "name": n.get("name"), }) continue if bool(prev.get("connected")) != bool(n.get("connected")): new_events.append({ "ts": now, "kind": "connection", "severity": "info" if n.get("connected") else "warning", "device_id": did, "title": n.get("name"), "text": "Connected" if n.get("connected") else "Went offline", }) if bool(prev.get("powered_on")) != bool(n.get("powered_on")): new_events.append({ "ts": now, "kind": "power_state", "severity": "info" if n.get("powered_on") else "warning", "device_id": did, "title": n.get("name"), "text": "Powered on" if n.get("powered_on") else "Powered off", }) pw, pp = n.get("watts"), prev.get("watts") if pw is not None and pp is not None and abs(pw - pp) >= 40: new_events.append({ "ts": now, "kind": "power_watt", "severity": "info", "device_id": did, "title": n.get("name"), "text": f"Power {round(pp)}W → {round(pw)}W", }) if str(prev.get("status")) != str(n.get("status")): new_events.append({ "ts": now, "kind": "status", "severity": "warning", "device_id": did, "title": n.get("name"), "text": f"Status {prev.get('status')} → {n.get('status')}", }) if not first_poll: for did, prev in PREV_DEVICES.items(): if did in current_ids: continue if not (prev.get("is_server") or prev.get("is_idrac")): continue role = _role(prev) new_events.append({ "ts": now, "kind": "device_removed", "severity": "warning", "device_id": did, "title": f"{role} left inventory", "text": ( f"{prev.get('name')} · {prev.get('ip') or 'no IP'} · " f"{prev.get('model') or 'unknown model'}" ), "notify": True, "role": role, "name": prev.get("name"), "ip": prev.get("ip"), "model": prev.get("model"), }) PREV_DEVICES = {n["id"]: n for n in devices if n.get("id") is not None} if new_events: EVENT_FEED = (new_events + EVENT_FEED)[:120] sev_map = {"Critical": 0, "Warning": 0, "Normal": 0, "Info": 0, "Unknown": 0} alerts = [] for a in alerts_raw: sev = a.get("SeverityName") or "Unknown" if sev not in sev_map: sev_map[sev] = 0 sev_map[sev] = sev_map.get(sev, 0) + 1 alerts.append({ "id": a.get("Id"), "severity": sev, "device_id": a.get("AlertDeviceId") or a.get("AlertEntityId"), "device": a.get("AlertDeviceName") or a.get("AlertEntityName"), "ip": a.get("AlertDeviceIpAddress"), "category": a.get("CategoryName"), "subcategory": a.get("SubCategoryName"), "message": a.get("Message"), "message_id": a.get("AlertMessageId"), "status": a.get("StatusName"), "time": a.get("TimeStamp"), "action": a.get("RecommendedAction"), }) hottest = sorted( [n for n in devices if n.get("watts") is not None], key=lambda x: x.get("watts") or 0, reverse=True, )[:5] recent_status = sorted( [n for n in devices if n.get("last_status_time")], key=lambda x: str(x.get("last_status_time")), reverse=True, )[:5] context = { "refreshed_at": now, "poll_seconds": settings.poll_interval, "alerts_total": alerts_total, "alert_severity": sev_map, "events_new": len(new_events), "notifications": [e for e in new_events if e.get("notify")], "hottest": [ {"id": n["id"], "name": n["name"], "watts": n["watts"], "subnet": n["subnet"]} for n in hottest ], "recent_status": [ { "id": n["id"], "name": n["name"], "time": n.get("last_status_time"), "connected": n.get("connected"), "status": n.get("status"), } for n in recent_status ], "focus_hint": ( f"{connected} connected · {round(total_watts)} W sampled · " f"{sev_map.get('Critical', 0)} critical / {sev_map.get('Warning', 0)} warning alerts in feed" ), } return { "ome": { "name": info.get("Name") or "OpenManage Enterprise", "version": info.get("Version"), "build": info.get("BuildNumber"), "url": base, "fqdn": "cov-omeprod01.dell-atc.lan", "console_url": f"{base}/management-console/ome/", }, "summary": { "total": len(devices), "idracs": idrac_count, "servers": server_count, "switches": switch_count, "chassis": chassis_count, "pdus": pdu_count, "storage": storage_count, "connected": connected, "powered_on": powered, "offline": max(0, server_count - connected), "status_counts": dict(status_counts), "total_watts": round(total_watts, 1), "avg_node_watts": round(total_watts / watts_samples, 1) if watts_samples else None, "power_samples": watts_samples, "alerts_critical": sev_map.get("Critical", 0), "alerts_warning": sev_map.get("Warning", 0), "alerts_total": alerts_total, }, "subnets": subnets, "devices": devices, "groups": groups, "models": models, "alerts": alerts, "events": EVENT_FEED[:40], "context": context, "openwebui_url": settings.openwebui_url, "updated_at": now, } async def broadcast(payload: dict): dead = [] for ws in list(CLIENTS): try: await ws.send_json(payload) except Exception: dead.append(ws) for ws in dead: CLIENTS.discard(ws) def _seed_atc_racks() -> None: """Idempotent seed: ATC1 (8×42U) + ATC2 (6×42U).""" now = time.time() with _db() as conn: n = conn.execute("SELECT COUNT(*) AS c FROM racks").fetchone()["c"] if n > 0: return conn.execute( "INSERT OR IGNORE INTO sites(id, name, sort_order) VALUES (?, ?, ?)", ("ATC1", "ATC1", 1), ) conn.execute( "INSERT OR IGNORE INTO sites(id, name, sort_order) VALUES (?, ?, ?)", ("ATC2", "ATC2", 2), ) for i in range(1, 9): conn.execute( """ INSERT INTO racks(site_id, name, units, sort_order, created_at, updated_at) VALUES (?, ?, 42, ?, ?, ?) """, ("ATC1", f"ATC1-R{i:02d}", i, now, now), ) for i in range(1, 7): conn.execute( """ INSERT INTO racks(site_id, name, units, sort_order, created_at, updated_at) VALUES (?, ?, 42, ?, ?, ?) """, ("ATC2", f"ATC2-R{i:02d}", i, now, now), ) log.info("Seeded ATC racks: ATC1×8 + ATC2×6 (42U)") def build_network_fabric(devices: list[dict] | None = None) -> dict: """Inferred switch↔server / chassis↔blade edges (no LLDP).""" devices = list(devices if devices is not None else (STATE.get("devices") or [])) by_id = {d.get("id"): d for d in devices if d.get("id") is not None} edges: list[dict] = [] seen: set[tuple] = set() def add_edge(a: int, b: int, kind: str): if a is None or b is None or a == b: return key = (min(a, b), max(a, b), kind) if key in seen: return seen.add(key) da, db = by_id.get(a) or {}, by_id.get(b) or {} edges.append( { "from_id": a, "to_id": b, "kind": kind, "confidence": "inferred", "note": ( "management subnet" if kind == "subnet" else "MX chassis / blade" ), "live": bool(da.get("connected") and db.get("connected")), } ) switches = [d for d in devices if d.get("is_switch")] for sw in switches: cidr = sw.get("subnet") or "unknown" if cidr == "unknown": continue for d in devices: if d.get("id") == sw.get("id"): continue if d.get("subnet") != cidr: continue if d.get("is_switch") or d.get("is_pdu"): continue # Prefer linking servers/storage/chassis endpoints if d.get("is_server") or d.get("is_storage") or d.get("is_chassis") or d.get("is_idrac"): add_edge(sw["id"], d["id"], "subnet") chassis_list = [d for d in devices if d.get("is_chassis")] for ch in chassis_list: cst = (ch.get("service_tag") or "").upper() for d in devices: if not d.get("is_server"): continue model = (d.get("model") or "").upper() blade_cst = (d.get("chassis_service_tag") or "").upper() if cst and blade_cst and blade_cst == cst: add_edge(ch["id"], d["id"], "chassis") elif "MX" in model and "MX7000" not in model: # fallback: all MX blades to MX chassis in same environment add_edge(ch["id"], d["id"], "chassis") return { "edges": edges, "note": "Edges inferred from management subnet / MX chassis — not LLDP.", "switches": [ { "id": s.get("id"), "name": s.get("name"), "service_tag": s.get("service_tag"), "ip": s.get("ip") or s.get("idrac_ip"), "subnet": s.get("subnet"), "connected": s.get("connected"), "model": s.get("model"), "connected_device_ids": [ e["to_id"] if e["from_id"] == s.get("id") else e["from_id"] for e in edges if e["kind"] == "subnet" and (e["from_id"] == s.get("id") or e["to_id"] == s.get("id")) ], } for s in switches ], "summary": { "devices": len(devices), "edges": len(edges), "switches": len(switches), "chassis": sum(1 for d in devices if d.get("is_chassis")), }, } def _default_u_height(device: dict | None) -> int: if not device: return 1 cat = match_rack_catalog(device.get("model"), device.get("role")) if cat is not None: uh = int(cat.get("u_height") or 0) return uh if uh > 0 else 1 # 0U vertical PDUs place as 1U marker unless side=left/right if device.get("is_chassis"): return 7 if device.get("is_storage"): return 2 if device.get("is_switch"): return 1 return 1 def _rack_overlap( conn: sqlite3.Connection, rack_id: int, u_start: int, u_height: int, side: str = "front", exclude_item_id: int | None = None, exclude_device_id: int | None = None, ) -> bool: if side in ("left", "right"): return False # side PDUs do not consume front/rear U map u_end = u_start + max(u_height, 1) - 1 rows = conn.execute( "SELECT id, device_id, u_start, u_height, side FROM rack_items WHERE rack_id=?", (rack_id,), ).fetchall() for r in rows: if exclude_item_id is not None and r["id"] == exclude_item_id: continue if exclude_device_id is not None and r["device_id"] == exclude_device_id: continue rside = (r["side"] or "front") if rside in ("left", "right"): continue if rside != side: continue a, b = r["u_start"], r["u_start"] + r["u_height"] - 1 if not (u_end < a or u_start > b): return True return False def _enrich_rack_item(item: dict, devices: dict) -> dict: out = dict(item) dev = devices.get(item.get("device_id")) if item.get("device_id") is not None else None sku = item.get("catalog_sku") cat = None if sku: cat = next((c for c in DELL_RACK_CATALOG if c["sku"] == sku), None) if not cat and dev: cat = match_rack_catalog(dev.get("model"), dev.get("role")) if cat: out["catalog"] = {k: cat[k] for k in ("sku", "name", "family", "category", "u_height", "face", "image") if k in cat} out["face"] = cat.get("face") out["image"] = cat.get("image") or catalog_image_for(cat) out["category"] = cat.get("category") else: out["catalog"] = None out["face"] = "custom" out["image"] = FACE_IMAGE_MAP["custom"] out["category"] = (dev or {}).get("role") or "custom" if dev: out["device"] = { "id": dev.get("id"), "name": dev.get("name"), "model": dev.get("model"), "service_tag": dev.get("service_tag"), "role": dev.get("role"), "ip": dev.get("ip") or dev.get("idrac_ip"), "connected": dev.get("connected"), "is_switch": dev.get("is_switch"), "is_server": dev.get("is_server"), "is_storage": dev.get("is_storage"), "is_chassis": dev.get("is_chassis"), "is_pdu": dev.get("is_pdu"), } out["label"] = item.get("label") or dev.get("name") out["model"] = dev.get("model") else: out["device"] = None out["label"] = item.get("label") or ((cat or {}).get("name") if cat else "Custom") out["model"] = (cat or {}).get("name") return out def _racks_payload() -> dict: _migrate_rack_items() devices = {d.get("id"): d for d in (STATE.get("devices") or []) if d.get("id") is not None} with _db() as conn: sites = [dict(r) for r in conn.execute("SELECT * FROM sites ORDER BY sort_order").fetchall()] racks = [dict(r) for r in conn.execute("SELECT * FROM racks ORDER BY site_id, sort_order").fetchall()] items = [dict(r) for r in conn.execute("SELECT * FROM rack_items ORDER BY u_start DESC").fetchall()] placed_ids = {i["device_id"] for i in items if i.get("device_id") is not None} by_site: dict[str, list] = {} for rk in racks: card = dict(rk) card["items"] = [] card["placements"] = [] # backward compat alias for it in items: if it["rack_id"] != rk["id"]: continue enriched = _enrich_rack_item(it, devices) card["items"].append(enriched) # compat shape for older UI bits card["placements"].append( { "device_id": it.get("device_id"), "rack_id": it["rack_id"], "u_start": it["u_start"], "u_height": it["u_height"], "item_id": it["id"], "side": it.get("side") or "front", "face": enriched.get("face"), "catalog": enriched.get("catalog"), "label": enriched.get("label"), "device": enriched.get("device") or { "id": it.get("device_id"), "name": enriched.get("label"), "model": enriched.get("model"), "role": enriched.get("category"), "service_tag": None, }, } ) by_site.setdefault(rk["site_id"], []).append(card) site_out = [{**s, "racks": by_site.get(s["id"], [])} for s in sites] unplaced = [] for d in STATE.get("devices") or []: if d.get("id") in placed_ids: continue if not (d.get("is_server") or d.get("is_switch") or d.get("is_chassis") or d.get("is_storage") or d.get("is_pdu")): continue cat = match_rack_catalog(d.get("model"), d.get("role")) unplaced.append( { "id": d.get("id"), "name": d.get("name"), "model": d.get("model"), "service_tag": d.get("service_tag"), "role": d.get("role"), "ip": d.get("ip") or d.get("idrac_ip"), "connected": d.get("connected"), "default_u_height": _default_u_height(d), "catalog_sku": (cat or {}).get("sku"), "face": (cat or {}).get("face") or "custom", "image": (cat or {}).get("image") or catalog_image_for(cat or {"face": "custom"}), "is_switch": d.get("is_switch"), "is_server": d.get("is_server"), "is_storage": d.get("is_storage"), "is_chassis": d.get("is_chassis"), "is_pdu": d.get("is_pdu"), } ) unplaced.sort(key=lambda x: ((x.get("role") or ""), (x.get("name") or "").lower())) return { "sites": site_out, "items": items, "placements": items, "unplaced": unplaced, "catalog": DELL_RACK_CATALOG, } def _switch_port_count(device: dict | None) -> int: """Default front-panel port count from model (OME has no LLDP ports here).""" model = ((device or {}).get("model") or "").lower() if "s4048" in model: return 48 if "s5248" in model or "s5296" in model: return 48 if "s4148" in model: return 48 if "mx9116" in model: return 16 if "mx5108" in model: return 8 return 48 def _port_label(port_num: int, total: int = 48) -> str: return f"Eth1/{port_num}" def build_switch_portmap(switch_id: int) -> dict: devices = {d.get("id"): d for d in (STATE.get("devices") or []) if d.get("id") is not None} sw = devices.get(switch_id) if not sw or not sw.get("is_switch"): # still allow if type 7000 missing flag somehow sw = next((d for d in (STATE.get("devices") or []) if d.get("id") == switch_id), None) if not sw: raise HTTPException(404, "Switch not found in fleet") nports = _switch_port_count(sw) with _db() as conn: rows = conn.execute( "SELECT * FROM port_links WHERE switch_id=? ORDER BY switch_port", (switch_id,), ).fetchall() links = [dict(r) for r in rows] by_port = {int(l["switch_port"]): l for l in links} ports = [] for i in range(1, nports + 1): link = by_port.get(i) peer = devices.get(link["device_id"]) if link else None ports.append( { "port": i, "label": _port_label(i, nports), "wired": bool(link), "link": ( { "id": link["id"], "device_id": link["device_id"], "device_port": link.get("device_port") or "", "note": link.get("note") or "", "device": { "id": peer.get("id"), "name": peer.get("name"), "service_tag": peer.get("service_tag"), "ip": peer.get("ip") or peer.get("idrac_ip"), "model": peer.get("model"), "role": peer.get("role"), "connected": peer.get("connected"), } if peer else None, } if link else None ), } ) return { "switch": { "id": sw.get("id"), "name": sw.get("name"), "model": sw.get("model"), "service_tag": sw.get("service_tag"), "ip": sw.get("ip") or sw.get("idrac_ip"), "subnet": sw.get("subnet"), "connected": sw.get("connected"), "port_count": nports, }, "ports": ports, "wired_count": sum(1 for p in ports if p["wired"]), "note": "Port wiring is manual (OME has no LLDP port map for this switch). Map switch port → server NIC/port.", } def _seed_atc_vlans() -> None: """Seed known ATC lab VLANs/subnets (editable). Ensures new catalog entries are added.""" now = time.time() defaults = [ (40, "iDRAC / OOB A", "10.0.40.0/24", "Out-of-band management (has S4048)", "ATC1", "#00a8e8", 1), (41, "iDRAC / OOB B", "10.0.41.0/24", "Out-of-band management", "ATC1", "#3dffe0", 2), (42, "iDRAC / OOB C", "10.0.42.0/24", "Out-of-band management", "ATC2", "#7c5cff", 3), (10, "Production / OS", "10.0.10.0/24", "Hypervisor & Windows OS / RDP", "ATC1", "#ff9a3c", 4), (11, "Lab / OS", "10.0.11.0/24", "Lab OS network", "ATC2", "#ff5c5c", 5), (20, "Shared services", "10.0.20.0/24", "Mgmt / demo / shared ATC services", "ATC1", "#a3e635", 6), (120, "Secondary OS", "10.0.120.0/24", "Secondary OS / RDP path", "ATC1", "#f0d060", 7), (90, "FDE cluster / AI", "10.0.90.0/24", "FDE platform · DNS · HAProxy · OPNsense · Proxmox (dell-fde.lan)", "FDE", "#38bdf8", 8), ] with _db() as conn: existing = { int(r["vlan_id"]) for r in conn.execute("SELECT vlan_id FROM vlans WHERE vlan_id IS NOT NULL").fetchall() if r["vlan_id"] is not None } added = 0 for vlan_id, name, cidr, purpose, site, color, so in defaults: if vlan_id in existing: continue conn.execute( """ INSERT INTO vlans(vlan_id, name, cidr, purpose, site_id, color, sort_order, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) """, (vlan_id, name, cidr, purpose, site, color, so, now, now), ) added += 1 if added: log.info("Seeded ATC VLAN catalog (+%s new, %s known defaults)", added, len(defaults)) def _synthetic_device_id(ip: str) -> int: """Stable negative id for inventory-only hosts (safe for JS Number).""" try: a, b, c, d = (int(x) for x in ip.split(".")) return -(a * 1_000_000 + b * 10_000 + c * 100 + d) except Exception: return -abs(hash(ip)) % 1_000_000_000 def _vlan_id_for_ip(ip: str) -> int | None: try: return int(ip.split(".")[2]) except Exception: return None def _ensure_network_endpoints_schema(conn) -> None: conn.execute( """ CREATE TABLE IF NOT EXISTS network_endpoints ( id INTEGER PRIMARY KEY AUTOINCREMENT, hostname TEXT, ip TEXT NOT NULL UNIQUE, role TEXT NOT NULL DEFAULT 'server', kind TEXT NOT NULL DEFAULT 'physical', device_id INTEGER, vlan_id INTEGER, switch_id INTEGER, switch_port INTEGER, model TEXT, note TEXT, user_note TEXT, created_at REAL NOT NULL, updated_at REAL NOT NULL ) """ ) cols = {r[1] for r in conn.execute("PRAGMA table_info(network_endpoints)").fetchall()} if "user_note" not in cols: conn.execute("ALTER TABLE network_endpoints ADD COLUMN user_note TEXT") def _upsert_network_endpoint( *, hostname: str | None, ip: str, role: str = "server", kind: str = "host", device_id: int | None = None, vlan_id: int | None = None, switch_id: int | None = None, switch_port: int | None = None, model: str | None = None, note: str | None = None, overwrite_identity: bool = False, clear_device_id: bool = False, ) -> None: """Insert/update inventory row. Never clobber user_note. Soft-update identity fields.""" ip = (ip or "").strip() if not ip or ip.count(".") != 3: return now = time.time() vlan_id = vlan_id if vlan_id is not None else _vlan_id_for_ip(ip) with _db() as conn: _ensure_network_endpoints_schema(conn) row = conn.execute("SELECT * FROM network_endpoints WHERE ip=?", (ip,)).fetchone() if not row: conn.execute( """ INSERT INTO network_endpoints( hostname, ip, role, kind, device_id, vlan_id, switch_id, switch_port, model, note, user_note, created_at, updated_at ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, NULL, ?, ?) """, ( hostname, ip, role or "server", kind or "host", device_id, vlan_id, switch_id, switch_port, model, note, now, now, ), ) return # Update carefully sets = ["updated_at=?"] vals: list = [now] if overwrite_identity or not row["hostname"]: if hostname: sets.append("hostname=?") vals.append(hostname) if overwrite_identity or not row["model"]: if model: sets.append("model=?") vals.append(model) if overwrite_identity or not row["note"]: if note: sets.append("note=?") vals.append(note) if overwrite_identity and note is not None: sets.append("note=?") vals.append(note) if overwrite_identity and model is not None: if "model=?" not in sets: sets.append("model=?") vals.append(model) if overwrite_identity and hostname is not None: if "hostname=?" not in sets: sets.append("hostname=?") vals.append(hostname) if role: sets.append("role=?") vals.append(role) if kind: sets.append("kind=?") vals.append(kind) if vlan_id is not None: sets.append("vlan_id=?") vals.append(vlan_id) if clear_device_id: sets.append("device_id=NULL") elif device_id is not None and (overwrite_identity or row["device_id"] is None): sets.append("device_id=?") vals.append(device_id) if switch_id is not None and (overwrite_identity or row["switch_id"] is None): sets.append("switch_id=?") vals.append(switch_id) if switch_port is not None and (overwrite_identity or row["switch_port"] is None): sets.append("switch_port=?") vals.append(switch_port) vals.append(ip) conn.execute(f"UPDATE network_endpoints SET {', '.join(sets)} WHERE ip=?", vals) def _known_host_inventory() -> list[dict]: """Static + discovered lab hosts (OS / FDE / shared services).""" return [ # VLAN 90 — FDE (physical Proxmox nodes are R640 per ops; OME idrac-proxmox R740xd is a different box) {"hostname": "router.dell-fde.lan", "ip": "10.0.90.1", "role": "gateway", "kind": "appliance", "vlan_id": 90, "model": None, "note": "FDE gateway"}, {"hostname": "ns.dell-fde.lan", "ip": "10.0.90.2", "role": "dns", "kind": "vm", "vlan_id": 90, "note": "FDE DNS"}, {"hostname": "haproxy.dell-fde.lan", "ip": "10.0.90.3", "role": "lb", "kind": "vm", "vlan_id": 90, "note": "FDE HAProxy"}, {"hostname": "caddy.dell-fde.lan", "ip": "10.0.90.4", "role": "proxy", "kind": "vm", "vlan_id": 90, "note": "FDE Caddy"}, {"hostname": "opnsense01.dell-fde.lan", "ip": "10.0.90.5", "role": "firewall", "kind": "vm", "vlan_id": 90, "note": "FDE OPNsense 1"}, {"hostname": "opnsense02.dell-fde.lan", "ip": "10.0.90.6", "role": "firewall", "kind": "vm", "vlan_id": 90, "note": "FDE OPNsense 2"}, { "hostname": "proxmox01.dell-fde.lan", "ip": "10.0.90.21", "role": "server", "kind": "physical", "vlan_id": 90, "model": "PowerEdge R640", "device_id": None, "switch_id": 51950, "note": "FDE Proxmox node 1 · R640 (ops). Candidate iDRAC: C90XK53/B90XK53 on VLAN41 — not the R740xd idrac-proxmox.", "overwrite_identity": True, "clear_device_id": True, }, { "hostname": "proxmox02.dell-fde.lan", "ip": "10.0.90.22", "role": "server", "kind": "physical", "vlan_id": 90, "model": "PowerEdge R640", "device_id": None, "switch_id": 51950, "note": "FDE Proxmox node 2 · R640 (ops). Candidate iDRAC: C90XK53/B90XK53 on VLAN41.", "overwrite_identity": True, "clear_device_id": True, }, {"hostname": "fde-dns.fde-dns.lan", "ip": "10.0.90.41", "role": "dns", "kind": "vm", "vlan_id": 90}, {"hostname": "dns-server.fde-dns.lan", "ip": "10.0.90.42", "role": "dns", "kind": "vm", "vlan_id": 90}, {"hostname": "db01.dell-fde.lan", "ip": "10.0.90.181", "role": "database", "kind": "vm", "vlan_id": 90}, # VLAN 10 — Production OS {"hostname": "cop-hv01.dell-atc.lan", "ip": "10.0.10.13", "role": "hypervisor", "kind": "physical", "vlan_id": 10}, {"hostname": "cop-dc01.dell-atc.lan", "ip": "10.0.10.15", "role": "dc", "kind": "vm", "vlan_id": 10}, {"hostname": "cop-dc02.dell-atc.lan", "ip": "10.0.10.16", "role": "dc", "kind": "vm", "vlan_id": 10}, {"hostname": "cop-vsan-vdi01.dell-atc.lan", "ip": "10.0.10.41", "role": "hypervisor", "kind": "physical", "vlan_id": 10}, {"hostname": "cop-vsan-vdi02.dell-atc.lan", "ip": "10.0.10.42", "role": "hypervisor", "kind": "physical", "vlan_id": 10}, {"hostname": "cop-vsan-vdi03.dell-atc.lan", "ip": "10.0.10.43", "role": "hypervisor", "kind": "physical", "vlan_id": 10}, {"hostname": "cop-vsan-vdi04.dell-atc.lan", "ip": "10.0.10.44", "role": "hypervisor", "kind": "physical", "vlan_id": 10}, {"hostname": "pve01.dell-atc.dell.nl", "ip": "10.0.10.65", "role": "server", "kind": "physical", "vlan_id": 10, "note": "Proxmox VE (ATC)"}, {"hostname": "atc-gpu-prod.dell-atc.lan", "ip": "10.0.10.106", "role": "gpu", "kind": "physical", "vlan_id": 10}, # VLAN 20 — Shared services {"hostname": "cov-dc03.dell-atc.lan", "ip": "10.0.20.15", "role": "dc", "kind": "vm", "vlan_id": 20}, {"hostname": "cov-omedemo01.dell-atc.lan", "ip": "10.0.20.22", "role": "ome", "kind": "vm", "vlan_id": 20}, {"hostname": "cov-cloudiq01.dell-atc.lan", "ip": "10.0.20.23", "role": "cloudiq", "kind": "vm", "vlan_id": 20}, {"hostname": "cov-scg01.dell-atc.lan", "ip": "10.0.20.31", "role": "scg", "kind": "vm", "vlan_id": 20}, {"hostname": "cov-vcsa-vsan01.dell-atc.lan", "ip": "10.0.20.40", "role": "vcenter", "kind": "vm", "vlan_id": 20}, {"hostname": "photon-machine.dell-atc.lan", "ip": "10.0.20.102", "role": "host", "kind": "vm", "vlan_id": 20}, {"hostname": "atc-grafana.dell-atc.lan", "ip": "10.0.20.103", "role": "monitoring", "kind": "vm", "vlan_id": 20}, {"hostname": "atc-mgt01.dell-atc.lan", "ip": "10.0.20.104", "role": "mgmt", "kind": "vm", "vlan_id": 20}, {"hostname": "atc-dataflow01.dell-atc.lan", "ip": "10.0.20.105", "role": "dataflow", "kind": "vm", "vlan_id": 20}, {"hostname": "atc-gpu-dev.dell-atc.lan", "ip": "10.0.20.106", "role": "gpu", "kind": "vm", "vlan_id": 20}, {"hostname": "atc-spearmint.dell-atc.lan", "ip": "10.0.20.108", "role": "app", "kind": "vm", "vlan_id": 20}, {"hostname": "cov-file01.dell-atc.lan", "ip": "10.0.20.109", "role": "files", "kind": "vm", "vlan_id": 20}, {"hostname": "atc-nas.dell-atc.lan", "ip": "10.0.20.110", "role": "nas", "kind": "appliance", "vlan_id": 20}, {"hostname": "atc-objectscale.dell-atc.lan", "ip": "10.0.20.111", "role": "object", "kind": "appliance", "vlan_id": 20}, {"hostname": "atc-db01.dell-atc.lan", "ip": "10.0.20.112", "role": "database", "kind": "vm", "vlan_id": 20}, {"hostname": "atc-backup.dell-atc.lan", "ip": "10.0.20.113", "role": "backup", "kind": "vm", "vlan_id": 20}, {"hostname": "opnsense03.dell-atc.lan", "ip": "10.0.20.114", "role": "firewall", "kind": "vm", "vlan_id": 20}, {"hostname": "atc-git.dell-atc.lan", "ip": "10.0.20.118", "role": "git", "kind": "vm", "vlan_id": 20}, {"hostname": "cvd-jody01.dell-atc.lan", "ip": "10.0.20.119", "role": "desktop", "kind": "vm", "vlan_id": 20}, {"hostname": "emj-wilcor01.dell-atc.lan", "ip": "10.0.20.133", "role": "desktop", "kind": "vm", "vlan_id": 20}, {"hostname": "cov-wac01.dell-atc.lan", "ip": "10.0.20.147", "role": "wac", "kind": "vm", "vlan_id": 20}, {"hostname": "CVP-MGMT01.dell-atc.lan", "ip": "10.0.20.164", "role": "mgmt", "kind": "vm", "vlan_id": 20}, ] def _seed_network_endpoints() -> None: """Ensure schema + known host inventory + correct FDE Proxmox identity.""" with _db() as conn: _ensure_network_endpoints_schema(conn) for h in _known_host_inventory(): _upsert_network_endpoint( hostname=h.get("hostname"), ip=h["ip"], role=h.get("role") or "server", kind=h.get("kind") or "host", device_id=h.get("device_id"), vlan_id=h.get("vlan_id"), switch_id=h.get("switch_id"), switch_port=h.get("switch_port"), model=h.get("model"), note=h.get("note"), overwrite_identity=bool(h.get("overwrite_identity")), clear_device_id=bool(h.get("clear_device_id")), ) def sync_endpoints_from_fleet(devices: list[dict] | None = None) -> int: """Upsert every OME fleet mgmt IP into network_endpoints (enables notes on all systems).""" _seed_network_endpoints() devices = devices if devices is not None else (STATE.get("devices") or []) n = 0 for d in devices: if d.get("source") == "inventory": continue did = d.get("id") if not isinstance(did, int) or did <= 0: continue ip = d.get("idrac_ip") or d.get("ip") if not ip: continue role = d.get("role") or "server" kind = "switch" if d.get("is_switch") else ("pdu" if d.get("is_pdu") else ("chassis" if d.get("is_chassis") else "physical")) _upsert_network_endpoint( hostname=d.get("name"), ip=ip, role=role, kind=kind, device_id=did, vlan_id=_vlan_id_for_ip(ip), model=d.get("model"), note=f"OME {d.get('service_tag') or did}", ) n += 1 # Also register secondary OS IPs from rdp/os/extra for sip in (d.get("rdp_ips") or []) + (d.get("os_ips") or []) + (d.get("extra_ips") or []): if not sip or sip == ip: continue _upsert_network_endpoint( hostname=d.get("cluster_hostname") or d.get("os_hostname") or d.get("name"), ip=sip, role=role, kind="os", device_id=did, vlan_id=_vlan_id_for_ip(sip), model=d.get("model"), note=f"OS/secondary IP for OME {d.get('service_tag') or did}", ) n += 1 return n def _load_network_endpoints() -> list[dict]: _seed_network_endpoints() with _db() as conn: _ensure_network_endpoints_schema(conn) return [dict(r) for r in conn.execute("SELECT * FROM network_endpoints ORDER BY ip").fetchall()] def _load_vlan_catalog_by_cidr() -> dict[str, dict]: _seed_atc_vlans() with _db() as conn: rows = [dict(r) for r in conn.execute("SELECT * FROM vlans").fetchall()] out = {} for r in rows: cidr = (r.get("cidr") or "").strip() if cidr: out[cidr] = r return out def _device_ips(d: dict) -> list[str]: out: list[str] = [] for k in ("ip", "idrac_ip"): v = d.get(k) if v and v not in out: out.append(v) for key in ("rdp_ips", "os_ips", "extra_ips"): for v in d.get(key) or []: if v and v not in out: out.append(v) return out def _refresh_map_cidrs(d: dict) -> None: cidrs: list[str] = [] seen: set[str] = set() primary = d.get("subnet") if primary and primary != "unknown" and primary not in seen: seen.add(primary) cidrs.append(primary) for ip in _device_ips(d): c = subnet_of(ip) if c != "unknown" and c not in seen: seen.add(c) cidrs.append(c) d["map_cidrs"] = cidrs def apply_network_endpoints(devices: list[dict]) -> None: """Attach inventory IPs to OME devices and inject synthetic hosts for unlinked endpoints.""" endpoints = _load_network_endpoints() if not endpoints: for d in devices: _refresh_map_cidrs(d) return by_id = {d.get("id"): d for d in devices if d.get("id") is not None} # Index existing IPs on devices to avoid duplicate synthetics for OME mgmt IPs device_ips: set[str] = set() for d in devices: for ip in _device_ips(d): device_ips.add(ip) existing_ids = set(by_id) used_synth: set[int] = set() for ep in endpoints: ip = (ep.get("ip") or "").strip() if not ip or ip.count(".") != 3: continue hostname = (ep.get("hostname") or ip).strip() did = ep.get("device_id") user_note = ep.get("user_note") or "" sys_note = ep.get("note") or "" if did is not None and did in by_id: d = by_id[did] extras = list(d.get("extra_ips") or []) # Only treat as extra if not already primary mgmt IP if ip not in extras and ip != d.get("ip") and ip != d.get("idrac_ip"): extras.append(ip) d["extra_ips"] = extras if ip != d.get("ip") and ip != d.get("idrac_ip"): os_ips = list(d.get("os_ips") or []) if ip not in os_ips: os_ips.append(ip) d["os_ips"] = os_ips rdp = list(d.get("rdp_ips") or []) if ip not in rdp: rdp.append(ip) d["rdp_ips"] = rdp d["cluster_hostname"] = hostname d["endpoint_id"] = ep.get("id") d["endpoint_note"] = sys_note d["user_note"] = user_note if ep.get("model") and (d.get("source") == "inventory" or not d.get("model")): pass # keep OME model authoritative for linked devices if ep.get("switch_id") is not None: d["endpoint_switch_id"] = ep.get("switch_id") d["endpoint_switch_port"] = ep.get("switch_port") _refresh_map_cidrs(d) continue # Skip synthetic if this IP already belongs to an OME device in the fleet if ip in device_ips: # Still attach notes onto the matching device if we can find it for d in devices: if ip in _device_ips(d) or d.get("ip") == ip or d.get("idrac_ip") == ip: d["endpoint_id"] = ep.get("id") d["endpoint_note"] = sys_note d["user_note"] = user_note break continue sid = _synthetic_device_id(ip) if sid in existing_ids or sid in used_synth: continue used_synth.add(sid) cidr = subnet_of(ip) role = ep.get("role") or "server" devices.append( { "id": sid, "name": hostname, "model": ep.get("model") or "Inventory host", "service_tag": None, "type": 1000, "sub_type": "inventory", "connected": True, "power_state": None, "powered_on": None, "status": "inventory", "ip": ip, "idrac_ip": None, "os_hostname": hostname, "mgmt_dns_name": hostname, "rdp_host": ip, "rdp_ips": [ip], "os_ips": [ip], "extra_ips": [ip], "is_windows": False, "subnet": cidr, "map_cidrs": [cidr], "role": role, "is_server": True, "is_idrac": False, "is_switch": role == "switch", "is_chassis": False, "is_pdu": role == "pdu", "is_storage": role == "storage", "chassis_service_tag": None, "idrac_url": None, "watts": None, "source": "inventory", "endpoint_id": ep.get("id"), "endpoint_note": sys_note, "user_note": user_note, "endpoint_switch_id": ep.get("switch_id"), "endpoint_switch_port": ep.get("switch_port"), "cluster_hostname": hostname, } ) for d in devices: _refresh_map_cidrs(d) def build_subnet_summaries(devices: list[dict]) -> list[dict]: """Aggregate devices by map_cidrs (mgmt + secondary/inventory IPs) and attach VLAN catalog labels.""" catalog = _load_vlan_catalog_by_cidr() buckets: dict[str, list[dict]] = defaultdict(list) for d in devices: cidrs = d.get("map_cidrs") or ([d.get("subnet")] if d.get("subnet") else []) for c in cidrs: if not c or c == "unknown": continue buckets[c].append(d) for cidr in catalog: buckets.setdefault(cidr, []) subnets = [] for cidr, members in sorted(buckets.items(), key=lambda x: (-len(x[1]), x[0])): seen: set = set() uniq = [] for m in members: mid = m.get("id") if mid in seen: continue seen.add(mid) uniq.append(m) ids = [m["id"] for m in uniq if m.get("id") is not None] sub_watts = sum(n["watts"] or 0 for n in uniq if n.get("watts") is not None) switches = [n for n in uniq if n.get("is_switch")] meta = catalog.get(cidr) or {} subnets.append( { "cidr": cidr, "count": len(uniq), "connected": sum(1 for n in uniq if n.get("connected")), "watts": round(sub_watts, 1) if sub_watts else None, "device_ids": ids, "has_switch": bool(switches), "switches": [ { "id": s["id"], "name": s["name"], "service_tag": s.get("service_tag"), "ip": s.get("ip"), } for s in switches ], "vlan_id": meta.get("vlan_id"), "vlan_name": meta.get("name"), "vlan_color": meta.get("color"), "site_id": meta.get("site_id"), } ) return subnets def _all_port_links() -> list[dict]: with _db() as conn: conn.execute( """ CREATE TABLE IF NOT EXISTS port_links ( id INTEGER PRIMARY KEY AUTOINCREMENT, switch_id INTEGER NOT NULL, switch_port INTEGER NOT NULL, device_id INTEGER NOT NULL, device_port TEXT, note TEXT, updated_at REAL NOT NULL, UNIQUE(switch_id, switch_port) ) """ ) return [dict(r) for r in conn.execute("SELECT * FROM port_links").fetchall()] _NETWORK_INV_TS = 0.0 def ensure_network_inventory_in_state(force: bool = False) -> None: """Merge inventory endpoints into live fleet STATE (safe between OME polls).""" global _NETWORK_INV_TS now = time.time() if not force and STATE.get("devices") and (now - _NETWORK_INV_TS) < 8: return devices = list(STATE.get("devices") or []) sync_endpoints_from_fleet(devices) devices = [d for d in devices if d.get("source") != "inventory"] apply_network_endpoints(devices) STATE["devices"] = devices STATE["subnets"] = build_subnet_summaries(devices) _NETWORK_INV_TS = now def build_vlan_map() -> dict: """VLANs from catalog + membership from fleet IPs + inventory endpoints; switch attachment matrix.""" _seed_atc_vlans() _seed_network_endpoints() _migrate_rack_items() ensure_network_inventory_in_state() devices = list(STATE.get("devices") or []) with _db() as conn: rows = [dict(r) for r in conn.execute("SELECT * FROM vlans ORDER BY sort_order, vlan_id").fetchall()] def in_cidr(ip: str, cidr: str) -> bool: try: return ipaddress.ip_address(ip) in ipaddress.ip_network(cidr, strict=False) except Exception: return False switches_by_id = {d.get("id"): d for d in devices if d.get("is_switch")} links = _all_port_links() links_by_device: dict[int, list[dict]] = defaultdict(list) for link in links: did = link.get("device_id") if did is None: continue sw = switches_by_id.get(link.get("switch_id")) or {} links_by_device[int(did)].append( { "switch_id": link.get("switch_id"), "switch_name": sw.get("name") or f"switch-{link.get('switch_id')}", "switch_ip": sw.get("ip") or sw.get("idrac_ip"), "switch_port": link.get("switch_port"), "device_port": link.get("device_port") or "", "note": link.get("note") or "", } ) for d in devices: sid = d.get("endpoint_switch_id") if sid is None: continue did = d.get("id") if did is None: continue already = {(x["switch_id"], x.get("switch_port")) for x in links_by_device.get(did, [])} port = d.get("endpoint_switch_port") key = (sid, port) if key in already: continue sw = switches_by_id.get(sid) or {} links_by_device[int(did)].append( { "switch_id": sid, "switch_name": sw.get("name") or f"switch-{sid}", "switch_ip": sw.get("ip") or sw.get("idrac_ip"), "switch_port": port, "device_port": "", "note": "from inventory endpoint", "inferred": True, } ) eps_by_ip = {e.get("ip"): e for e in _load_network_endpoints() if e.get("ip")} vlans = [] for v in rows: members = [] for d in devices: matched = [ip for ip in _device_ips(d) if in_cidr(ip, v["cidr"])] if not matched: continue mid = d.get("id") display = d.get("cluster_hostname") or d.get("name") ep = None for ip in matched: if ip in eps_by_ip: ep = eps_by_ip[ip] break model = d.get("model") if d.get("source") == "inventory" and ep and ep.get("model"): model = ep.get("model") members.append( { "id": mid, "name": display, "ome_name": d.get("name"), "service_tag": d.get("service_tag"), "role": d.get("role"), "connected": d.get("connected"), "ips": matched, "model": model, "source": d.get("source") or "ome", "switch_ports": links_by_device.get(mid, []), "endpoint_id": d.get("endpoint_id") or (ep or {}).get("id"), "endpoint_note": d.get("endpoint_note") or (ep or {}).get("note") or "", "user_note": d.get("user_note") or (ep or {}).get("user_note") or "", } ) members.sort(key=lambda m: (m.get("name") or "").lower()) vlans.append( { **v, "member_count": len(members), "connected_count": sum(1 for m in members if m.get("connected")), "members": members[:250], } ) matrix = [] for d in devices: if not (d.get("is_server") or d.get("source") == "inventory"): continue if d.get("is_switch") or d.get("is_pdu"): continue ips = _device_ips(d) member_vlans = [] for v in rows: matched = [ip for ip in ips if in_cidr(ip, v["cidr"])] if matched: member_vlans.append( { "vlan_id": v.get("vlan_id"), "name": v.get("name"), "cidr": v.get("cidr"), "color": v.get("color"), "ips": matched, } ) if not member_vlans and not links_by_device.get(d.get("id")): continue matrix.append( { "id": d.get("id"), "name": d.get("cluster_hostname") or d.get("name"), "ome_name": d.get("name"), "service_tag": d.get("service_tag"), "model": d.get("model"), "role": d.get("role"), "connected": d.get("connected"), "source": d.get("source") or "ome", "ips": ips, "vlans": member_vlans, "switch_ports": links_by_device.get(d.get("id"), []), "endpoint_id": d.get("endpoint_id"), "endpoint_note": d.get("endpoint_note") or "", "user_note": d.get("user_note") or "", } ) matrix.sort(key=lambda m: (m.get("name") or "").lower()) seen = {v["cidr"] for v in rows} discovered = [] buckets: dict[str, int] = {} for d in devices: for ip in _device_ips(d): if ip.count(".") != 3: continue cidr = ".".join(ip.split(".")[:3]) + ".0/24" if cidr.startswith("127."): continue buckets[cidr] = buckets.get(cidr, 0) + 1 for cidr, cnt in sorted(buckets.items(), key=lambda x: -x[1]): if cidr not in seen: discovered.append({"cidr": cidr, "device_ips": cnt, "in_catalog": False}) return { "vlans": vlans, "attachment_matrix": matrix, "port_links": [ { "switch_id": l.get("switch_id"), "switch_port": l.get("switch_port"), "device_id": l.get("device_id"), "device_port": l.get("device_port"), "note": l.get("note"), } for l in links ], "discovered_subnets": discovered, "note": "VLAN catalog + OME IPs + inventory endpoints. Switch ports from Fabric wiring and endpoint hints.", } def _migrate_rack_items() -> None: """Copy legacy rack_placements into flexible rack_items once.""" with _db() as conn: n = conn.execute("SELECT COUNT(*) AS c FROM rack_items").fetchone()["c"] if n > 0: return try: rows = conn.execute("SELECT * FROM rack_placements").fetchall() except Exception: return now = time.time() for r in rows: conn.execute( """ INSERT INTO rack_items(rack_id, device_id, catalog_sku, label, u_start, u_height, side, notes, updated_at) VALUES (?, ?, NULL, NULL, ?, ?, 'front', NULL, ?) """, (r["rack_id"], r["device_id"], r["u_start"], r["u_height"], now), ) if rows: log.info("Migrated %s rack_placements → rack_items", len(rows)) OPS_USERS = [ { "id": "jody", "name": "Jody van Dongen", "role": "ATC Datacenter Admin", "team": "admin", "email": "jody.van.dongen@dell.com", "focus": "OME fleet · racks · warranty / compliance", }, { "id": "laurens", "name": "Laurens Rammers", "role": "ATC Datacenter Admin", "team": "admin", "email": "laurens.rammers@dell.com", "focus": "Datacenter ops · handoffs · escalation", }, { "id": "mo", "name": "Mohamed El Kadi", "role": "Data Forward Deployed Engineer", "team": "fde", "email": "mohamed.el.kadi@dell.com", "focus": "OME Cockpit · OpenManage AI · AI workloads on FDE cluster", }, { "id": "bart", "name": "Bart Sjerps", "role": "Data Forward Deployed Engineer", "team": "fde", "email": "bart.sjerps@dell.com", "focus": "FDE cluster · AI workload deployment with Mo", }, ] # Back-compat alias used by older ticket endpoints / UI ADMINS = OPS_USERS def _ops_user(uid: str) -> dict | None: for u in OPS_USERS: if u["id"] == uid: return u return None def _default_assignee(created_by: str) -> str: """Admins hand off to each other; FDE hand off to an admin by default.""" if created_by == "jody": return "laurens" if created_by == "laurens": return "jody" if created_by in ("mo", "bart"): return "jody" return "jody" DATA_DIR = Path(settings.cockpit_data) DB_PATH = DATA_DIR / "ops.db" def _db(): DATA_DIR.mkdir(parents=True, exist_ok=True) conn = sqlite3.connect(DB_PATH) conn.row_factory = sqlite3.Row conn.execute("PRAGMA foreign_keys=ON") return conn def _backup_ops_db(reason: str = "manual") -> Path | None: """Snapshot ops.db so rebuilds / wipes do not silently lose ticket history.""" try: if not DB_PATH.exists() or DB_PATH.stat().st_size < 100: return None bak_dir = DATA_DIR / "backups" bak_dir.mkdir(parents=True, exist_ok=True) stamp = time.strftime("%Y%m%d-%H%M%S") dest = bak_dir / f"ops-{stamp}-{reason}-{int(time.time()*1000)%100000}.db" import shutil shutil.copy2(DB_PATH, dest) # keep last 20 backups olds = sorted(bak_dir.glob("ops-*.db"), key=lambda x: x.stat().st_mtime, reverse=True) for stale in olds[20:]: try: stale.unlink() except Exception: pass return dest except Exception as e: log.warning("ops.db backup failed: %s", e) return None def _restore_ops_db_if_empty() -> bool: """If tickets table is empty but a backup exists, restore the newest backup with tickets.""" try: with _db() as conn: n = conn.execute("SELECT COUNT(*) AS c FROM tickets").fetchone()["c"] if n > 0: return False bak_dir = DATA_DIR / "backups" if not bak_dir.exists(): return False candidates = sorted(bak_dir.glob("ops-*.db"), key=lambda x: x.stat().st_mtime, reverse=True) for cand in candidates: try: c2 = sqlite3.connect(cand) c2.row_factory = sqlite3.Row cnt = c2.execute("SELECT COUNT(*) AS c FROM tickets").fetchone()["c"] c2.close() if cnt <= 0: continue import shutil # preserve empty current as .empty if DB_PATH.exists(): shutil.copy2(DB_PATH, bak_dir / f"ops-empty-before-restore-{int(time.time())}.db") shutil.copy2(cand, DB_PATH) log.warning("Restored ops.db from backup %s (%s tickets)", cand.name, cnt) return True except Exception as e: log.debug("skip backup %s: %s", cand, e) return False except Exception as e: log.warning("ops.db restore check failed: %s", e) return False def init_db(): DATA_DIR.mkdir(parents=True, exist_ok=True) with _db() as conn: conn.executescript( """ CREATE TABLE IF NOT EXISTS tickets ( id INTEGER PRIMARY KEY AUTOINCREMENT, title TEXT NOT NULL, body TEXT NOT NULL, status TEXT NOT NULL DEFAULT 'open', priority TEXT NOT NULL DEFAULT 'normal', created_by TEXT NOT NULL, assignee TEXT, accepted_by TEXT, created_at REAL NOT NULL, updated_at REAL NOT NULL ); CREATE TABLE IF NOT EXISTS ticket_messages ( id INTEGER PRIMARY KEY AUTOINCREMENT, ticket_id INTEGER NOT NULL REFERENCES tickets(id) ON DELETE CASCADE, author TEXT NOT NULL, body TEXT NOT NULL, created_at REAL NOT NULL ); """ ) cols = {r[1] for r in conn.execute("PRAGMA table_info(tickets)").fetchall()} if "accepted_by" not in cols: conn.execute("ALTER TABLE tickets ADD COLUMN accepted_by TEXT") conn.executescript( """ CREATE TABLE IF NOT EXISTS sites ( id TEXT PRIMARY KEY, name TEXT NOT NULL, sort_order INTEGER NOT NULL ); CREATE TABLE IF NOT EXISTS racks ( id INTEGER PRIMARY KEY AUTOINCREMENT, site_id TEXT NOT NULL REFERENCES sites(id), name TEXT NOT NULL, units INTEGER NOT NULL DEFAULT 42, sort_order INTEGER NOT NULL, created_at REAL NOT NULL, updated_at REAL NOT NULL ); CREATE TABLE IF NOT EXISTS rack_placements ( device_id INTEGER PRIMARY KEY, rack_id INTEGER NOT NULL REFERENCES racks(id) ON DELETE CASCADE, u_start INTEGER NOT NULL, u_height INTEGER NOT NULL, updated_at REAL NOT NULL ); CREATE TABLE IF NOT EXISTS rack_items ( id INTEGER PRIMARY KEY AUTOINCREMENT, rack_id INTEGER NOT NULL REFERENCES racks(id) ON DELETE CASCADE, device_id INTEGER, catalog_sku TEXT, label TEXT, u_start INTEGER NOT NULL, u_height INTEGER NOT NULL, side TEXT NOT NULL DEFAULT 'front', notes TEXT, updated_at REAL NOT NULL ); CREATE TABLE IF NOT EXISTS port_links ( id INTEGER PRIMARY KEY AUTOINCREMENT, switch_id INTEGER NOT NULL, switch_port INTEGER NOT NULL, device_id INTEGER NOT NULL, device_port TEXT, note TEXT, updated_at REAL NOT NULL, UNIQUE(switch_id, switch_port) ); CREATE TABLE IF NOT EXISTS vlans ( id INTEGER PRIMARY KEY AUTOINCREMENT, vlan_id INTEGER, name TEXT NOT NULL, cidr TEXT NOT NULL, purpose TEXT, site_id TEXT, color TEXT, sort_order INTEGER NOT NULL DEFAULT 0, created_at REAL NOT NULL, updated_at REAL NOT NULL ); CREATE TABLE IF NOT EXISTS network_endpoints ( id INTEGER PRIMARY KEY AUTOINCREMENT, hostname TEXT, ip TEXT NOT NULL UNIQUE, role TEXT NOT NULL DEFAULT 'server', kind TEXT NOT NULL DEFAULT 'physical', device_id INTEGER, vlan_id INTEGER, switch_id INTEGER, switch_port INTEGER, model TEXT, note TEXT, created_at REAL NOT NULL, updated_at REAL NOT NULL ); """ ) _restore_ops_db_if_empty() _seed_atc_racks() _seed_atc_vlans() _seed_network_endpoints() n = 0 try: with _db() as conn: n = conn.execute("SELECT COUNT(*) AS c FROM tickets").fetchone()["c"] except Exception: pass log.info("Ops tickets DB ready at %s (%s tickets)", DB_PATH, n) async def fetch_gpu() -> dict: url = settings.gpu_metrics_url.rstrip("/") + "/api/gpu" try: async with httpx.AsyncClient(timeout=4.0) as client: r = await client.get(url) if r.status_code == 200: return r.json() except Exception as e: log.warning("GPU metrics fetch failed: %s", e) return { "host": "atc-gpu-prod", "gpus": [], "error": "unreachable", "updated_at": time.time(), "model": settings.vllm_model, } async def poll_loop(): while True: try: data = await ome_fetch() gpu = await fetch_gpu() data["gpu"] = gpu async with _lock: STATE.update(data) STATE["pulse"] = int(STATE.get("pulse") or 0) + 1 await broadcast({"type": "snapshot", "data": {**STATE}}) log.info( "OME refresh: %s devices, %s connected, %s W sampled, GPU util avg %s", STATE["summary"].get("total"), STATE["summary"].get("connected"), STATE["summary"].get("total_watts"), (gpu.get("summary") or {}).get("avg_util"), ) except Exception as e: log.exception("OME poll failed: %s", e) await broadcast({"type": "error", "message": str(e)}) await asyncio.sleep(settings.poll_interval) async def gpu_loop(): """Faster GPU pulse so matrix feels live during inference.""" while True: try: gpu = await fetch_gpu() async with _lock: STATE["gpu"] = gpu await broadcast({"type": "gpu", "data": gpu}) except Exception as e: log.warning("gpu loop: %s", e) await asyncio.sleep(2.0) @app.on_event("startup") async def startup(): init_db() _backup_ops_db("startup") 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/memory 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) # Wait until fleet snapshot exists, then warm memory inventory for chat tools for _ in range(40): if STATE.get("devices"): break await asyncio.sleep(2) try: mem = await fetch_fleet_memory(force=False) log.info( "Memory cache warmed: %s/%s servers", mem.get("with_memory"), mem.get("count"), ) except Exception as e: log.warning("Memory cache warm failed: %s", e) @app.get("/api/health") async def health(): return { "status": "ok", "updated_at": STATE.get("updated_at"), "pulse": STATE.get("pulse"), } @app.get("/api/fleet") async def fleet(): ensure_network_inventory_in_state() return {**STATE} @app.get("/api/devices/{device_id}") async def device_detail(device_id: int): # lightweight detail: return fleet node + optional inventory slices node = next((d for d in STATE.get("devices") or [] if d.get("id") == device_id), None) if not node: raise HTTPException(404, "Device not found in current fleet snapshot") if device_id in DETAIL_CACHE and time.time() - DETAIL_CACHE[device_id].get("_ts", 0) < 300: return DETAIL_CACHE[device_id] base = settings.ome_url.rstrip("/") detail = {"device": node, "inventory": {}, "power": {}} async with httpx.AsyncClient(verify=False, timeout=60.0) as client: 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 = r.json().get("Id") headers = {"X-Auth-Token": token, "Accept": "application/json"} try: detail["power"] = await fetch_power(client, base, headers, device_id) # Discover all inventory types OME has for this device (app/firmware landscape) inv_types: list[str] = [] try: tr = await client.get( f"{base}/api/DeviceService/Devices({device_id})/InventoryTypes", headers=headers, ) if tr.status_code == 200: inv_types = list((tr.json() or {}).get("InventoryTypes") or []) except Exception: inv_types = [] # Ensure core + software landscape types are always attempted for required in ( "serverProcessors", "serverMemoryDevices", "serverArrayDisks", "serverPowerSupplies", "serverNetworkInterfaces", "deviceLocation", "deviceManagement", "subsystemRollupStatus", "serverOperatingSystems", "deviceSoftware", "deviceLicense", "deviceFru", "deviceCapabilities", "serverRaidControllers", "serverDeviceCards", "deviceBaseboards", "serverDellVideos", "serverFcCards", "serverVirtualFlashes", "serverStorageEnclosures", "serverSupportedPowerStates", "serverBiosSystemProfileSettings", "deviceInventory", ): if required not in inv_types: inv_types.append(required) detail["inventory_types"] = inv_types for inv_type in inv_types: try: ir = await client.get( f"{base}/api/DeviceService/Devices({device_id})/InventoryDetails('{inv_type}')", headers=headers, ) if ir.status_code == 200: info = ir.json().get("InventoryInfo") or [] if info: detail["inventory"][inv_type] = info except Exception: pass # Normalized application / firmware landscape view software = detail["inventory"].get("deviceSoftware") or [] os_info = detail["inventory"].get("serverOperatingSystems") or [] mgmt = detail["inventory"].get("deviceManagement") or [] licenses = detail["inventory"].get("deviceLicense") or [] def _stype(s: dict) -> str: return str(s.get("SoftwareType") or "").strip().upper() fw_types = {"BIOS", "FRMW", "FIRMWARE", "IDRAC", "USC", "BMC", "CPLD", "DCSM"} drv_types = {"DRVR", "DRIVER", "DRV"} firmware = [s for s in software if _stype(s) in fw_types or "FW" in _stype(s)] drivers = [s for s in software if _stype(s) in drv_types or "DRIVER" in _stype(s)] fw_ids = {id(s) for s in firmware} drv_ids = {id(s) for s in drivers} applications = [s for s in software if id(s) not in fw_ids and id(s) not in drv_ids] detail["landscape"] = { "os": os_info, "software": software, "management": mgmt, "licenses": licenses, "firmware": firmware, "drivers": drivers, "applications": applications, } detail["expansion"] = build_expansion_report(node.get("model"), detail.get("inventory") or {}) finally: if sid: try: await client.delete( f"{base}/api/SessionService/Sessions('{sid}')", headers=headers, ) except Exception: pass detail["_ts"] = time.time() DETAIL_CACHE[device_id] = detail return detail # OME JobService powerState values (Dell set_power_state.py) OME_POWER_ACTIONS = { "on": ("2", "Power On"), "cycle": ("5", "Power Cycle"), "force_off": ("8", "Power Off Non-Graceful"), "off": ("12", "Power Off Graceful"), } class PowerActionIn(BaseModel): action: str # on | off | cycle | force_off confirm: bool = False def _fleet_device(device_id: int) -> dict | None: return next((d for d in (STATE.get("devices") or []) if d.get("id") == device_id), None) @app.post("/api/devices/{device_id}/power") async def device_power_control(device_id: int, payload: PowerActionIn): """Power control via OME JobService DeviceAction_Task (iDRAC through OME).""" action = (payload.action or "").strip().lower() if action not in OME_POWER_ACTIONS: raise HTTPException(400, f"Invalid action. Use one of: {', '.join(OME_POWER_ACTIONS)}") if action in ("off", "force_off", "cycle") and not payload.confirm: raise HTTPException(400, "confirm=true required for off/cycle actions") node = _fleet_device(device_id) if not node: raise HTTPException(404, "Device not found in current fleet snapshot") if node.get("source") == "inventory" or (isinstance(device_id, int) and device_id < 0): raise HTTPException(400, "Inventory-only hosts cannot be powered via OME") if not (node.get("is_server") or node.get("is_idrac") or node.get("type") == 1000): raise HTTPException(400, "Power control is only supported for servers / iDRAC endpoints") power_state, label = OME_POWER_ACTIONS[action] async with httpx.AsyncClient(verify=False, timeout=60.0) as client: base, headers, sid = await ome_session(client) try: body = { "Id": 0, "JobName": f"Cockpit {label}", "JobDescription": f"OME Cockpit power control · {node.get('name')} · {node.get('service_tag')}", "State": "Enabled", "Schedule": "startnow", "JobType": {"Name": "DeviceAction_Task"}, "Targets": [ { "Id": int(device_id), "Data": "", "TargetType": {"Id": 1000, "Name": "DEVICE"}, } ], "Params": [ {"Key": "override", "Value": "true"}, {"Key": "powerState", "Value": str(power_state)}, {"Key": "operationName", "Value": "POWER_CONTROL"}, {"Key": "deviceTypes", "Value": "1000"}, ], } r = await client.post( f"{base}/api/JobService/Jobs", headers={**headers, "Content-Type": "application/json"}, json=body, ) if r.status_code >= 400: raise HTTPException( r.status_code, f"OME power job rejected: {(r.text or '')[:300]}", ) job = r.json() if r.content else {} job_id = job.get("Id") log.info( "OME power %s for device %s (%s) → job %s", action, device_id, node.get("service_tag"), job_id, ) return { "ok": True, "action": action, "label": label, "device_id": device_id, "name": node.get("name"), "service_tag": node.get("service_tag"), "job_id": job_id, "note": "OME accepted the job. Power state updates on the next fleet poll.", } finally: await ome_session_delete(client, base, headers, sid) @app.get("/api/devices/{device_id}/idrac-console") async def device_idrac_console(device_id: int, request: Request): """Return iDRAC web / HTML5 console URLs — prefer same-origin proxy embed.""" node = _fleet_device(device_id) if not node: raise HTTPException(404, "Device not found in current fleet snapshot") ip = node.get("idrac_ip") or node.get("ip") if not ip: raise HTTPException(400, "No iDRAC / management IP for this device") base = f"https://{ip}" # Same-origin reverse proxy so X-Frame-Options / CSP cannot block the iframe embed = f"/api/idrac-proxy/{device_id}/restgui/start.html?console" urls = { "web": base + "/", "html5": base + "/console", "restgui": base + "/restgui/start.html?console", "viewer": base + "/virtualconsole", "embed": str(request.base_url).rstrip("/") + embed, "embed_path": embed, } return { "device_id": device_id, "name": node.get("name"), "service_tag": node.get("service_tag"), "ip": ip, "powered_on": node.get("powered_on"), "connected": node.get("connected"), "urls": urls, "primary": embed, "note": ( "Console is proxied through Cockpit (same-origin) so it can run in this panel. " "Log in with your iDRAC credentials. Popout still opens the iDRAC directly." ), } _IDRAC_HOP_HEADERS = { "connection", "keep-alive", "proxy-authenticate", "proxy-authorization", "te", "trailers", "transfer-encoding", "upgrade", "content-length", "content-encoding", "content-security-policy", "content-security-policy-report-only", "x-frame-options", "x-xss-protection", "strict-transport-security", } def _idrac_proxy_prefix(device_id: int) -> str: return f"/api/idrac-proxy/{device_id}" def _idrac_resolve(device_id: int) -> tuple[dict, str]: node = _fleet_device(device_id) if not node: raise HTTPException(404, "Device not found in current fleet snapshot") # Prefer explicit iDRAC management IP; never treat bare OS inventory as iDRAC ip = node.get("idrac_ip") if not ip and node.get("is_idrac"): ip = node.get("ip") if not ip: st = (node.get("service_tag") or "").strip() if st: sibling = next( ( d for d in (STATE.get("devices") or []) if d.get("is_idrac") and (d.get("service_tag") or "").strip().upper() == st.upper() and (d.get("idrac_ip") or d.get("ip")) ), None, ) if sibling: node = sibling ip = sibling.get("idrac_ip") or sibling.get("ip") if not ip: raise HTTPException( 400, "No iDRAC management IP for this device — select an iDRAC endpoint (OOB), not the OS host", ) try: ipaddress.ip_address(ip) except ValueError as e: raise HTTPException(400, "Invalid management IP") from e return node, ip def _idrac_rewrite_location(value: str, device_id: int, idrac_ip: str) -> str: prefix = _idrac_proxy_prefix(device_id) if not value: return value if value.startswith("/"): return prefix + value try: u = urllib.parse.urlparse(value) except Exception: return value host = (u.hostname or "").lower() if host == idrac_ip.lower() or host.endswith(".dell-atc.lan") or host.endswith(".internal."): path = u.path or "/" return prefix + path + (("?" + u.query) if u.query else "") + (("#" + u.fragment) if u.fragment else "") return value def _idrac_rewrite_cookie(value: str, device_id: int) -> str: """Point cookies at the proxy path; drop Domain/Secure so HTTP cockpit can store them.""" prefix = _idrac_proxy_prefix(device_id) parts = [] for part in value.split(";"): p = part.strip() pl = p.lower() if pl.startswith("domain="): continue if pl == "secure": continue if pl.startswith("path="): parts.append(f"Path={prefix}/") continue parts.append(p) if not any(p.lower().startswith("path=") for p in parts): parts.append(f"Path={prefix}/") return "; ".join(parts) def _idrac_inject_bootstrap(html: bytes, device_id: int, idrac_ip: str) -> bytes: """Rewrite absolute paths + patch fetch/XHR/WebSocket so the SPA stays on the proxy.""" prefix = _idrac_proxy_prefix(device_id) try: text = html.decode("utf-8") except UnicodeDecodeError: text = html.decode("latin-1") # Absolute root paths in markup for attr in ("href", "src", "action"): text = re.sub( rf'({attr}\s*=\s*["\'])/(?!/)', rf"\1{prefix}/", text, flags=re.I, ) text = text.replace(f"https://{idrac_ip}/", f"{prefix}/") text = text.replace(f"http://{idrac_ip}/", f"{prefix}/") boot = f"""""" if re.search(r"
]*>", text, flags=re.I): text = re.sub(r"(]*>)", r"\1" + boot, text, count=1, flags=re.I) else: text = boot + text return text.encode("utf-8") def _idrac_rewrite_css(css: bytes, device_id: int, idrac_ip: str) -> bytes: prefix = _idrac_proxy_prefix(device_id) try: text = css.decode("utf-8") except UnicodeDecodeError: text = css.decode("latin-1") text = re.sub(r"url\((['\"]?)/", rf"url(\1{prefix}/", text) text = text.replace(f"https://{idrac_ip}/", f"{prefix}/") return text.encode("utf-8") async def _idrac_proxy_http(device_id: int, path: str, request: Request): import gzip _, idrac_ip = _idrac_resolve(device_id) prefix = _idrac_proxy_prefix(device_id) rel = path or "" qs = request.url.query target = f"https://{idrac_ip}/{rel}" + (f"?{qs}" if qs else "") # Forward headers; force gzip so iDRAC serves .js/.css (precompressed only) fwd: dict[str, str] = {} for k, v in request.headers.items(): lk = k.lower() if lk in ("host", "content-length", "connection", "accept-encoding"): continue if lk == "referer" and v: # Map our proxy referer back to iDRAC origin when possible v = v.replace(str(request.base_url).rstrip("/") + prefix, f"https://{idrac_ip}") v = v.replace(prefix, f"https://{idrac_ip}") fwd[k] = v fwd["Host"] = idrac_ip fwd["Accept-Encoding"] = "gzip, deflate" # Avoid long-lived upstream hangs body = await request.body() try: async with httpx.AsyncClient(verify=False, timeout=120.0, follow_redirects=False) as client: upstream = await client.request( request.method, target, headers=fwd, content=body if body else None, ) except httpx.RequestError as e: accept = (request.headers.get("accept") or "").lower() dest = (request.headers.get("sec-fetch-dest") or "").lower() if "text/html" in accept or dest == "iframe" or request.method == "GET": err = ( str(e) .replace("&", "&") .replace("<", "<") .replace(">", ">") ) html = f"""Target {idrac_ip} did not accept a connection from Cockpit.
Use an iDRAC / OOB endpoint (VLAN 40/41…), not the OS host IP.
{err}