344 lines
12 KiB
Python
344 lines
12 KiB
Python
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
|