From 1e87f22006b30957ae3ce8a3465992cdcb2c9dbe Mon Sep 17 00:00:00 2001 From: root Date: Sat, 18 Jul 2026 21:56:30 +0200 Subject: [PATCH] Add VLAN inventory, Present decks, and network fabric mapping. Seed all ATC/FDE VLANs with live host inventory and editable notes, fix cluster membership on the map, and ship Present/network UI polish. Co-authored-by: Cursor --- api/main.py | 1112 +++++++++++- ui/app.js | 331 +++- ui/index.html | 159 +- ui/logos/browser.svg | 11 + ui/logos/docker.svg | 14 + ui/logos/fastapi.svg | 4 + ui/logos/ome.svg | 4 + ui/logos/openwebui.svg | 8 + ui/logos/sqlite.svg | 7 + ui/logos/vllm.svg | 7 + ui/network.js | 533 +++++- ui/ops.js | 134 +- ui/present.js | 1230 +++++++++++++ ui/reports.js | 505 +++++- ui/styles.css | 3910 ++++++++++++++++++++++++++++++++++++++-- 15 files changed, 7587 insertions(+), 382 deletions(-) create mode 100644 ui/logos/browser.svg create mode 100644 ui/logos/docker.svg create mode 100644 ui/logos/fastapi.svg create mode 100644 ui/logos/ome.svg create mode 100644 ui/logos/openwebui.svg create mode 100644 ui/logos/sqlite.svg create mode 100644 ui/logos/vllm.svg create mode 100644 ui/present.js diff --git a/api/main.py b/api/main.py index f1ac8d0..a1c3840 100644 --- a/api/main.py +++ b/api/main.py @@ -1159,7 +1159,6 @@ async def ome_fetch() -> dict: pass devices = [] - subnet_map: dict[str, list] = defaultdict(list) model_counts: dict[str, int] = defaultdict(int) idrac_count = server_count = connected = powered = 0 switch_count = chassis_count = pdu_count = storage_count = 0 @@ -1259,28 +1258,14 @@ async def ome_fetch() -> dict: "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) - subnet_map[subnet].append(node["id"]) await enrich_rdp_targets(devices) - - subnets = [] - for cidr, ids in sorted(subnet_map.items(), key=lambda x: -len(x[1])): - members = [n for n in devices if n["id"] in ids] - sub_watts = sum(n["watts"] or 0 for n in members if n.get("watts") is not None) - switches = [n for n in members if n.get("is_switch")] - subnets.append({ - "cidr": cidr, - "count": len(members), - "connected": sum(1 for n in members if n["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 - ], - }) + sync_endpoints_from_fleet(devices) + apply_network_endpoints(devices) + subnets = build_subnet_summaries(devices) groups = [ {"id": g.get("Id"), "name": g.get("Name")} @@ -1900,7 +1885,7 @@ def build_switch_portmap(switch_id: int) -> dict: def _seed_atc_vlans() -> None: - """Seed known ATC lab VLANs/subnets (editable).""" + """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), @@ -1908,13 +1893,20 @@ def _seed_atc_vlans() -> None: (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), - (120, "Secondary OS", "10.0.120.0/24", "Secondary OS / RDP path", "ATC1", "#f0d060", 6), + (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: - n = conn.execute("SELECT COUNT(*) AS c FROM vlans").fetchone()["c"] - if n > 0: - return + 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) @@ -1922,50 +1914,646 @@ def _seed_atc_vlans() -> None: """, (vlan_id, name, cidr, purpose, site, color, so, now, now), ) - log.info("Seeded ATC VLAN catalog (%s entries)", len(defaults)) + 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 + live membership from fleet IPs (mgmt + RDP).""" + """VLANs from catalog + membership from fleet IPs + inventory endpoints; switch attachment matrix.""" _seed_atc_vlans() + _seed_network_endpoints() _migrate_rack_items() - devices = STATE.get("devices") or [] + 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 ips_for(d: dict) -> list[str]: - out = [] - for k in ("ip", "idrac_ip"): - v = d.get(k) - if v and v not in out: - out.append(v) - for v in d.get("rdp_ips") or []: - if v and v not in out: - out.append(v) - return out - 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 ips_for(d) if in_cidr(ip, v["cidr"])] + 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": d.get("id"), - "name": d.get("name"), + "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": d.get("model"), + "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()) @@ -1974,16 +2562,57 @@ def build_vlan_map() -> dict: **v, "member_count": len(members), "connected_count": sum(1 for m in members if m.get("connected")), - "members": members[:80], + "members": members[:250], } ) - # Also surface discovered /24s not in catalog + 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 ips_for(d): + for ip in _device_ips(d): if ip.count(".") != 3: continue cidr = ".".join(ip.split(".")[:3]) + ".0/24" @@ -1996,11 +2625,23 @@ def build_vlan_map() -> dict: 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 names are an ATC catalog (editable). Membership is live from OME mgmt/RDP IPs.", + "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: @@ -2023,10 +2664,60 @@ def _migrate_rack_items() -> None: if rows: log.info("Migrated %s rack_placements → rack_items", len(rows)) -ADMINS = [ - {"id": "jody", "name": "Jody van Dongen", "role": "ATC Datacenter Admin", "email": "jody.van.dongen@dell.com"}, - {"id": "laurens", "name": "Laurens Rammers", "role": "ATC Datacenter Admin", "email": "laurens.rammers@dell.com"}, +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" @@ -2184,11 +2875,27 @@ def init_db(): 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: @@ -2301,6 +3008,7 @@ async def health(): @app.get("/api/fleet") async def fleet(): + ensure_network_inventory_in_state() return {**STATE} @@ -2586,7 +3294,10 @@ async def fetch_fleet_memory(force: bool = False) -> dict: devices = [ d for d in (STATE.get("devices") or []) - if d.get("is_server") or d.get("is_idrac") or (d.get("type") == 1000) + if (d.get("is_server") or d.get("is_idrac") or (d.get("type") == 1000)) + and d.get("source") != "inventory" + and isinstance(d.get("id"), int) + and d.get("id") > 0 ] # Prefer unique device ids seen: set[int] = set() @@ -2719,13 +3430,17 @@ def build_fleet_context(focus_device_id: int | None = None, max_chars: int | Non lines = [ "You are OpenManage Cockpit Copilot for Dell ATC. Be concise and operational.", - "Admins: Jody van Dongen, Laurens Rammers, Mohamed El Kadi.", - "ACCURACY RULES (mandatory):", - "1) Use ONLY facts from this snapshot and any TOOL FACTS block. If missing => say unknown. Never invent ST, IPs, DIMM counts, firmware versions, or RDP targets.", - "2) ALWAYS cite Service Tag (ST=...), model, idrac IP, and rdp IP (or 'rdp unresolved') when discussing a system.", - "3) Management/iDRAC IP is NOT the Windows RDP IP. Never recommend RDP to an iDRAC address.", - "4) Hardware CURRENT (OME inventory) is NOT Dell CATALOG MAX. Never state catalog maxima as installed capacity.", - "5) Prefer TOOL FACTS over the summary index when both are present.", + "ATC Datacenter Admins (escalate here when facts are missing):", + " - Jody van Dongen ", + " - Laurens Rammers ", + "ACCURACY RULES (mandatory — never break these):", + "1) Use ONLY facts from this snapshot and any TOOL FACTS block. Never invent Service Tags, IPs, DIMM counts, firmware versions, RDP targets, port maps, VLAN members, or rack placements.", + "2) If a requested fact is not present in the snapshot/TOOL FACTS, or tools failed/returned empty: say exactly what is unknown, then tell the user to overleggen met Jody van Dongen and Laurens Rammers (emails above). Do not guess.", + "3) ALWAYS cite Service Tag (ST=...), model, idrac IP, and rdp IP (or 'rdp unresolved') when discussing a system.", + "4) Management/iDRAC IP is NOT the Windows RDP IP. Never recommend RDP to an iDRAC address.", + "5) Hardware CURRENT (OME inventory) is NOT Dell CATALOG MAX. Never state catalog maxima as installed capacity.", + "6) Prefer TOOL FACTS over the summary index when both are present. Quote TOOL FACTS numbers verbatim.", + "7) Never pretend MCP/tool output exists when no TOOL FACTS block was provided.", "", "FLEET: total={t} connected={c} offline={o} watts={w} alerts_total={a}".format( t=summary.get("total"), @@ -2860,7 +3575,9 @@ def build_fleet_context(focus_device_id: int | None = None, max_chars: int | Non lines.append("device_id=%s missing from fleet snapshot" % focus_device_id) lines.append( - "Reply with concrete Service Tags, idrac/rdp IPs, models, and next actions. Quote TOOL FACTS verbatim for hardware numbers." + "Reply with concrete Service Tags, idrac/rdp IPs, models, and next actions. " + "Quote TOOL FACTS verbatim for hardware numbers. " + "If anything is missing from facts: do not invent — escalate to Jody van Dongen and Laurens Rammers." ) body = "\n".join(lines) budget = max(800, limit - len(st_block) - 40) @@ -2907,6 +3624,15 @@ def plan_chat_tools(message: str, focus_device_id: int | None = None) -> list[tu wants_fw = bool(re.search(r"\b(firmware|bios\s*version|outdated|compliance|firmware\s*catalog|update\s*catalog|baseline)\b", low)) wants_war = bool(re.search(r"\b(warrant|prosupport|support\s*end|days?\s*left)\b", low)) wants_alerts = bool(re.search(r"\b(alert|critical|warning|fault|health)\b", low)) + wants_counts = bool( + re.search( + r"\b(hoeveel|how\s+many|count|aantal|total|fleet\s+size|connected|offline)\b", + low, + ) + ) + # Fleet summary questions still benefit from alerts + memory tools when relevant + if wants_counts and not planned: + add("list_alerts", {}) target_id = focus_device_id target_st = None @@ -3272,7 +3998,22 @@ async def api_gpu(): @app.get("/api/admins") async def api_admins(): - return {"admins": ADMINS} + return {"admins": OPS_USERS, "users": OPS_USERS} + + +@app.get("/api/ops/users") +async def api_ops_users(): + return { + "users": OPS_USERS, + "admins": [u for u in OPS_USERS if u.get("team") == "admin"], + "fde": [u for u in OPS_USERS if u.get("team") == "fde"], + "context": { + "cockpit": "OME Cockpit by Data Forward Deployed Engineers Mohamed El Kadi & Bart Sjerps", + "cluster": "Runs on the FDE cluster operated by Data Forward Deployed Engineers Mohamed El Kadi and Bart Sjerps", + "admins": "Jody van Dongen and Laurens Rammers — ATC datacenter administrators", + "fde": "Mo and Bart — both Data Forward Deployed Engineers deploying AI workloads", + }, + } @@ -3482,13 +4223,31 @@ async def api_chat(payload: ChatIn): if role in ("user", "assistant") and content: messages.append({"role": role, "content": str(content)[:1200]}) user_msg = payload.message[:2500] + escalate = ( + "If any needed fact is absent, say it is unknown and instruct the user to overleggen met " + "Jody van Dongen (jody.van.dongen@dell.com) and Laurens Rammers (laurens.rammers@dell.com). " + "Never invent." + ) if tool_results: + tool_errors = [r for r in tool_results if r.get("error")] user_msg += ( "\n\n[System note: live TOOL FACTS were fetched for this question. " "Treat TOOL FACTS as ground truth. Quote CURRENT vs CATALOG separately. " "Do not hedge or invent. Say unknown only if a field is absent from TOOL FACTS. " "For fleet lists: output EVERY row from TOOL FACTS in a compact table " - "(ST | name | model | memory_gb | dimms | idrac) — do not summarize or omit.]" + "(ST | name | model | memory_gb | dimms | idrac) — do not summarize or omit. " + f"{escalate}]" + ) + if tool_errors: + user_msg += ( + "\n[System note: some tools failed — do not fill gaps from model knowledge. " + f"{escalate}]" + ) + else: + user_msg += ( + "\n\n[System note: no live TOOL FACTS were fetched for this turn. " + "Answer only from the fleet snapshot in the system message. " + f"{escalate}]" ) messages.append({"role": "user", "content": user_msg}) @@ -3511,7 +4270,7 @@ async def api_chat(payload: ChatIn): json={ "model": model, "messages": messages, - "temperature": 0.1, + "temperature": 0.05, "max_tokens": max_tokens, "stream": False, }, @@ -3528,7 +4287,7 @@ async def api_chat(payload: ChatIn): json={ "model": model, "messages": messages, - "temperature": 0.1, + "temperature": 0.05, "max_tokens": max_tokens, }, ) @@ -3544,7 +4303,7 @@ async def api_chat(payload: ChatIn): json={ "model": model, "messages": messages, - "temperature": 0.1, + "temperature": 0.05, "max_tokens": 500, }, ) @@ -3576,10 +4335,8 @@ async def api_chat(payload: ChatIn): def _admin_name(aid: str) -> str: - for a in ADMINS: - if a["id"] == aid: - return a["name"] - return aid + u = _ops_user(aid) + return u["name"] if u else aid @app.get("/api/tickets") @@ -3588,15 +4345,17 @@ async def list_tickets(): rows = conn.execute( "SELECT * FROM tickets ORDER BY updated_at DESC LIMIT 200" ).fetchall() - return {"tickets": [dict(r) for r in rows], "admins": ADMINS} + return {"tickets": [dict(r) for r in rows], "admins": OPS_USERS, "users": OPS_USERS} @app.post("/api/tickets") async def create_ticket(payload: TicketIn): now = time.time() assignee = payload.assignee - if not assignee: - assignee = "laurens" if payload.created_by == "jody" else "jody" + if not assignee or not _ops_user(assignee): + assignee = _default_assignee(payload.created_by) + if payload.created_by and not _ops_user(payload.created_by): + raise HTTPException(400, f"Unknown actor: {payload.created_by}") with _db() as conn: cur = conn.execute( """ @@ -3633,7 +4392,7 @@ async def get_ticket(ticket_id: int): "SELECT * FROM ticket_messages WHERE ticket_id=? ORDER BY created_at ASC", (ticket_id,), ).fetchall() - return {"ticket": dict(row), "messages": [dict(m) for m in msgs], "admins": ADMINS} + return {"ticket": dict(row), "messages": [dict(m) for m in msgs], "admins": OPS_USERS, "users": OPS_USERS} @app.post("/api/tickets/{ticket_id}/messages") @@ -3724,6 +4483,7 @@ class PlacementIn(BaseModel): @app.get("/api/network/fabric") async def api_network_fabric(): + ensure_network_inventory_in_state() return build_network_fabric() @@ -3772,6 +4532,108 @@ async def api_create_vlan(payload: VlanIn): return {"id": vid, **build_vlan_map()} +class EndpointIn(BaseModel): + hostname: str | None = 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 + user_note: str | None = None + + +class EndpointPatch(BaseModel): + hostname: str | None = None + role: str | None = None + kind: str | None = None + 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 + user_note: str | None = None + clear_device_id: bool = False + + +@app.get("/api/network/endpoints") +async def api_list_endpoints(): + ensure_network_inventory_in_state() + return {"endpoints": _load_network_endpoints()} + + +@app.post("/api/network/endpoints") +async def api_create_endpoint(payload: EndpointIn): + try: + ipaddress.ip_address(payload.ip.strip()) + except Exception as e: + raise HTTPException(400, f"Invalid ip: {e}") from e + _upsert_network_endpoint( + hostname=(payload.hostname or "").strip() or None, + ip=payload.ip.strip(), + role=(payload.role or "server").strip()[:40], + kind=(payload.kind or "host").strip()[:40], + device_id=payload.device_id, + vlan_id=payload.vlan_id, + switch_id=payload.switch_id, + switch_port=payload.switch_port, + model=(payload.model or "").strip()[:80] or None, + note=(payload.note or "").strip()[:500] or None, + overwrite_identity=True, + ) + if payload.user_note is not None: + with _db() as conn: + _ensure_network_endpoints_schema(conn) + conn.execute( + "UPDATE network_endpoints SET user_note=?, updated_at=? WHERE ip=?", + ((payload.user_note or "")[:2000], time.time(), payload.ip.strip()), + ) + ensure_network_inventory_in_state(force=True) + _backup_ops_db("endpoint-create") + row = next((e for e in _load_network_endpoints() if e.get("ip") == payload.ip.strip()), None) + return {"endpoint": row, "vlans": build_vlan_map()} + + +@app.patch("/api/network/endpoints/{endpoint_id}") +async def api_patch_endpoint(endpoint_id: int, payload: EndpointPatch): + with _db() as conn: + _ensure_network_endpoints_schema(conn) + row = conn.execute("SELECT * FROM network_endpoints WHERE id=?", (endpoint_id,)).fetchone() + if not row: + raise HTTPException(404, "Endpoint not found") + sets = ["updated_at=?"] + vals: list = [time.time()] + data = payload.model_dump(exclude_unset=True) + clear = bool(data.pop("clear_device_id", False)) + mapping = { + "hostname": 80, + "role": 40, + "kind": 40, + "model": 80, + "note": 500, + "user_note": 2000, + } + for key, maxlen in mapping.items(): + if key in data and data[key] is not None: + sets.append(f"{key}=?") + vals.append(str(data[key])[:maxlen]) + for key in ("device_id", "vlan_id", "switch_id", "switch_port"): + if key in data and data[key] is not None: + sets.append(f"{key}=?") + vals.append(data[key]) + if clear: + sets.append("device_id=NULL") + vals.append(endpoint_id) + conn.execute(f"UPDATE network_endpoints SET {', '.join(sets)} WHERE id=?", vals) + ensure_network_inventory_in_state(force=True) + _backup_ops_db("endpoint-patch") + row = next((e for e in _load_network_endpoints() if e.get("id") == endpoint_id), None) + return {"endpoint": row, "vlans": build_vlan_map()} + class PortLinkIn(BaseModel): switch_port: int @@ -3834,25 +4696,87 @@ async def api_delete_port_link(switch_id: int, port_num: int): @app.get("/api/devices/{device_id}/nics") async def api_device_nics(device_id: int): - """OME serverNetworkInterfaces for wiring UI (best-effort).""" + """OME serverNetworkInterfaces expanded to port/FQDD rows for wiring UI.""" 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") - nics = [] + nics: list[dict] = [] try: inv = await ome_fetch_inventory_types(device_id, ["serverNetworkInterfaces"]) - for nic in inv.get("serverNetworkInterfaces") or []: - nics.append( - { - "name": nic.get("ProductName") or nic.get("DeviceDescription") or nic.get("Fqdd") or "NIC", - "fqdd": nic.get("Fqdd") or nic.get("InstanceId"), - "mac": nic.get("PermanentMACAddress") or nic.get("CurrentMACAddress"), - "speed": nic.get("LinkSpeed") or nic.get("Speed"), - "vendor": nic.get("Manufacturer") or nic.get("VendorName"), - } - ) + for card in inv.get("serverNetworkInterfaces") or []: + nic_id = str(card.get("NicId") or "").strip() + vendor = str(card.get("VendorName") or card.get("Manufacturer") or "").strip() + ports = card.get("Ports") or [] + if not ports: + # Rare flat inventory shape + fqdd = card.get("Fqdd") or card.get("InstanceId") or nic_id + name = ( + card.get("ProductName") + or card.get("DeviceDescription") + or fqdd + or "NIC" + ) + nics.append( + { + "nic_id": nic_id or None, + "port_id": fqdd, + "fqdd": fqdd, + "name": name, + "mac": card.get("PermanentMACAddress") or card.get("CurrentMACAddress"), + "speed": card.get("LinkSpeed") or card.get("Speed"), + "link": card.get("LinkStatus"), + "vendor": vendor or None, + "label": str(fqdd or name), + } + ) + continue + for port in ports: + port_id = str(port.get("PortId") or "").strip() + parts = port.get("Partitions") or [] + part = parts[0] if parts else {} + fqdd = str(part.get("Fqdd") or port_id or "").strip() + mac = ( + part.get("PermanentMacAddress") + or part.get("CurrentMacAddress") + or part.get("PermanentMACAddress") + or part.get("CurrentMACAddress") + or "" + ) + product = str(port.get("ProductName") or "").strip() + # OME often appends " - AA:BB:..." — keep product without MAC + if " - " in product and mac and product.upper().endswith(str(mac).upper()): + product = product[: product.rfind(" - ")].strip() + elif " - " in product: + left, right = product.rsplit(" - ", 1) + if ":" in right and len(right.replace(":", "")) >= 12: + product = left.strip() + link = port.get("LinkStatus") + speed = port.get("LinkSpeed") + label_bits = [port_id or fqdd or nic_id or "NIC"] + if product: + label_bits.append(product) + if mac: + label_bits.append(str(mac)) + if link: + label_bits.append(str(link)) + if speed not in (None, "", 0, "0"): + label_bits.append(f"{speed} Mb/s") + nics.append( + { + "nic_id": nic_id or None, + "port_id": port_id or None, + "fqdd": fqdd or port_id or None, + "name": product or port_id or nic_id or "NIC", + "mac": mac or None, + "speed": speed, + "link": link, + "vendor": vendor or None, + "label": " · ".join(label_bits), + } + ) except Exception as e: log.debug("nic fetch %s: %s", device_id, e) + # Prefer unique port_id/fqdd order as returned by OME return {"device_id": device_id, "nics": nics, "count": len(nics)} @@ -5120,6 +6044,26 @@ async def reports_js(): return FileResponse(STATIC_DIR / "reports.js", media_type="application/javascript") +@app.get("/present.js") +async def present_js(): + return FileResponse(STATIC_DIR / "present.js", media_type="application/javascript") + + +@app.get("/logos/{filename}") +async def logo_asset(filename: str): + """SVG / image logos for Present architecture slides.""" + safe = Path(filename).name + if not safe or safe != filename or ".." in filename: + raise HTTPException(400, "invalid filename") + path = STATIC_DIR / "logos" / safe + if not path.is_file(): + raise HTTPException(404, "logo not found") + media = "image/svg+xml" if safe.lower().endswith(".svg") else "image/png" + resp = FileResponse(path, media_type=media) + resp.headers["Cache-Control"] = "public, max-age=86400" + return resp + + @app.get("/dell.png") async def dell_png(): return FileResponse(STATIC_DIR / "dell.png", media_type="image/png") diff --git a/ui/app.js b/ui/app.js index cdadebb..da6459a 100644 --- a/ui/app.js +++ b/ui/app.js @@ -26,6 +26,10 @@ hasPower: false, ghostLinks: false, pulseLinks: true, + pulseSpeed: 0.45, + pulseLineColor: "#3dffe0", + pulseGlowColor: "#00a8e8", + pulsePacketColor: "#ffffff", minWatts: 0, search: "", }, @@ -98,7 +102,10 @@ if (!includeServers && !includeIdrac) return []; return (d.devices || []).filter((n) => { - if (state.subnet !== "all" && n.subnet !== state.subnet) return false; + if (state.subnet !== "all") { + const cidrs = n.map_cidrs && n.map_cidrs.length ? n.map_cidrs : [n.subnet]; + if (!cidrs.includes(state.subnet)) return false; + } if (state.model && n.model !== state.model) return false; const isServer = !!n.is_server; @@ -345,50 +352,112 @@ ctx.arc(hub.x, hub.y, hub.r, 0, Math.PI * 2); ctx.fillStyle = "#0076ce"; ctx.fill(); - ctx.strokeStyle = "#3dffe0"; + const th = canvasTheme(); + ctx.strokeStyle = th.hubStroke; ctx.lineWidth = 2; ctx.stroke(); - ctx.fillStyle = "#fff"; + ctx.fillStyle = th.hubText; ctx.font = "700 11px IBM Plex Sans, sans-serif"; ctx.textAlign = "center"; ctx.textBaseline = "middle"; ctx.fillText("OME", hub.x, hub.y); } + + function isLightTheme() { + return document.documentElement.getAttribute("data-theme") === "light"; + } + function canvasTheme() { + if (isLightTheme()) { + return { + label: "rgba(14, 28, 44, 0.92)", + labelSoft: "rgba(30, 50, 72, 0.78)", + empty: "rgba(14, 28, 44, 0.88)", + emptyHint: "rgba(60, 85, 110, 0.95)", + hubStroke: "#0076ce", + hubText: "#ffffff", + selectRing: "#0076ce", + decor: (i) => `rgba(0, 100, 180, ${0.08 + i * 0.025})`, + spiral: "rgba(0, 100, 180, 0.16)", + helix: (i) => `rgba(0, 100, 180, ${0.1 + i * 0.04})`, + ghostLink: (a) => `rgba(70, 90, 110, ${0.22 * a})`, + clusterLabel: "rgba(14, 28, 44, 0.85)", + laneLabel: "rgba(14, 28, 44, 0.8)", + }; + } + return { + label: "rgba(232,244,255,0.85)", + labelSoft: "rgba(232,244,255,0.7)", + empty: "rgba(232,244,255,0.82)", + emptyHint: "rgba(122,147,168,0.95)", + hubStroke: "#3dffe0", + hubText: "#fff", + selectRing: "#fff", + decor: (i) => `rgba(0,168,232,${0.035 + i * 0.012})`, + spiral: "rgba(0,168,232,0.08)", + helix: (i) => `rgba(0,168,232,${0.06 + i * 0.03})`, + ghostLink: (a) => `rgba(90,106,122,${0.14 * a})`, + clusterLabel: "rgba(232,244,255,0.7)", + laneLabel: "rgba(232,244,255,0.55)", + }; + } + + function hexToRgb(hex) { + const h = String(hex || "#3dffe0").replace("#", ""); + const full = h.length === 3 ? h.split("").map((c) => c + c).join("") : h.padEnd(6, "0"); + const n = parseInt(full.slice(0, 6), 16); + if (!Number.isFinite(n)) return { r: 61, g: 255, b: 224 }; + return { r: (n >> 16) & 255, g: (n >> 8) & 255, b: n & 255 }; + } + function rgba(hex, a) { + const { r, g, b } = hexToRgb(hex); + return `rgba(${r},${g},${b},${a})`; + } + function drawPulseLink(x0, y0, x1, y1, connected, t, id, alphaScale) { if (!connected && !state.filters.ghostLinks) return; const animate = state.filters.pulseLinks !== false; + const spd = Math.max(0.1, Number(state.filters.pulseSpeed) || 0.45); + let line = state.filters.pulseLineColor || "#3dffe0"; + let glow = state.filters.pulseGlowColor || "#00a8e8"; + let packet = state.filters.pulsePacketColor || "#ffffff"; + if (isLightTheme()) { + // Stronger, readable pulse on light stage when using default cyan palette + if (!state.filters.pulseLineColor || line === "#3dffe0") line = "#0076ce"; + if (!state.filters.pulseGlowColor || glow === "#00a8e8") glow = "#00a4e4"; + if (!state.filters.pulsePacketColor || packet === "#ffffff") packet = "#0b2a44"; + } const alpha = connected - ? (animate ? (0.55 + 0.35 * Math.sin(t * 0.05 + id)) : 0.72) * alphaScale + ? (animate ? (0.55 + 0.35 * Math.sin(t * 0.05 * spd + id)) : 0.72) * alphaScale : 0.06 * alphaScale; // glow underlay for live OME links if (connected) { ctx.beginPath(); ctx.moveTo(x0, y0); ctx.lineTo(x1, y1); - ctx.strokeStyle = `rgba(0,168,232,${(animate ? 0.22 : 0.14) * alphaScale})`; + ctx.strokeStyle = rgba(glow, (animate ? 0.28 : 0.16) * alphaScale); ctx.lineWidth = animate ? 4.5 : 3.2; ctx.stroke(); } ctx.beginPath(); ctx.moveTo(x0, y0); ctx.lineTo(x1, y1); - ctx.strokeStyle = connected ? `rgba(61,255,224,${alpha})` : `rgba(90,106,122,${0.14 * alphaScale})`; + ctx.strokeStyle = connected ? rgba(line, alpha) : canvasTheme().ghostLink(alphaScale); ctx.lineWidth = connected ? 2.4 : 0.8; ctx.stroke(); if (connected && animate) { // dual packets along the link for (const off of [0, 0.5]) { - const u = ((t * 0.018 + (id % 50) * 0.02 + off) % 1); + const u = ((t * 0.018 * spd + (id % 50) * 0.02 + off) % 1); const px = x0 + (x1 - x0) * u; const py = y0 + (y1 - y0) * u; ctx.beginPath(); ctx.arc(px, py, 3.2, 0, Math.PI * 2); - ctx.fillStyle = "rgba(255,255,255,0.95)"; + ctx.fillStyle = packet; ctx.fill(); ctx.beginPath(); ctx.arc(px, py, 5.5, 0, Math.PI * 2); - ctx.strokeStyle = "rgba(61,255,224,0.55)"; + ctx.strokeStyle = rgba(line, 0.6); ctx.lineWidth = 1.5; ctx.stroke(); } @@ -401,7 +470,7 @@ for (let i = 1; i <= 5; i++) { ctx.beginPath(); ctx.arc(hub.x, hub.y, 70 * i * 0.5, 0, Math.PI * 2); - ctx.strokeStyle = `rgba(0,168,232,${0.035 + i * 0.012})`; + ctx.strokeStyle = canvasTheme().decor(i); ctx.lineWidth = 1; ctx.stroke(); } @@ -417,7 +486,7 @@ if (i === 0) ctx.moveTo(x, y); else ctx.lineTo(x, y); } - ctx.strokeStyle = "rgba(0,168,232,0.08)"; + ctx.strokeStyle = canvasTheme().spiral; ctx.lineWidth = 1.2; ctx.stroke(); } @@ -434,8 +503,8 @@ ctx.globalAlpha = 0.55; ctx.fill(); ctx.globalAlpha = 1; - ctx.fillStyle = "rgba(232,244,255,0.7)"; - ctx.font = "500 9px IBM Plex Mono, monospace"; + ctx.fillStyle = canvasTheme().clusterLabel; + ctx.font = "600 9px IBM Plex Mono, monospace"; ctx.textAlign = "center"; ctx.fillText((ch.label || "").slice(0, 18), ch.x, ch.y - 18); drawPulseLink(hub.x, hub.y, ch.x, ch.y, true, t, (ch.label || "").length * 17, 0.55); @@ -454,8 +523,8 @@ ctx.arc(dashX, g.y, 2.5, 0, Math.PI * 2); ctx.fillStyle = g.color || "#3dffe0"; ctx.fill(); - ctx.fillStyle = "rgba(232,244,255,0.55)"; - ctx.font = "500 9px IBM Plex Mono, monospace"; + ctx.fillStyle = canvasTheme().laneLabel; + ctx.font = "600 9px IBM Plex Mono, monospace"; ctx.textAlign = "left"; ctx.fillText((g.label || "").slice(0, 16), 74, g.y - 8); } @@ -473,7 +542,7 @@ if (s === 0) ctx.moveTo(x, y); else ctx.lineTo(x, y); } - ctx.strokeStyle = `rgba(0,168,232,${0.06 + i * 0.03})`; + ctx.strokeStyle = canvasTheme().helix(i); ctx.lineWidth = 1; ctx.stroke(); } @@ -540,7 +609,7 @@ if (selected) { ctx.beginPath(); ctx.arc(n.x, n.y, n.r + 6, 0, Math.PI * 2); - ctx.strokeStyle = "#fff"; + ctx.strokeStyle = canvasTheme().selectRing; ctx.lineWidth = 2; ctx.stroke(); } @@ -558,14 +627,21 @@ ctx.fill(); ctx.globalAlpha = 1; if (state.cam.scale > 0.85) { - ctx.fillStyle = "rgba(232,244,255,0.85)"; - ctx.font = "500 9px IBM Plex Mono, monospace"; - ctx.textAlign = "center"; - ctx.textBaseline = "top"; + const th = canvasTheme(); + // Halo behind text for contrast on busy light canvas const label = state.viewMode === "power" && n.watts != null ? `${Math.round(n.watts)}W` : (n.name || "").slice(0, 18); + ctx.font = "600 9px IBM Plex Mono, monospace"; + ctx.textAlign = "center"; + ctx.textBaseline = "top"; + if (isLightTheme()) { + ctx.lineWidth = 3; + ctx.strokeStyle = "rgba(236, 242, 248, 0.92)"; + ctx.strokeText(label, n.x, n.y + n.r + 3); + } + ctx.fillStyle = th.label; ctx.fillText(label, n.x, n.y + n.r + 3); } } @@ -600,7 +676,8 @@ ctx.restore(); if (!state.nodes.length) { - ctx.fillStyle = "rgba(232,244,255,0.82)"; + const th = canvasTheme(); + ctx.fillStyle = th.empty; ctx.font = "600 15px IBM Plex Sans, sans-serif"; ctx.textAlign = "center"; ctx.textBaseline = "middle"; @@ -610,11 +687,13 @@ : "No devices match the current filters"; ctx.fillText(tip, w / 2, h / 2); ctx.font = "500 12px IBM Plex Mono, monospace"; - ctx.fillStyle = "rgba(122,147,168,0.95)"; + ctx.fillStyle = th.emptyHint; ctx.fillText("Adjust Filters or click Reset filters", w / 2, h / 2 + 28); } - if (state.filters.pulseLinks !== false) state.pulseT += 1; + if (state.filters.pulseLinks !== false) { + state.pulseT += Math.max(0.1, Number(state.filters.pulseSpeed) || 0.45); + } requestAnimationFrame(draw); } @@ -922,12 +1001,17 @@ All networks ${state.data?.summary?.total ?? 0} nodes · ${state.data?.summary?.total_watts != null ? Math.round(state.data.summary.total_watts) + " W" : "—"} `, - ...subs.map( - (s) => `` - ), + ...subs.map((s) => { + const vlanLabel = + s.vlan_id != null + ? `VLAN ${s.vlan_id}${s.vlan_name ? " · " + s.vlan_name : ""}` + : s.cidr; + const accent = s.vlan_color ? ` style="--vlan:${escapeAttr(s.vlan_color)}"` : ""; + return ``; + }), ]; $("#subnet-list").innerHTML = html.join(""); } @@ -1301,13 +1385,34 @@ set("f-has-power", f.hasPower); set("f-ghost-links", f.ghostLinks); set("f-pulse-links", f.pulseLinks !== false); + set("f-pulse-speed", f.pulseSpeed ?? 0.45); + set("f-pulse-line-color", f.pulseLineColor || "#3dffe0"); + set("f-pulse-glow-color", f.pulseGlowColor || "#00a8e8"); + set("f-pulse-packet-color", f.pulsePacketColor || "#ffffff"); set("f-min-watts", f.minWatts || 0); const lab = $("#min-w-label"); if (lab) lab.textContent = String(f.minWatts || 0); + const pLab = $("#pulse-speed-label"); + if (pLab) pLab.textContent = `${Number(f.pulseSpeed ?? 0.45).toFixed(2)}×`; const search = $("#search"); if (search) search.value = f.search || ""; } + function savePulsePrefs() { + try { + localStorage.setItem( + "cockpit_pulse_prefs", + JSON.stringify({ + pulseSpeed: state.filters.pulseSpeed ?? 0.45, + pulseLineColor: state.filters.pulseLineColor || "#3dffe0", + pulseGlowColor: state.filters.pulseGlowColor || "#00a8e8", + pulsePacketColor: state.filters.pulsePacketColor || "#ffffff", + }) + ); + localStorage.setItem("cockpit_pulse_speed", String(state.filters.pulseSpeed ?? 0.45)); + } catch (_) {} + } + function readFiltersFromDom() { const on = (id) => !!document.getElementById(id)?.checked; state.filters.connected = on("f-connected"); @@ -1317,9 +1422,18 @@ state.filters.hasPower = on("f-has-power"); state.filters.ghostLinks = on("f-ghost-links"); state.filters.pulseLinks = on("f-pulse-links"); + const ps = document.getElementById("f-pulse-speed"); + if (ps) state.filters.pulseSpeed = Math.max(0.1, Math.min(2, Number(ps.value) || 0.45)); + const line = document.getElementById("f-pulse-line-color"); + const glow = document.getElementById("f-pulse-glow-color"); + const packet = document.getElementById("f-pulse-packet-color"); + if (line?.value) state.filters.pulseLineColor = line.value; + if (glow?.value) state.filters.pulseGlowColor = glow.value; + if (packet?.value) state.filters.pulsePacketColor = packet.value; const mw = document.getElementById("f-min-watts"); state.filters.minWatts = mw ? Number(mw.value) || 0 : 0; state.filters.search = $("#search")?.value || ""; + savePulsePrefs(); } function clearFilters() { @@ -1331,6 +1445,10 @@ hasPower: false, ghostLinks: false, pulseLinks: true, + pulseSpeed: 0.45, + pulseLineColor: "#3dffe0", + pulseGlowColor: "#00a8e8", + pulsePacketColor: "#ffffff", minWatts: 0, search: "", }; @@ -1690,8 +1808,23 @@ } } + function setInvOpen(on) { + document.body.classList.toggle("inv-open", !!on); + } + + function wrapInvHtml(html) { + return `
+
+ Full inventory + Theme toggle parked left while reading +
+ ${html} +
`; + } + async function showInspector(node) { state.selectedId = node?.id ?? null; + setInvOpen(false); const empty = $("#inspector-empty"); const body = $("#inspector-body"); if (!node) { @@ -1775,14 +1908,18 @@ const cachedInv = state.inventoryCache[node.id]; if (cachedInv) { const mount = $("#inv-mount"); - if (mount) mount.innerHTML = cachedInv; + if (mount) { + mount.innerHTML = cachedInv.includes("inv-panel") ? cachedInv : wrapInvHtml(cachedInv); + setInvOpen(true); + } } $("#btn-detail")?.addEventListener("click", async () => { const mount = $("#inv-mount"); const deviceId = node.id; state.inventoryLoadingId = deviceId; - mount.innerHTML = `

Loading full inventory + application landscape…

`; + setInvOpen(true); + mount.innerHTML = `

Loading full inventory + application landscape…

`; try { const r = await fetch(`/api/devices/${deviceId}`); const detail = await r.json(); @@ -1888,17 +2025,19 @@ ); } - html = html || `

No inventory available

`; + html = wrapInvHtml(html || `

No inventory available

`); state.inventoryCache[deviceId] = html; if (state.selectedId === deviceId) { const live = $("#inv-mount"); if (live) live.innerHTML = html; + setInvOpen(true); } } catch (e) { - const errHtml = `

Inventory failed: ${escapeHtml(e.message)}

`; + const errHtml = wrapInvHtml(`

Inventory failed: ${escapeHtml(e.message)}

`); if (state.selectedId === deviceId) { const live = $("#inv-mount"); if (live) live.innerHTML = errHtml; + setInvOpen(true); } } finally { if (state.inventoryLoadingId === deviceId) state.inventoryLoadingId = null; @@ -1910,6 +2049,7 @@ const ome = state.data?.ome || {}; const s = state.data?.summary || {}; state.selectedId = null; + setInvOpen(false); $("#inspector-empty").classList.add("hidden"); const body = $("#inspector-body"); body.classList.remove("hidden"); @@ -2144,6 +2284,12 @@ if (t.id === "f-min-watts") { $("#min-w-label").textContent = String(state.filters.minWatts); } + if (t.id === "f-pulse-speed") { + const pLab = $("#pulse-speed-label"); + if (pLab) pLab.textContent = `${Number(state.filters.pulseSpeed).toFixed(2)}×`; + showToast(`Pulse speed ${Number(state.filters.pulseSpeed).toFixed(2)}×`); + return; + } // Pulse-only toggle: no need to relayout if (t.id === "f-pulse-links") { updateFocusContext(); @@ -2160,6 +2306,94 @@ } }); + $("#filters").addEventListener("input", (e) => { + if (e.target?.id !== "f-pulse-speed") return; + readFiltersFromDom(); + const pLab = $("#pulse-speed-label"); + if (pLab) pLab.textContent = `${Number(state.filters.pulseSpeed).toFixed(2)}×`; + }); + + function togglePulsePop(force) { + const pop = $("#pulse-pop"); + const btn = $("#btn-pulse-settings"); + const block = document.querySelector('.rail-block[data-module="filters"]'); + if (!pop || !btn) return; + const open = force != null ? !!force : pop.hidden; + pop.hidden = !open; + btn.setAttribute("aria-expanded", open ? "true" : "false"); + block?.classList.toggle("pulse-open", open); + if (open) { + const ps = $("#f-pulse-speed"); + if (ps) ps.value = String(state.filters.pulseSpeed ?? 0.45); + const pLab = $("#pulse-speed-label"); + if (pLab) pLab.textContent = `${Number(state.filters.pulseSpeed ?? 0.45).toFixed(2)}×`; + const line = $("#f-pulse-line-color"); + const glow = $("#f-pulse-glow-color"); + const packet = $("#f-pulse-packet-color"); + if (line) line.value = state.filters.pulseLineColor || "#3dffe0"; + if (glow) glow.value = state.filters.pulseGlowColor || "#00a8e8"; + if (packet) packet.value = state.filters.pulsePacketColor || "#ffffff"; + } + } + + $("#btn-pulse-settings")?.addEventListener("click", (e) => { + e.preventDefault(); + e.stopPropagation(); + togglePulsePop(); + }); + + const PULSE_THEMES = { + cyan: { line: "#3dffe0", glow: "#00a8e8", packet: "#ffffff" }, + orange: { line: "#ff9a3c", glow: "#ffb040", packet: "#fff4e5" }, + green: { line: "#3dffa0", glow: "#1b7a4a", packet: "#e8fff4" }, + purple: { line: "#c4a7ff", glow: "#7b6cf0", packet: "#f4efff" }, + pink: { line: "#f472b6", glow: "#db2777", packet: "#fff0f7" }, + gold: { line: "#f0e68c", glow: "#d4af37", packet: "#fffceb" }, + }; + + $("#pulse-pop")?.addEventListener("click", (e) => { + const chip = e.target.closest("[data-pulse-speed]"); + if (chip) { + const v = Number(chip.dataset.pulseSpeed); + state.filters.pulseSpeed = v; + const ps = $("#f-pulse-speed"); + if (ps) ps.value = String(v); + const pLab = $("#pulse-speed-label"); + if (pLab) pLab.textContent = `${v.toFixed(2)}×`; + savePulsePrefs(); + showToast(`Pulse speed ${v.toFixed(2)}×`); + return; + } + const sw = e.target.closest("[data-pulse-theme]"); + if (sw) { + const theme = PULSE_THEMES[sw.dataset.pulseTheme]; + if (!theme) return; + state.filters.pulseLineColor = theme.line; + state.filters.pulseGlowColor = theme.glow; + state.filters.pulsePacketColor = theme.packet; + const line = $("#f-pulse-line-color"); + const glow = $("#f-pulse-glow-color"); + const packet = $("#f-pulse-packet-color"); + if (line) line.value = theme.line; + if (glow) glow.value = theme.glow; + if (packet) packet.value = theme.packet; + savePulsePrefs(); + showToast(`Pulse color: ${sw.dataset.pulseTheme}`); + } + }); + + $("#pulse-pop")?.addEventListener("input", (e) => { + if (!e.target?.matches?.('input[type="color"]')) return; + readFiltersFromDom(); + }); + + document.addEventListener("click", (e) => { + const pop = $("#pulse-pop"); + if (!pop || pop.hidden) return; + if (e.target.closest("#pulse-pop") || e.target.closest("#btn-pulse-settings")) return; + togglePulsePop(false); + }); + $("#search").addEventListener("input", (e) => { state.filters.search = e.target.value; layout(); @@ -2332,8 +2566,15 @@ if (mode === "triage") document.getElementById("btn-triage-close")?.click(); else if (mode === "kpi") closeKpiPopup(); else if (mode === "connect") closeConnect(); - else if (mode === "chat-drawer" || mode === "ops-drawer" || mode === "ai-drawer") { - /* ops.js also listens */ + else if ( + mode === "chat-drawer" || + mode === "ops-drawer" || + mode === "ai-drawer" || + mode === "reports-drawer" || + mode === "an-context" || + mode === "network-drawer" + ) { + /* reports.js / ops.js / network.js also listen */ } else closeAi(); }); $("#btn-ome-console").addEventListener("click", () => { @@ -2353,6 +2594,10 @@ window.addEventListener("keydown", (e) => { if (e.key === "Escape") { + if (!$("#an-context-modal")?.classList.contains("hidden")) { + window.cockpitReports?.closeAnContext?.(); + return; + } closeConnect(); closeAi(); closeKpiPopup(); @@ -2392,6 +2637,20 @@ } // Keep JS filter state in sync with checkbox defaults in HTML + try { + const saved = JSON.parse(localStorage.getItem("cockpit_pulse_prefs") || "null"); + if (saved && typeof saved === "object") { + if (Number.isFinite(Number(saved.pulseSpeed)) && saved.pulseSpeed > 0) { + state.filters.pulseSpeed = Number(saved.pulseSpeed); + } + if (saved.pulseLineColor) state.filters.pulseLineColor = saved.pulseLineColor; + if (saved.pulseGlowColor) state.filters.pulseGlowColor = saved.pulseGlowColor; + if (saved.pulsePacketColor) state.filters.pulsePacketColor = saved.pulsePacketColor; + } else { + const savedSpd = Number(localStorage.getItem("cockpit_pulse_speed")); + if (Number.isFinite(savedSpd) && savedSpd > 0) state.filters.pulseSpeed = savedSpd; + } + } catch (_) {} readFiltersFromDom(); syncFilterInputs(); resize(); diff --git a/ui/index.html b/ui/index.html index a1fa677..58bed49 100644 --- a/ui/index.html +++ b/ui/index.html @@ -7,7 +7,7 @@ - + - - - - - - + + + + + + + diff --git a/ui/logos/browser.svg b/ui/logos/browser.svg new file mode 100644 index 0000000..ef63681 --- /dev/null +++ b/ui/logos/browser.svg @@ -0,0 +1,11 @@ + + + + + + + + + + + diff --git a/ui/logos/docker.svg b/ui/logos/docker.svg new file mode 100644 index 0000000..36871ad --- /dev/null +++ b/ui/logos/docker.svg @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + diff --git a/ui/logos/fastapi.svg b/ui/logos/fastapi.svg new file mode 100644 index 0000000..2ab91b1 --- /dev/null +++ b/ui/logos/fastapi.svg @@ -0,0 +1,4 @@ + + + + diff --git a/ui/logos/ome.svg b/ui/logos/ome.svg new file mode 100644 index 0000000..4db3095 --- /dev/null +++ b/ui/logos/ome.svg @@ -0,0 +1,4 @@ + + + OME + diff --git a/ui/logos/openwebui.svg b/ui/logos/openwebui.svg new file mode 100644 index 0000000..11b6489 --- /dev/null +++ b/ui/logos/openwebui.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/ui/logos/sqlite.svg b/ui/logos/sqlite.svg new file mode 100644 index 0000000..72c247f --- /dev/null +++ b/ui/logos/sqlite.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/ui/logos/vllm.svg b/ui/logos/vllm.svg new file mode 100644 index 0000000..357a1f3 --- /dev/null +++ b/ui/logos/vllm.svg @@ -0,0 +1,7 @@ + + + + + + + diff --git a/ui/network.js b/ui/network.js index 2059b27..f82b6a2 100644 --- a/ui/network.js +++ b/ui/network.js @@ -18,8 +18,60 @@ wireDeviceId: null, anim: 0, raf: 0, + peerOrder: {}, // switchId -> [port,...] + cableColors: {}, // `${switchId}:${port}` -> #hex }; + const CABLE_PALETTE = ["#ff9a3c", "#3dffe0", "#7ec8ff", "#ff5c5c", "#c4a7ff", "#3dffa0", "#f0e68c", "#f472b6"]; + + function loadFabricPrefs() { + try { + const raw = JSON.parse(localStorage.getItem("cockpit_fabric_prefs") || "{}"); + if (raw.peerOrder) state.peerOrder = raw.peerOrder; + if (raw.cableColors) state.cableColors = raw.cableColors; + } catch (_) {} + } + function saveFabricPrefs() { + try { + localStorage.setItem( + "cockpit_fabric_prefs", + JSON.stringify({ peerOrder: state.peerOrder, cableColors: state.cableColors }) + ); + } catch (_) {} + } + loadFabricPrefs(); + + function cableColorKey(port) { + return `${state.selectedSwitchId || 0}:${port}`; + } + function getCableColor(port) { + return state.cableColors[cableColorKey(port)] || CABLE_PALETTE[(Number(port) - 1) % CABLE_PALETTE.length]; + } + function setCableColor(port, hex) { + state.cableColors[cableColorKey(port)] = hex; + saveFabricPrefs(); + } + function hexToRgba(hex, a) { + const h = String(hex || "#ff9a3c").replace("#", ""); + const full = h.length === 3 ? h.split("").map((c) => c + c).join("") : h; + const n = parseInt(full, 16); + if (!Number.isFinite(n)) return `rgba(255,154,60,${a})`; + const r = (n >> 16) & 255; + const g = (n >> 8) & 255; + const b = n & 255; + return `rgba(${r},${g},${b},${a})`; + } + function orderedWiredPorts(ports) { + const wired = (ports || []).filter((p) => p.wired && p.link?.device); + const order = state.peerOrder[state.selectedSwitchId] || []; + const rank = new Map(order.map((p, i) => [Number(p), i])); + return wired.slice().sort((a, b) => { + const ra = rank.has(a.port) ? rank.get(a.port) : 1000 + a.port; + const rb = rank.has(b.port) ? rank.get(b.port) : 1000 + b.port; + return ra - rb; + }); + } + async function api(method, url, body) { const opts = { method, headers: {} }; if (body !== undefined) { @@ -207,40 +259,47 @@ ${pm.wired_count}/${sw.port_count} ports wired -
- ${ports - .map((p) => { - const cls = [ - "sw-port", - p.wired ? "wired" : "empty", - selected === p.port ? "selected" : "", - p.link?.device?.connected ? "live" : "", - ] - .filter(Boolean) - .join(" "); - const tip = p.wired - ? `${p.label} → ${(p.link.device && p.link.device.name) || "?"} / ${p.link.device_port || "?"}` - : `${p.label} — click to wire`; - return ``; - }) - .join("")} -
-
- -
+
+ +
${ports - .filter((p) => p.wired && p.link?.device) + .map((p) => { + const cls = [ + "sw-port", + p.wired ? "wired" : "empty", + selected === p.port ? "selected" : "", + p.link?.device?.connected ? "live" : "", + ] + .filter(Boolean) + .join(" "); + const tip = p.wired + ? `${p.label} → ${(p.link.device && p.link.device.name) || "?"} / ${p.link.device_port || "?"}` + : `${p.label} — click to wire`; + return ``; + }) + .join("")} +
+
+

Drag servers to rearrange · pick cable color per link

+ ${orderedWiredPorts(ports) .map((p) => { const d = p.link.device; - return `
+ const col = getCableColor(p.port); + const nicLabel = p.link.device_port || "—"; + return `
+ P${p.port} -
+
${escape(d.name)} - NIC ${escape(p.link.device_port || "—")} · ST=${escape(d.service_tag || "—")} + ${escape(nicLabel)} · ST=${escape(d.service_tag || "—")} + ${d.connected ? "connected" : "offline"}
+
`; }) .join("") || '

No ports wired yet — click a port on the switch.

'} @@ -276,7 +335,65 @@ $("#sw-wire-save")?.addEventListener("click", saveWire); $("#sw-wire-clear")?.addEventListener("click", clearWire); $("#sw-wire-device")?.addEventListener("change", onWireDeviceChange); - requestAnimationFrame(() => drawCables()); + if ($("#sw-wire-device")?.value) onWireDeviceChange(); + bindPeerRail($("#sw-peer-rail")); + requestAnimationFrame(() => { + requestAnimationFrame(() => drawCables()); + }); + } + + function bindPeerRail(rail) { + if (!rail) return; + let dragPort = null; + rail.querySelectorAll("[data-cable-color]").forEach((inp) => { + inp.addEventListener("input", (e) => { + e.stopPropagation(); + const port = Number(inp.dataset.cableColor); + setCableColor(port, inp.value); + const chip = inp.closest(".sw-peer-chip"); + if (chip) chip.style.setProperty("--cable", inp.value); + drawCables(); + }); + inp.addEventListener("click", (e) => e.stopPropagation()); + inp.addEventListener("mousedown", (e) => e.stopPropagation()); + }); + rail.querySelectorAll(".sw-peer-chip[draggable]").forEach((chip) => { + chip.addEventListener("dragstart", (e) => { + dragPort = Number(chip.dataset.peerPort); + chip.classList.add("dragging"); + e.dataTransfer.effectAllowed = "move"; + e.dataTransfer.setData("text/plain", String(dragPort)); + }); + chip.addEventListener("dragend", () => { + chip.classList.remove("dragging"); + rail.querySelectorAll(".sw-peer-chip").forEach((n) => n.classList.remove("drag-over")); + dragPort = null; + drawCables(); + }); + chip.addEventListener("dragover", (e) => { + e.preventDefault(); + e.dataTransfer.dropEffect = "move"; + chip.classList.add("drag-over"); + }); + chip.addEventListener("dragleave", () => chip.classList.remove("drag-over")); + chip.addEventListener("drop", (e) => { + e.preventDefault(); + chip.classList.remove("drag-over"); + const from = Number(e.dataTransfer.getData("text/plain") || dragPort); + const to = Number(chip.dataset.peerPort); + if (!from || !to || from === to) return; + const ports = orderedWiredPorts(state.portmap?.ports || []).map((p) => p.port); + const fi = ports.indexOf(from); + const ti = ports.indexOf(to); + if (fi < 0 || ti < 0) return; + ports.splice(fi, 1); + ports.splice(ti, 0, from); + state.peerOrder[state.selectedSwitchId] = ports; + saveFabricPrefs(); + renderFabric(); + startPulse(); + }); + }); } function renderWirePane(selPort, servers) { @@ -304,7 +421,10 @@