first commit
This commit is contained in:
commit
4b98219bf7
144 changed files with 31561 additions and 0 deletions
34
frontend/lib/alarm-context.tsx
Normal file
34
frontend/lib/alarm-context.tsx
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
"use client";
|
||||
|
||||
import { createContext, useContext, useEffect, useState, useCallback } from "react";
|
||||
import { fetchAlarmStats, type AlarmStats } from "@/lib/api";
|
||||
|
||||
const SITE_ID = "sg-01";
|
||||
|
||||
type AlarmContextValue = { active: number; critical: number };
|
||||
const AlarmContext = createContext<AlarmContextValue>({ active: 0, critical: 0 });
|
||||
|
||||
export function AlarmProvider({ children }: { children: React.ReactNode }) {
|
||||
const [counts, setCounts] = useState<AlarmContextValue>({ active: 0, critical: 0 });
|
||||
|
||||
const poll = useCallback(async () => {
|
||||
try {
|
||||
const s: AlarmStats = await fetchAlarmStats(SITE_ID);
|
||||
setCounts({ active: s.active, critical: s.critical });
|
||||
} catch {
|
||||
// keep previous value
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
poll();
|
||||
const id = setInterval(poll, 15_000);
|
||||
return () => clearInterval(id);
|
||||
}, [poll]);
|
||||
|
||||
return <AlarmContext.Provider value={counts}>{children}</AlarmContext.Provider>;
|
||||
}
|
||||
|
||||
export function useAlarmCount() {
|
||||
return useContext(AlarmContext);
|
||||
}
|
||||
711
frontend/lib/api.ts
Normal file
711
frontend/lib/api.ts
Normal file
|
|
@ -0,0 +1,711 @@
|
|||
// BASE is the Next.js rewrite prefix defined in next.config.ts:
|
||||
// /api/backend/:path* → http://backend:8000/:path*
|
||||
// All fetch paths below start with /api/... giving the full path /api/backend/api/...
|
||||
// This double "api" is intentional: /api/backend is the proxy mount point, /api/... is the FastAPI route.
|
||||
const BASE = process.env.NEXT_PUBLIC_API_URL ?? "/api/backend"
|
||||
|
||||
async function apiFetch<T>(path: string, init?: RequestInit): Promise<T> {
|
||||
const res = await fetch(`${BASE}${path}`, { cache: "no-store", ...init })
|
||||
if (!res.ok) throw new Error(`API ${path} → ${res.status}`)
|
||||
if (res.status === 204) return undefined as T
|
||||
return res.json()
|
||||
}
|
||||
|
||||
// ── Types ────────────────────────────────────────────────────────
|
||||
|
||||
export type KpiData = {
|
||||
total_power_kw: number
|
||||
pue: number
|
||||
avg_temperature: number
|
||||
active_alarms: number
|
||||
}
|
||||
|
||||
export type PowerBucket = {
|
||||
bucket: string
|
||||
total_kw: number
|
||||
}
|
||||
|
||||
export type TempBucket = {
|
||||
bucket: string
|
||||
room_id: string
|
||||
avg_temp: number
|
||||
}
|
||||
|
||||
export type Alarm = {
|
||||
id: number
|
||||
sensor_id: string | null
|
||||
site_id: string
|
||||
room_id: string | null
|
||||
rack_id: string | null
|
||||
severity: "critical" | "warning" | "info"
|
||||
message: string
|
||||
state: string
|
||||
triggered_at: string
|
||||
}
|
||||
|
||||
export type RoomStatus = {
|
||||
room_id: string
|
||||
avg_temp: number
|
||||
total_kw: number
|
||||
alarm_count: number
|
||||
status: "ok" | "warning" | "critical"
|
||||
}
|
||||
|
||||
// ── Fetchers ─────────────────────────────────────────────────────
|
||||
|
||||
export const fetchKpis = (siteId: string) =>
|
||||
apiFetch<KpiData>(`/api/readings/kpis?site_id=${siteId}`)
|
||||
|
||||
export const fetchPowerHistory = (siteId: string, hours = 1) =>
|
||||
apiFetch<PowerBucket[]>(`/api/readings/site-power-history?site_id=${siteId}&hours=${hours}`)
|
||||
|
||||
export const fetchTempHistory = (siteId: string, hours = 1) =>
|
||||
apiFetch<TempBucket[]>(`/api/readings/room-temp-history?site_id=${siteId}&hours=${hours}`)
|
||||
|
||||
export const fetchAlarms = (siteId: string, state = "active", limit = 100) =>
|
||||
apiFetch<Alarm[]>(`/api/alarms?site_id=${siteId}&state=${state}&limit=${limit}`)
|
||||
|
||||
export const fetchRoomStatus = (siteId: string) =>
|
||||
apiFetch<RoomStatus[]>(`/api/readings/room-status?site_id=${siteId}`)
|
||||
|
||||
export type AlarmStats = {
|
||||
active: number
|
||||
acknowledged: number
|
||||
resolved: number
|
||||
critical: number
|
||||
warning: number
|
||||
}
|
||||
|
||||
export const fetchAlarmStats = (siteId: string) =>
|
||||
apiFetch<AlarmStats>(`/api/alarms/stats?site_id=${siteId}`)
|
||||
|
||||
export const acknowledgeAlarm = async (alarmId: number): Promise<void> => {
|
||||
const res = await fetch(`${BASE}/api/alarms/${alarmId}/acknowledge`, { method: "POST" })
|
||||
if (!res.ok) throw new Error(`Acknowledge failed: ${res.status}`)
|
||||
}
|
||||
|
||||
export const resolveAlarm = async (alarmId: number): Promise<void> => {
|
||||
const res = await fetch(`${BASE}/api/alarms/${alarmId}/resolve`, { method: "POST" })
|
||||
if (!res.ok) throw new Error(`Resolve failed: ${res.status}`)
|
||||
}
|
||||
|
||||
export type RackAsset = {
|
||||
rack_id: string
|
||||
temp: number | null
|
||||
power_kw: number | null
|
||||
status: "ok" | "warning" | "critical" | "unknown"
|
||||
alarm_count: number
|
||||
}
|
||||
|
||||
export type CracAsset = {
|
||||
crac_id: string
|
||||
state: "online" | "fault" | "unknown"
|
||||
supply_temp: number | null
|
||||
return_temp: number | null
|
||||
fan_pct: number | null
|
||||
}
|
||||
|
||||
export type UpsAsset = {
|
||||
ups_id: string
|
||||
state: "online" | "battery" | "overload" | "unknown"
|
||||
charge_pct: number | null
|
||||
load_pct: number | null
|
||||
runtime_min: number | null
|
||||
voltage_v: number | null
|
||||
}
|
||||
|
||||
export type UpsHistoryPoint = {
|
||||
bucket: string
|
||||
charge_pct: number | null
|
||||
load_pct: number | null
|
||||
runtime_min: number | null
|
||||
voltage_v: number | null
|
||||
}
|
||||
|
||||
export type AssetsData = {
|
||||
site_id: string
|
||||
rooms: { room_id: string; crac: CracAsset; racks: RackAsset[] }[]
|
||||
ups_units: UpsAsset[]
|
||||
}
|
||||
|
||||
export const fetchAssets = (siteId: string) =>
|
||||
apiFetch<AssetsData>(`/api/assets?site_id=${siteId}`)
|
||||
|
||||
export type RackPower = { rack_id: string; power_kw: number | null }
|
||||
export type RoomPowerBreakdown = { room_id: string; racks: RackPower[] }
|
||||
export type PowerHistoryBucket = { bucket: string; room_id: string; total_kw: number }
|
||||
|
||||
export const fetchRackBreakdown = (siteId: string) =>
|
||||
apiFetch<RoomPowerBreakdown[]>(`/api/power/rack-breakdown?site_id=${siteId}`)
|
||||
|
||||
export const fetchRoomPowerHistory = (siteId: string, hours = 6) =>
|
||||
apiFetch<PowerHistoryBucket[]>(`/api/power/room-history?site_id=${siteId}&hours=${hours}`)
|
||||
|
||||
export const fetchUpsStatus = (siteId: string) =>
|
||||
apiFetch<UpsAsset[]>(`/api/power/ups?site_id=${siteId}`)
|
||||
|
||||
export const fetchUpsHistory = (siteId: string, upsId: string, hours = 6) =>
|
||||
apiFetch<UpsHistoryPoint[]>(`/api/power/ups/history?site_id=${siteId}&ups_id=${upsId}&hours=${hours}`)
|
||||
|
||||
export type RackEnvReading = { rack_id: string; temperature: number | null; humidity: number | null }
|
||||
export type RoomEnvReadings = { room_id: string; racks: RackEnvReading[] }
|
||||
export type HumidityBucket = { bucket: string; room_id: string; avg_humidity: number }
|
||||
export type CracStatus = {
|
||||
crac_id: string
|
||||
room_id: string | null
|
||||
state: "online" | "fault"
|
||||
delta: number | null
|
||||
rated_capacity_kw: number
|
||||
// Thermal
|
||||
supply_temp: number | null
|
||||
return_temp: number | null
|
||||
supply_humidity: number | null
|
||||
return_humidity: number | null
|
||||
airflow_cfm: number | null
|
||||
filter_dp_pa: number | null
|
||||
// Capacity
|
||||
cooling_capacity_kw: number | null
|
||||
cooling_capacity_pct: number | null
|
||||
cop: number | null
|
||||
sensible_heat_ratio: number | null
|
||||
// Compressor
|
||||
compressor_state: number | null
|
||||
compressor_load_pct: number | null
|
||||
compressor_power_kw: number | null
|
||||
compressor_run_hours: number | null
|
||||
high_pressure_bar: number | null
|
||||
low_pressure_bar: number | null
|
||||
discharge_superheat_c: number | null
|
||||
liquid_subcooling_c: number | null
|
||||
// Fan
|
||||
fan_pct: number | null
|
||||
fan_rpm: number | null
|
||||
fan_power_kw: number | null
|
||||
fan_run_hours: number | null
|
||||
// Electrical
|
||||
total_unit_power_kw: number | null
|
||||
input_voltage_v: number | null
|
||||
input_current_a: number | null
|
||||
power_factor: number | null
|
||||
}
|
||||
|
||||
export type CracHistoryPoint = {
|
||||
bucket: string
|
||||
supply_temp: number | null
|
||||
return_temp: number | null
|
||||
delta_t: number | null
|
||||
capacity_kw: number | null
|
||||
capacity_pct: number | null
|
||||
cop: number | null
|
||||
comp_load: number | null
|
||||
filter_dp: number | null
|
||||
fan_pct: number | null
|
||||
}
|
||||
|
||||
export const fetchRackEnvReadings = (siteId: string) =>
|
||||
apiFetch<RoomEnvReadings[]>(`/api/env/rack-readings?site_id=${siteId}`)
|
||||
|
||||
export const fetchHumidityHistory = (siteId: string, hours = 6) =>
|
||||
apiFetch<HumidityBucket[]>(`/api/env/humidity-history?site_id=${siteId}&hours=${hours}`)
|
||||
|
||||
export const fetchCracStatus = (siteId: string) =>
|
||||
apiFetch<CracStatus[]>(`/api/env/crac-status?site_id=${siteId}`)
|
||||
|
||||
export type RackHistoryPoint = { bucket: string; temperature?: number; humidity?: number; power_kw?: number }
|
||||
export type RackHistory = { rack_id: string; site_id: string; history: RackHistoryPoint[]; alarms: Alarm[] }
|
||||
|
||||
export const fetchRackHistory = (siteId: string, rackId: string, hours = 6) =>
|
||||
apiFetch<RackHistory>(`/api/env/rack-history?site_id=${siteId}&rack_id=${rackId}&hours=${hours}`)
|
||||
|
||||
export type CracDeltaPoint = { bucket: string; delta: number }
|
||||
|
||||
export const fetchCracDeltaHistory = (siteId: string, cracId: string, hours = 1) =>
|
||||
apiFetch<CracDeltaPoint[]>(`/api/env/crac-delta-history?site_id=${siteId}&crac_id=${cracId}&hours=${hours}`)
|
||||
|
||||
export const fetchCracHistory = (siteId: string, cracId: string, hours = 6) =>
|
||||
apiFetch<CracHistoryPoint[]>(`/api/env/crac-history?site_id=${siteId}&crac_id=${cracId}&hours=${hours}`)
|
||||
|
||||
export type ReportSummary = {
|
||||
site_id: string
|
||||
generated_at: string
|
||||
kpis: { total_power_kw: number; avg_temperature: number }
|
||||
alarm_stats: { active: number; acknowledged: number; resolved: number; critical: number; warning: number }
|
||||
crac_uptime: { crac_id: string; room_id: string; uptime_pct: number }[]
|
||||
ups_uptime: { ups_id: string; uptime_pct: number }[]
|
||||
}
|
||||
|
||||
export const fetchReportSummary = (siteId: string) =>
|
||||
apiFetch<ReportSummary>(`/api/reports/summary?site_id=${siteId}`)
|
||||
|
||||
export const reportExportUrl = (type: "power" | "temperature" | "alarms", siteId: string, hours = 24) =>
|
||||
type === "alarms"
|
||||
? `${BASE}/api/reports/export/alarms?site_id=${siteId}`
|
||||
: `${BASE}/api/reports/export/${type}?site_id=${siteId}&hours=${hours}`
|
||||
|
||||
export type RackCapacity = {
|
||||
rack_id: string
|
||||
room_id: string
|
||||
power_kw: number | null
|
||||
power_capacity_kw: number
|
||||
power_pct: number | null
|
||||
temp: number | null
|
||||
}
|
||||
|
||||
export type RoomCapacity = {
|
||||
room_id: string
|
||||
power: { used_kw: number; capacity_kw: number; pct: number; headroom_kw: number }
|
||||
cooling: { load_kw: number; capacity_kw: number; pct: number; headroom_kw: number }
|
||||
space: { racks_total: number; racks_populated: number; pct: number }
|
||||
}
|
||||
|
||||
export type CapacitySummary = {
|
||||
site_id: string
|
||||
config: { rack_power_kw: number; room_power_kw: number; crac_cooling_kw: number; rack_u_total: number }
|
||||
rooms: RoomCapacity[]
|
||||
racks: RackCapacity[]
|
||||
}
|
||||
|
||||
export const fetchCapacitySummary = (siteId: string) =>
|
||||
apiFetch<CapacitySummary>(`/api/capacity/summary?site_id=${siteId}`)
|
||||
|
||||
export type Device = {
|
||||
device_id: string
|
||||
name: string
|
||||
type: "server" | "switch" | "patch_panel" | "pdu" | "storage" | "firewall" | "kvm"
|
||||
rack_id: string
|
||||
room_id: string
|
||||
site_id: string
|
||||
u_start: number
|
||||
u_height: number
|
||||
ip: string
|
||||
serial: string
|
||||
model: string
|
||||
status: "online" | "offline" | "unknown"
|
||||
power_draw_w: number
|
||||
}
|
||||
|
||||
export const fetchAllDevices = (siteId: string) =>
|
||||
apiFetch<Device[]>(`/api/assets/devices?site_id=${siteId}`)
|
||||
|
||||
export const fetchRackDevices = (siteId: string, rackId: string) =>
|
||||
apiFetch<Device[]>(`/api/assets/rack-devices?site_id=${siteId}&rack_id=${rackId}`)
|
||||
|
||||
// ── Generator ─────────────────────────────────────────────────────
|
||||
|
||||
export type GeneratorStatus = {
|
||||
gen_id: string
|
||||
state: "standby" | "running" | "test" | "fault" | "unknown"
|
||||
fuel_pct: number | null
|
||||
fuel_litres: number | null
|
||||
fuel_rate_lph: number | null
|
||||
load_kw: number | null
|
||||
load_pct: number | null
|
||||
run_hours: number | null
|
||||
voltage_v: number | null
|
||||
frequency_hz: number | null
|
||||
engine_rpm: number | null
|
||||
oil_pressure_bar: number | null
|
||||
coolant_temp_c: number | null
|
||||
exhaust_temp_c: number | null
|
||||
alternator_temp_c: number | null
|
||||
power_factor: number | null
|
||||
battery_v: number | null
|
||||
}
|
||||
|
||||
export type GeneratorHistoryPoint = {
|
||||
bucket: string
|
||||
load_pct: number | null
|
||||
fuel_pct: number | null
|
||||
coolant_temp_c: number | null
|
||||
exhaust_temp_c: number | null
|
||||
frequency_hz: number | null
|
||||
alternator_temp_c: number | null
|
||||
}
|
||||
|
||||
export const fetchGeneratorStatus = (siteId: string) =>
|
||||
apiFetch<GeneratorStatus[]>(`/api/generator/status?site_id=${siteId}`)
|
||||
|
||||
export const fetchGeneratorHistory = (siteId: string, genId: string, hours = 6) =>
|
||||
apiFetch<GeneratorHistoryPoint[]>(`/api/generator/history?site_id=${siteId}&gen_id=${genId}&hours=${hours}`)
|
||||
|
||||
// ── ATS ───────────────────────────────────────────────────────────
|
||||
|
||||
export type AtsStatus = {
|
||||
ats_id: string
|
||||
state: "stable" | "transferring"
|
||||
active_feed: "utility-a" | "utility-b" | "generator"
|
||||
transfer_count: number
|
||||
last_transfer_ms: number | null
|
||||
utility_a_v: number | null
|
||||
utility_b_v: number | null
|
||||
generator_v: number | null
|
||||
}
|
||||
|
||||
export const fetchAtsStatus = (siteId: string) =>
|
||||
apiFetch<AtsStatus[]>(`/api/power/ats?site_id=${siteId}`)
|
||||
|
||||
// ── Phase breakdown ───────────────────────────────────────────────
|
||||
|
||||
export type RackPhase = {
|
||||
rack_id: string
|
||||
room_id: string
|
||||
phase_a_kw: number | null
|
||||
phase_b_kw: number | null
|
||||
phase_c_kw: number | null
|
||||
phase_a_a: number | null
|
||||
phase_b_a: number | null
|
||||
phase_c_a: number | null
|
||||
imbalance_pct: number | null
|
||||
}
|
||||
export type RoomPhase = { room_id: string; racks: RackPhase[] }
|
||||
|
||||
export const fetchPhaseBreakdown = (siteId: string) =>
|
||||
apiFetch<RoomPhase[]>(`/api/power/phase?site_id=${siteId}`)
|
||||
|
||||
// ── Power redundancy ──────────────────────────────────────────────
|
||||
|
||||
export type PowerRedundancy = {
|
||||
site_id: string
|
||||
level: "2N" | "N+1" | "N"
|
||||
ups_total: number
|
||||
ups_online: number
|
||||
generator_ok: boolean
|
||||
ats_active_feed: string | null
|
||||
notes: string
|
||||
}
|
||||
|
||||
export const fetchPowerRedundancy = (siteId: string) =>
|
||||
apiFetch<PowerRedundancy>(`/api/power/redundancy?site_id=${siteId}`)
|
||||
|
||||
// ── Utility power ─────────────────────────────────────────────────
|
||||
|
||||
export type UtilityPower = {
|
||||
site_id: string
|
||||
total_kw: number
|
||||
tariff_sgd_kwh: number
|
||||
kwh_month_to_date: number
|
||||
cost_sgd_mtd: number
|
||||
kwh_annual_est: number
|
||||
cost_sgd_annual_est: number
|
||||
currency: string
|
||||
}
|
||||
|
||||
export const fetchUtilityPower = (siteId: string) =>
|
||||
apiFetch<UtilityPower>(`/api/power/utility?site_id=${siteId}`)
|
||||
|
||||
// ── Chiller ───────────────────────────────────────────────────────
|
||||
|
||||
export type ChillerStatus = {
|
||||
chiller_id: string
|
||||
state: "online" | "fault" | "unknown"
|
||||
chw_supply_c: number | null
|
||||
chw_return_c: number | null
|
||||
chw_delta_c: number | null
|
||||
flow_gpm: number | null
|
||||
cooling_load_kw: number | null
|
||||
cooling_load_pct: number | null
|
||||
cop: number | null
|
||||
compressor_load_pct: number | null
|
||||
condenser_pressure_bar: number | null
|
||||
evaporator_pressure_bar: number | null
|
||||
cw_supply_c: number | null
|
||||
cw_return_c: number | null
|
||||
run_hours: number | null
|
||||
}
|
||||
|
||||
export type ChillerHistoryPoint = {
|
||||
bucket: string
|
||||
cop: number | null
|
||||
load_kw: number | null
|
||||
load_pct: number | null
|
||||
chw_supply_c: number | null
|
||||
chw_return_c: number | null
|
||||
comp_load: number | null
|
||||
}
|
||||
|
||||
export const fetchChillerStatus = (siteId: string) =>
|
||||
apiFetch<ChillerStatus[]>(`/api/cooling/status?site_id=${siteId}`)
|
||||
|
||||
export const fetchChillerHistory = (siteId: string, chillerId: string, hours = 6) =>
|
||||
apiFetch<ChillerHistoryPoint[]>(`/api/cooling/history?site_id=${siteId}&chiller_id=${chillerId}&hours=${hours}`)
|
||||
|
||||
// ── Fire / VESDA ──────────────────────────────────────────────────
|
||||
|
||||
export type FireZoneStatus = {
|
||||
zone_id: string
|
||||
room_id: string | null
|
||||
level: "normal" | "alert" | "action" | "fire"
|
||||
obscuration_pct_m: number | null
|
||||
detector_1_ok: boolean
|
||||
detector_2_ok: boolean
|
||||
power_ok: boolean
|
||||
flow_ok: boolean
|
||||
}
|
||||
|
||||
export const fetchFireStatus = (siteId: string) =>
|
||||
apiFetch<FireZoneStatus[]>(`/api/fire/status?site_id=${siteId}`)
|
||||
|
||||
// ── Leak sensors ──────────────────────────────────────────────────
|
||||
|
||||
export type LeakSensorStatus = {
|
||||
sensor_id: string
|
||||
floor_zone: string
|
||||
under_floor: boolean
|
||||
near_crac: boolean
|
||||
room_id: string | null
|
||||
state: "clear" | "detected" | "unknown"
|
||||
recorded_at: string | null
|
||||
}
|
||||
|
||||
export const fetchLeakStatus = (siteId: string) =>
|
||||
apiFetch<LeakSensorStatus[]>(`/api/leak/status?site_id=${siteId}`)
|
||||
|
||||
// ── Energy report ─────────────────────────────────────────────────
|
||||
|
||||
export type EnergyReport = {
|
||||
site_id: string
|
||||
period_days: number
|
||||
from_date: string
|
||||
to_date: string
|
||||
kwh_total: number
|
||||
cost_sgd: number
|
||||
tariff_sgd_kwh: number
|
||||
currency: string
|
||||
pue_estimated: number
|
||||
pue_trend: { day: string; avg_it_kw: number; pue_est: number }[]
|
||||
}
|
||||
|
||||
export const fetchEnergyReport = (siteId: string, days = 30) =>
|
||||
apiFetch<EnergyReport>(`/api/reports/energy?site_id=${siteId}&days=${days}`)
|
||||
|
||||
// ── Network ───────────────────────────────────────────────────────
|
||||
|
||||
export type NetworkSwitchStatus = {
|
||||
switch_id: string
|
||||
name: string
|
||||
model: string
|
||||
room_id: string
|
||||
rack_id: string
|
||||
role: "core" | "edge" | "access"
|
||||
port_count: number
|
||||
state: "up" | "degraded" | "down" | "unknown"
|
||||
uptime_s: number | null
|
||||
active_ports: number | null
|
||||
bandwidth_in_mbps: number | null
|
||||
bandwidth_out_mbps: number | null
|
||||
cpu_pct: number | null
|
||||
mem_pct: number | null
|
||||
temperature_c: number | null
|
||||
packet_loss_pct: number | null
|
||||
}
|
||||
|
||||
export const fetchNetworkStatus = (siteId: string) =>
|
||||
apiFetch<NetworkSwitchStatus[]>(`/api/network/status?site_id=${siteId}`)
|
||||
|
||||
export type PduReading = {
|
||||
rack_id: string
|
||||
room_id: string
|
||||
total_kw: number | null
|
||||
phase_a_kw: number | null
|
||||
phase_b_kw: number | null
|
||||
phase_c_kw: number | null
|
||||
phase_a_a: number | null
|
||||
phase_b_a: number | null
|
||||
phase_c_a: number | null
|
||||
imbalance_pct: number | null
|
||||
status: "ok" | "warning" | "critical"
|
||||
}
|
||||
|
||||
export const fetchPduReadings = (siteId: string) =>
|
||||
apiFetch<PduReading[]>(`/api/assets/pdus?site_id=${siteId}`)
|
||||
|
||||
// ── Maintenance Windows ───────────────────────────────────────────
|
||||
|
||||
export type MaintenanceWindow = {
|
||||
id: string
|
||||
site_id: string
|
||||
title: string
|
||||
target: string
|
||||
target_label: string
|
||||
start_dt: string
|
||||
end_dt: string
|
||||
suppress_alarms: boolean
|
||||
notes: string
|
||||
created_at: string
|
||||
status: "active" | "scheduled" | "expired"
|
||||
}
|
||||
|
||||
export const fetchMaintenanceWindows = (siteId: string) =>
|
||||
apiFetch<MaintenanceWindow[]>(`/api/maintenance?site_id=${siteId}`)
|
||||
|
||||
export const fetchActiveMaintenanceWindows = (siteId: string) =>
|
||||
apiFetch<MaintenanceWindow[]>(`/api/maintenance/active?site_id=${siteId}`)
|
||||
|
||||
export const createMaintenanceWindow = (body: {
|
||||
site_id: string; title: string; target: string; target_label: string;
|
||||
start_dt: string; end_dt: string; suppress_alarms: boolean; notes: string;
|
||||
}) => apiFetch<MaintenanceWindow>("/api/maintenance", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body) })
|
||||
|
||||
export const deleteMaintenanceWindow = (id: string) =>
|
||||
apiFetch<void>(`/api/maintenance/${id}`, { method: "DELETE" })
|
||||
|
||||
// ── Floor layout ──────────────────────────────────────────────────
|
||||
|
||||
export const fetchFloorLayout = (siteId: string) =>
|
||||
apiFetch<Record<string, unknown>>(`/api/floor-layout?site_id=${siteId}`)
|
||||
|
||||
export const saveFloorLayout = (siteId: string, layout: Record<string, unknown>) =>
|
||||
apiFetch<{ ok: boolean }>(`/api/floor-layout?site_id=${siteId}`, {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(layout),
|
||||
})
|
||||
|
||||
export type ParticleStatus = {
|
||||
room_id: string
|
||||
particles_0_5um: number | null
|
||||
particles_5um: number | null
|
||||
iso_class: number | null
|
||||
}
|
||||
|
||||
export const fetchParticleStatus = (siteId: string) =>
|
||||
apiFetch<ParticleStatus[]>(`/api/env/particles?site_id=${siteId}`)
|
||||
|
||||
// ── Settings: Sensors ─────────────────────────────────────────────────────────
|
||||
|
||||
export type DeviceType = 'ups' | 'generator' | 'crac' | 'chiller' | 'ats' | 'rack' | 'network_switch' | 'leak' | 'fire_zone' | 'custom'
|
||||
export type Protocol = 'mqtt' | 'modbus_tcp' | 'modbus_rtu' | 'snmp' | 'bacnet' | 'http'
|
||||
|
||||
export type SensorDevice = {
|
||||
id: number
|
||||
site_id: string
|
||||
device_id: string
|
||||
name: string
|
||||
device_type: DeviceType
|
||||
room_id: string | null
|
||||
rack_id: string | null
|
||||
protocol: Protocol
|
||||
protocol_config: Record<string, unknown>
|
||||
enabled: boolean
|
||||
created_at: string
|
||||
updated_at: string
|
||||
recent_readings?: { sensor_type: string; value: number; unit: string; recorded_at: string }[]
|
||||
}
|
||||
|
||||
export type SensorCreate = {
|
||||
device_id: string
|
||||
name: string
|
||||
device_type: DeviceType
|
||||
room_id?: string | null
|
||||
rack_id?: string | null
|
||||
protocol: Protocol
|
||||
protocol_config: Record<string, unknown>
|
||||
enabled: boolean
|
||||
}
|
||||
|
||||
export type SensorUpdate = Partial<Omit<SensorCreate, 'device_id'>>
|
||||
|
||||
export const fetchSensors = (siteId: string, params?: { device_type?: string; room_id?: string; protocol?: string }) => {
|
||||
const p: Record<string, string> = { site_id: siteId }
|
||||
if (params?.device_type) p.device_type = params.device_type
|
||||
if (params?.room_id) p.room_id = params.room_id
|
||||
if (params?.protocol) p.protocol = params.protocol
|
||||
return apiFetch<SensorDevice[]>(`/api/settings/sensors?${new URLSearchParams(p)}`)
|
||||
}
|
||||
|
||||
export const fetchSensor = (id: number) =>
|
||||
apiFetch<SensorDevice>(`/api/settings/sensors/${id}`)
|
||||
|
||||
export const createSensor = (siteId: string, body: SensorCreate) =>
|
||||
apiFetch<SensorDevice>(`/api/settings/sensors?site_id=${siteId}`, {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body),
|
||||
})
|
||||
|
||||
export const updateSensor = (id: number, body: SensorUpdate) =>
|
||||
apiFetch<SensorDevice>(`/api/settings/sensors/${id}`, {
|
||||
method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body),
|
||||
})
|
||||
|
||||
export const deleteSensor = (id: number) =>
|
||||
apiFetch<void>(`/api/settings/sensors/${id}`, { method: 'DELETE' })
|
||||
|
||||
// ── Settings: Thresholds ───────────────────────────────────────────────────────
|
||||
|
||||
export type AlarmThreshold = {
|
||||
id: number
|
||||
site_id: string
|
||||
sensor_type: string
|
||||
threshold_value: number
|
||||
direction: 'above' | 'below'
|
||||
severity: 'warning' | 'critical'
|
||||
message_template: string
|
||||
enabled: boolean
|
||||
locked: boolean
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}
|
||||
|
||||
export type ThresholdUpdate = { threshold_value?: number; severity?: string; enabled?: boolean }
|
||||
export type ThresholdCreate = { sensor_type: string; threshold_value: number; direction: string; severity: string; message_template: string }
|
||||
|
||||
export const fetchThresholds = (siteId: string) =>
|
||||
apiFetch<AlarmThreshold[]>(`/api/settings/thresholds?site_id=${siteId}`)
|
||||
|
||||
export const updateThreshold = (id: number, body: ThresholdUpdate) =>
|
||||
apiFetch<AlarmThreshold>(`/api/settings/thresholds/${id}`, {
|
||||
method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body),
|
||||
})
|
||||
|
||||
export const createThreshold = (siteId: string, body: ThresholdCreate) =>
|
||||
apiFetch<AlarmThreshold>(`/api/settings/thresholds?site_id=${siteId}`, {
|
||||
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body),
|
||||
})
|
||||
|
||||
export const deleteThreshold = (id: number) =>
|
||||
apiFetch<void>(`/api/settings/thresholds/${id}`, { method: 'DELETE' })
|
||||
|
||||
export const resetThresholds = (siteId: string) =>
|
||||
apiFetch<{ ok: boolean; count: number }>(`/api/settings/thresholds/reset?site_id=${siteId}`, { method: 'POST' })
|
||||
|
||||
// ── Settings: Site / Notifications / Integrations ─────────────────────────────
|
||||
|
||||
export type SiteSettings = { name: string; timezone: string; description: string }
|
||||
export type NotificationSettings = { critical_alarms: boolean; warning_alarms: boolean; generator_events: boolean; maintenance_reminders: boolean; webhook_url: string; email_recipients: string }
|
||||
export type IntegrationSettings = { mqtt_host: string; mqtt_port: number }
|
||||
export type PagePrefs = { default_time_range_hours: number; refresh_interval_seconds: number }
|
||||
|
||||
const _settingsPut = <T>(path: string, value: Partial<T>) =>
|
||||
apiFetch<T>(`/api/settings/${path}`, {
|
||||
method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ value }),
|
||||
})
|
||||
|
||||
export const fetchSiteSettings = (siteId: string) => apiFetch<SiteSettings>(`/api/settings/site?site_id=${siteId}`)
|
||||
export const updateSiteSettings = (siteId: string, v: Partial<SiteSettings>) => _settingsPut<SiteSettings>(`site?site_id=${siteId}`, v)
|
||||
export const fetchNotifications = (siteId: string) => apiFetch<NotificationSettings>(`/api/settings/notifications?site_id=${siteId}`)
|
||||
export const updateNotifications = (siteId: string, v: Partial<NotificationSettings>) => _settingsPut<NotificationSettings>(`notifications?site_id=${siteId}`, v)
|
||||
export const fetchIntegrations = (siteId: string) => apiFetch<IntegrationSettings>(`/api/settings/integrations?site_id=${siteId}`)
|
||||
export const updateIntegrations = (siteId: string, v: Partial<IntegrationSettings>) => _settingsPut<IntegrationSettings>(`integrations?site_id=${siteId}`, v)
|
||||
export const fetchPagePrefs = (siteId: string) => apiFetch<PagePrefs>(`/api/settings/page-prefs?site_id=${siteId}`)
|
||||
export const updatePagePrefs = (siteId: string, v: Partial<PagePrefs>) => _settingsPut<PagePrefs>(`page-prefs?site_id=${siteId}`, v)
|
||||
|
||||
// ── Scenarios (simulator control) ─────────────────────────────────
|
||||
|
||||
export type ScenarioInfo = {
|
||||
name: string
|
||||
label: string
|
||||
description: string
|
||||
duration: string
|
||||
compound: boolean
|
||||
default_target: string | null
|
||||
targets: string[]
|
||||
}
|
||||
|
||||
export const fetchScenarios = () =>
|
||||
apiFetch<ScenarioInfo[]>("/api/scenarios")
|
||||
|
||||
export const triggerScenario = (scenario: string, target?: string) =>
|
||||
apiFetch<{ ok: boolean }>("/api/scenarios/trigger", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ scenario, target: target ?? null }),
|
||||
})
|
||||
101
frontend/lib/threshold-context.tsx
Normal file
101
frontend/lib/threshold-context.tsx
Normal file
|
|
@ -0,0 +1,101 @@
|
|||
"use client";
|
||||
|
||||
import { createContext, useContext, useState, useEffect, useMemo } from "react";
|
||||
import { THRESHOLDS } from "@/lib/thresholds";
|
||||
|
||||
const LS_KEY = "bms_thresholds";
|
||||
|
||||
/** Subset of THRESHOLDS that the Settings page allows overriding */
|
||||
export interface ThresholdOverrides {
|
||||
temp?: { warn?: number; critical?: number };
|
||||
humidity?: { low?: number; warn?: number; critical?: number };
|
||||
power?: { warn?: number; critical?: number };
|
||||
}
|
||||
|
||||
/** Mutable version of THRESHOLDS for runtime use */
|
||||
export interface MergedThresholds {
|
||||
temp: { warn: number; critical: number };
|
||||
humidity: { low: number; warn: number; critical: number };
|
||||
dewPoint: { warn: number };
|
||||
power: { warn: number; critical: number };
|
||||
rackPower: { warn: number; critical: number; rated: number };
|
||||
filter: { warn: number; critical: number; ratePerDay: number; replaceAt: number };
|
||||
cop: { warn: number };
|
||||
compressor: { warn: number; critical: number };
|
||||
battery: { warn: number; critical: number };
|
||||
fuel: { warn: number; critical: number };
|
||||
ups: { loadWarn: number; loadCritical: number };
|
||||
pue: { target: number; warn: number; critical: number };
|
||||
phaseImbalance: { warn: number; critical: number };
|
||||
network: { cpuWarn: number; cpuCritical: number; memWarn: number; memCritical: number; tempWarn: number; tempCritical: number };
|
||||
ashrae: { tempMin: number; tempMax: number; rhMin: number; rhMax: number };
|
||||
}
|
||||
|
||||
interface ThresholdContextValue {
|
||||
thresholds: MergedThresholds;
|
||||
setThresholds: (patch: ThresholdOverrides) => void;
|
||||
}
|
||||
|
||||
const ThresholdContext = createContext<ThresholdContextValue | null>(null);
|
||||
|
||||
function loadOverrides(): ThresholdOverrides {
|
||||
if (typeof window === "undefined") return {};
|
||||
try {
|
||||
const raw = localStorage.getItem(LS_KEY);
|
||||
return raw ? JSON.parse(raw) : {};
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
function merge(overrides: ThresholdOverrides): MergedThresholds {
|
||||
return {
|
||||
temp: { warn: THRESHOLDS.temp.warn, critical: THRESHOLDS.temp.critical, ...overrides.temp },
|
||||
humidity: { low: THRESHOLDS.humidity.low, warn: THRESHOLDS.humidity.warn, critical: THRESHOLDS.humidity.critical, ...overrides.humidity },
|
||||
dewPoint: { warn: THRESHOLDS.dewPoint.warn },
|
||||
power: { warn: THRESHOLDS.power.warn, critical: THRESHOLDS.power.critical, ...overrides.power },
|
||||
rackPower: { warn: THRESHOLDS.rackPower.warn, critical: THRESHOLDS.rackPower.critical, rated: THRESHOLDS.rackPower.rated },
|
||||
filter: { warn: THRESHOLDS.filter.warn, critical: THRESHOLDS.filter.critical, ratePerDay: THRESHOLDS.filter.ratePerDay, replaceAt: THRESHOLDS.filter.replaceAt },
|
||||
cop: { warn: THRESHOLDS.cop.warn },
|
||||
compressor: { warn: THRESHOLDS.compressor.warn, critical: THRESHOLDS.compressor.critical },
|
||||
battery: { warn: THRESHOLDS.battery.warn, critical: THRESHOLDS.battery.critical },
|
||||
fuel: { warn: THRESHOLDS.fuel.warn, critical: THRESHOLDS.fuel.critical },
|
||||
ups: { loadWarn: THRESHOLDS.ups.loadWarn, loadCritical: THRESHOLDS.ups.loadCritical },
|
||||
pue: { target: THRESHOLDS.pue.target, warn: THRESHOLDS.pue.warn, critical: THRESHOLDS.pue.critical },
|
||||
phaseImbalance: { warn: THRESHOLDS.phaseImbalance.warn, critical: THRESHOLDS.phaseImbalance.critical },
|
||||
network: { cpuWarn: THRESHOLDS.network.cpuWarn, cpuCritical: THRESHOLDS.network.cpuCritical, memWarn: THRESHOLDS.network.memWarn, memCritical: THRESHOLDS.network.memCritical, tempWarn: THRESHOLDS.network.tempWarn, tempCritical: THRESHOLDS.network.tempCritical },
|
||||
ashrae: { tempMin: THRESHOLDS.ashrae.tempMin, tempMax: THRESHOLDS.ashrae.tempMax, rhMin: THRESHOLDS.ashrae.rhMin, rhMax: THRESHOLDS.ashrae.rhMax },
|
||||
};
|
||||
}
|
||||
|
||||
export function ThresholdProvider({ children }: { children: React.ReactNode }) {
|
||||
const [overrides, setOverrides] = useState<ThresholdOverrides>({});
|
||||
|
||||
useEffect(() => {
|
||||
setOverrides(loadOverrides());
|
||||
}, []);
|
||||
|
||||
const thresholds = useMemo(() => merge(overrides), [overrides]);
|
||||
|
||||
function setThresholds(patch: ThresholdOverrides) {
|
||||
const next: ThresholdOverrides = {
|
||||
temp: { ...overrides.temp, ...patch.temp },
|
||||
humidity: { ...overrides.humidity, ...patch.humidity },
|
||||
power: { ...overrides.power, ...patch.power },
|
||||
};
|
||||
setOverrides(next);
|
||||
localStorage.setItem(LS_KEY, JSON.stringify(next));
|
||||
}
|
||||
|
||||
return (
|
||||
<ThresholdContext.Provider value={{ thresholds, setThresholds }}>
|
||||
{children}
|
||||
</ThresholdContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useThresholds(): ThresholdContextValue {
|
||||
const ctx = useContext(ThresholdContext);
|
||||
if (!ctx) throw new Error("useThresholds must be used inside ThresholdProvider");
|
||||
return ctx;
|
||||
}
|
||||
114
frontend/lib/thresholds.ts
Normal file
114
frontend/lib/thresholds.ts
Normal file
|
|
@ -0,0 +1,114 @@
|
|||
/**
|
||||
* Centralised operational thresholds.
|
||||
* Import from here instead of hardcoding values in individual pages.
|
||||
*/
|
||||
export const THRESHOLDS = {
|
||||
temp: {
|
||||
warn: 26, // °C
|
||||
critical: 28, // °C
|
||||
},
|
||||
humidity: {
|
||||
low: 30, // % RH — static risk below this
|
||||
warn: 65, // % RH
|
||||
critical: 80, // % RH
|
||||
},
|
||||
dewPoint: {
|
||||
warn: 15, // °C — condensation risk zone
|
||||
},
|
||||
power: {
|
||||
warn: 0.75, // fraction of rated capacity
|
||||
critical: 0.85,
|
||||
},
|
||||
rackPower: {
|
||||
warn: 7.5, // kW per rack
|
||||
critical: 9.5, // kW per rack
|
||||
rated: 10, // kW per rack (default rated capacity)
|
||||
},
|
||||
filter: {
|
||||
warn: 80, // Pa differential pressure
|
||||
critical: 120, // Pa
|
||||
ratePerDay: 1.2, // Pa/day assumed fouling rate
|
||||
replaceAt: 120, // Pa
|
||||
},
|
||||
cop: {
|
||||
warn: 1.5, // COP below this is inefficient
|
||||
},
|
||||
compressor: {
|
||||
warn: 0.80, // fraction of capacity
|
||||
critical: 0.95,
|
||||
},
|
||||
battery: {
|
||||
warn: 30, // % state of charge
|
||||
critical: 20, // %
|
||||
},
|
||||
fuel: {
|
||||
warn: 30, // % fuel level
|
||||
critical: 15, // %
|
||||
},
|
||||
ups: {
|
||||
loadWarn: 0.75, // fraction of rated load
|
||||
loadCritical: 0.90,
|
||||
},
|
||||
pue: {
|
||||
target: 1.4,
|
||||
warn: 1.6,
|
||||
critical: 2.0,
|
||||
},
|
||||
phaseImbalance: {
|
||||
warn: 5, // %
|
||||
critical: 15, // %
|
||||
},
|
||||
network: {
|
||||
cpuWarn: 70, // %
|
||||
cpuCritical: 90, // %
|
||||
memWarn: 70, // %
|
||||
memCritical: 85, // %
|
||||
tempWarn: 55, // °C
|
||||
tempCritical: 70, // °C
|
||||
},
|
||||
ashrae: {
|
||||
// ASHRAE A1 Class envelope
|
||||
tempMin: 15, // °C
|
||||
tempMax: 32, // °C
|
||||
rhMin: 20, // %
|
||||
rhMax: 80, // %
|
||||
},
|
||||
} as const;
|
||||
|
||||
/** Colour helper: returns a Tailwind text/bg colour token based on a value vs warn/critical pair */
|
||||
export function severityColor(
|
||||
value: number,
|
||||
warn: number,
|
||||
critical: number,
|
||||
invert = false, // set true when lower is worse (e.g. battery %)
|
||||
): "green" | "amber" | "red" {
|
||||
if (invert) {
|
||||
if (value <= critical) return "red";
|
||||
if (value <= warn) return "amber";
|
||||
return "green";
|
||||
}
|
||||
if (value >= critical) return "red";
|
||||
if (value >= warn) return "amber";
|
||||
return "green";
|
||||
}
|
||||
|
||||
export const COLOR_CLASSES = {
|
||||
green: {
|
||||
text: "text-green-400",
|
||||
bg: "bg-green-500/15",
|
||||
border: "border-green-500/30",
|
||||
badge: "bg-green-500/20 text-green-300",
|
||||
},
|
||||
amber: {
|
||||
text: "text-amber-400",
|
||||
bg: "bg-amber-500/15",
|
||||
border: "border-amber-500/30",
|
||||
badge: "bg-amber-500/20 text-amber-300",
|
||||
},
|
||||
red: {
|
||||
text: "text-red-400",
|
||||
bg: "bg-red-500/15",
|
||||
border: "border-red-500/30",
|
||||
badge: "bg-red-500/20 text-red-300",
|
||||
},
|
||||
} as const;
|
||||
6
frontend/lib/utils.ts
Normal file
6
frontend/lib/utils.ts
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
import { clsx, type ClassValue } from "clsx"
|
||||
import { twMerge } from "tailwind-merge"
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs))
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue