import hashlib from fastapi import APIRouter, Depends, Query from sqlalchemy import text from sqlalchemy.ext.asyncio import AsyncSession from core.database import get_session router = APIRouter() # Mirrors the simulator topology — single source of truth for site layout TOPOLOGY = { "sg-01": { "rooms": [ {"room_id": "hall-a", "racks": [f"SG1A01.{i:02d}" for i in range(1, 21)] + [f"SG1A02.{i:02d}" for i in range(1, 21)], "crac_id": "crac-01"}, {"room_id": "hall-b", "racks": [f"SG1B01.{i:02d}" for i in range(1, 21)] + [f"SG1B02.{i:02d}" for i in range(1, 21)], "crac_id": "crac-02"}, ], "ups_units": ["ups-01", "ups-02"], "leak_sensors": ["leak-01"], } } # ── Device catalog ──────────────────────────────────────────────────────────── # Each tuple: (name, u_height, power_draw_w) _SERVERS = [ ("Dell PowerEdge R750", 2, 420), ("HPE ProLiant DL380 Gen10 Plus", 2, 380), ("Supermicro SuperServer 2029P", 2, 350), ("Dell PowerEdge R650xs", 1, 280), ("HPE ProLiant DL360 Gen10 Plus", 1, 260), ] _SWITCHES = [ ("Cisco Catalyst C9300-48P", 1, 60), ("Arista 7050CX3-32S", 1, 180), ("Juniper EX4300-48T", 1, 75), ] _PATCHES = [ ("Leviton 24-Port Cat6A Patch Panel", 1, 5), ("Panduit 48-Port Cat6A Patch Panel", 1, 5), ] _PDUS = [ ("APC AP8888 Metered Rack PDU", 1, 10), ("Raritan PX3-5190R Metered PDU", 1, 10), ] _STORAGE = [ ("Dell EMC PowerVault ME5024", 2, 280), ("NetApp AFF C190", 2, 200), ] _FIREWALL = [ ("Palo Alto PA-5220", 2, 150), ("Fortinet FortiGate 3000F",2, 180), ] _KVM = [("Raritan KX III-464", 1, 15)] def _serial(rack_id: str, u: int) -> str: return hashlib.md5(f"{rack_id}-u{u}".encode()).hexdigest()[:10].upper() def _rack_seq(rack_id: str) -> int: """SG1A01.05 → 5, SG1A02.05 → 25, SG1B01.05 → 5""" # Format: SG1A01.05 — row at [4:6], rack num after dot row = int(rack_id[4:6]) # "01" or "02" num = int(rack_id[7:]) # "01" to "20" return (row - 1) * 20 + num def _generate_devices(site_id: str, room_id: str, rack_id: str) -> list[dict]: s = _rack_seq(rack_id) room_oct = "1" if room_id == "hall-a" else "2" devices: list[dict] = [] u = 1 def add(name: str, dtype: str, u_start: int, u_height: int, power_w: int, ip: str = "-"): devices.append({ "device_id": f"{rack_id}-u{u_start:02d}", "name": name, "type": dtype, "rack_id": rack_id, "room_id": room_id, "site_id": site_id, "u_start": u_start, "u_height": u_height, "ip": ip, "serial": _serial(rack_id, u_start), "model": name, "status": "online", "power_draw_w": power_w, }) # U1: Patch panel p = _PATCHES[s % len(_PATCHES)] add(p[0], "patch_panel", u, p[1], p[2]); u += p[1] # U2: Switch sw = _SWITCHES[s % len(_SWITCHES)] add(sw[0], "switch", u, sw[1], sw[2], f"10.10.{room_oct}.{s}"); u += sw[1] # KVM in rack 5 / 15 if s in (5, 15): kvm = _KVM[0] add(kvm[0], "kvm", u, kvm[1], kvm[2], f"10.10.{room_oct}.{s + 100}"); u += kvm[1] # Firewall in first rack of each room if rack_id in ("SG1A01.01", "SG1B01.01"): fw = _FIREWALL[s % len(_FIREWALL)] add(fw[0], "firewall", u, fw[1], fw[2], f"10.10.{room_oct}.254"); u += fw[1] # Storage in rack 3 and 13 if s in (3, 13): stor = _STORAGE[s % len(_STORAGE)] add(stor[0], "storage", u, stor[1], stor[2], f"10.10.{room_oct}.{s + 50}"); u += stor[1] # Servers filling U slots up to U41 srv_pool = (_SERVERS * 3) ip_counter = (s - 1) * 15 + 10 for idx, (name, u_h, pwr) in enumerate(srv_pool): if u + u_h > 41: break # Occasional empty gap for realism if idx > 0 and (s + idx) % 8 == 0 and u + u_h + 1 <= 41: u += 1 if u + u_h > 41: break add(name, "server", u, u_h, pwr, f"10.10.{room_oct}.{ip_counter}"); u += u_h ip_counter += 1 # U42: PDU pdu = _PDUS[s % len(_PDUS)] add(pdu[0], "pdu", 42, pdu[1], pdu[2]) return devices # ── Endpoints ───────────────────────────────────────────────────────────────── @router.get("") async def get_assets( site_id: str = Query(...), session: AsyncSession = Depends(get_session), ): site = TOPOLOGY.get(site_id) if not site: return {"site_id": site_id, "rooms": [], "ups_units": []} result = await session.execute(text(""" SELECT DISTINCT ON (sensor_id) sensor_id, sensor_type, room_id, rack_id, value FROM readings WHERE site_id = :site_id AND recorded_at > NOW() - INTERVAL '10 minutes' ORDER BY sensor_id, recorded_at DESC """), {"site_id": site_id}) readings = result.mappings().all() alarm_result = await session.execute(text(""" SELECT rack_id, COUNT(*) AS cnt, MAX(severity) AS worst FROM alarms WHERE site_id = :site_id AND state = 'active' AND rack_id IS NOT NULL GROUP BY rack_id """), {"site_id": site_id}) alarm_map: dict[str, tuple[int, str]] = { r["rack_id"]: (int(r["cnt"]), r["worst"]) for r in alarm_result.mappings().all() } by_sensor: dict[str, float] = {r["sensor_id"]: float(r["value"]) for r in readings} def rack_reading(site: str, room: str, rack: str, suffix: str) -> float | None: return by_sensor.get(f"{site}/{room}/{rack}/{suffix}") def cooling_reading(site: str, crac: str, suffix: str) -> float | None: return by_sensor.get(f"{site}/cooling/{crac}/{suffix}") def ups_reading(site: str, ups: str, suffix: str) -> float | None: return by_sensor.get(f"{site}/power/{ups}/{suffix}") rooms = [] for room in site["rooms"]: room_id = room["room_id"] crac_id = room["crac_id"] supply = cooling_reading(site_id, crac_id, "supply_temp") return_t = cooling_reading(site_id, crac_id, "return_temp") fan = cooling_reading(site_id, crac_id, "fan_pct") crac_has_data = any(sid.startswith(f"{site_id}/cooling/{crac_id}") for sid in by_sensor) if supply is not None: crac_state = "online" elif crac_has_data: crac_state = "fault" else: crac_state = "unknown" racks = [] for rack_id in room["racks"]: temp = rack_reading(site_id, room_id, rack_id, "temperature") power = rack_reading(site_id, room_id, rack_id, "power_kw") alarm_cnt, worst_sev = alarm_map.get(rack_id, (0, None)) status = "ok" if worst_sev == "critical" or (temp is not None and temp >= 30): status = "critical" elif worst_sev == "warning" or (temp is not None and temp >= 26): status = "warning" elif temp is None and power is None: status = "unknown" racks.append({ "rack_id": rack_id, "temp": round(temp, 1) if temp is not None else None, "power_kw": round(power, 2) if power is not None else None, "status": status, "alarm_count": alarm_cnt, }) rooms.append({ "room_id": room_id, "crac": { "crac_id": crac_id, "state": crac_state, "supply_temp": round(supply, 1) if supply is not None else None, "return_temp": round(return_t, 1) if return_t is not None else None, "fan_pct": round(fan, 1) if fan is not None else None, }, "racks": racks, }) ups_units = [] for ups_id in site["ups_units"]: charge = ups_reading(site_id, ups_id, "charge_pct") load = ups_reading(site_id, ups_id, "load_pct") runtime = ups_reading(site_id, ups_id, "runtime_min") state_raw = ups_reading(site_id, ups_id, "state") if state_raw is not None: state = "battery" if state_raw == 1.0 else "online" elif charge is not None: state = "battery" if charge < 20.0 else "online" else: state = "unknown" ups_units.append({ "ups_id": ups_id, "state": state, "charge_pct": round(charge, 1) if charge is not None else None, "load_pct": round(load, 1) if load is not None else None, "runtime_min": round(runtime, 0) if runtime is not None else None, }) return {"site_id": site_id, "rooms": rooms, "ups_units": ups_units} @router.get("/devices") async def get_all_devices(site_id: str = Query(...)): """All devices across all racks for the site.""" site = TOPOLOGY.get(site_id) if not site: return [] devices = [] for room in site["rooms"]: for rack_id in room["racks"]: devices.extend(_generate_devices(site_id, room["room_id"], rack_id)) return devices @router.get("/rack-devices") async def get_rack_devices(site_id: str = Query(...), rack_id: str = Query(...)): """Devices in a specific rack.""" site = TOPOLOGY.get(site_id) if not site: return [] for room in site["rooms"]: if rack_id in room["racks"]: return _generate_devices(site_id, room["room_id"], rack_id) return [] @router.get("/pdus") async def get_pdus( site_id: str = Query(...), session: AsyncSession = Depends(get_session), ): """Per-rack PDU live phase data.""" site = TOPOLOGY.get(site_id) if not site: return [] result = await session.execute(text(""" SELECT DISTINCT ON (sensor_id) sensor_id, sensor_type, room_id, rack_id, value FROM readings WHERE site_id = :site_id AND sensor_type IN ( 'power_kw', 'pdu_phase_a_kw', 'pdu_phase_b_kw', 'pdu_phase_c_kw', 'pdu_phase_a_a', 'pdu_phase_b_a', 'pdu_phase_c_a', 'pdu_imbalance' ) AND recorded_at > NOW() - INTERVAL '5 minutes' ORDER BY sensor_id, recorded_at DESC """), {"site_id": site_id}) # Build per-rack dict keyed by rack_id rack_data: dict[str, dict] = {} for row in result.mappings().all(): rack_id = row["rack_id"] if not rack_id: continue if rack_id not in rack_data: rack_data[rack_id] = {"rack_id": rack_id, "room_id": row["room_id"]} field_map = { "power_kw": "total_kw", "pdu_phase_a_kw": "phase_a_kw", "pdu_phase_b_kw": "phase_b_kw", "pdu_phase_c_kw": "phase_c_kw", "pdu_phase_a_a": "phase_a_a", "pdu_phase_b_a": "phase_b_a", "pdu_phase_c_a": "phase_c_a", "pdu_imbalance": "imbalance_pct", } field = field_map.get(row["sensor_type"]) if field: rack_data[rack_id][field] = round(float(row["value"]), 2) # Emit rows for every rack in topology order, filling in None for missing data out = [] for room in site["rooms"]: for rack_id in room["racks"]: d = rack_data.get(rack_id, {"rack_id": rack_id, "room_id": room["room_id"]}) imb = d.get("imbalance_pct") status = ( "critical" if imb is not None and imb >= 10 else "warning" if imb is not None and imb >= 5 else "ok" ) out.append({ "rack_id": rack_id, "room_id": d.get("room_id", room["room_id"]), "total_kw": d.get("total_kw"), "phase_a_kw": d.get("phase_a_kw"), "phase_b_kw": d.get("phase_b_kw"), "phase_c_kw": d.get("phase_c_kw"), "phase_a_a": d.get("phase_a_a"), "phase_b_a": d.get("phase_b_a"), "phase_c_a": d.get("phase_c_a"), "imbalance_pct": imb, "status": status, }) return out