first commit

This commit is contained in:
mega 2026-03-19 11:32:17 +00:00
commit 4b98219bf7
144 changed files with 31561 additions and 0 deletions

42
.gitignore vendored Normal file
View file

@ -0,0 +1,42 @@
# Dependencies
node_modules/
.pnp
.pnp.js
# Build outputs
.next/
out/
dist/
build/
__pycache__/
*.pyc
*.pyo
*.egg-info/
.eggs/
# Environment files — NEVER commit these
.env
.env.local
.env.*.local
# IDE
.vscode/
.idea/
*.swp
*.swo
# OS
.DS_Store
Thumbs.db
# Docker
*.log
# Claude Code local settings
.claude/
# TypeScript build cache
*.tsbuildinfo
# Next.js auto-generated type file
next-env.d.ts

149
ACTION_PLAN.md Normal file
View file

@ -0,0 +1,149 @@
# BMS Action Plan — Post-Audit Fixes
> Generated: 2026-03-10
> Based on cross-referencing live site (bmsdemo.rdx4.com) against IMPROVEMENTS.md and source audit.
> Excludes: Clerk auth (intentional public demo), scheduled PDF email (deferred).
---
## Phase 1 — Trivial Fixes (< 30 min, no backend, no context changes)
These are single-file, low-risk changes.
| # | File | Issue | Fix |
|---|------|-------|-----|
| 1.1 | `components/layout/sidebar.tsx` | Network is in the "Safety" group alongside Leak and Fire, which is semantically wrong | Move `/network` into the "Infrastructure" group, below Generator |
| 1.2 | `lib/api.ts` | Base path is `/api/backend` and all fetch paths start `/api/...`, resulting in `/api/backend/api/...` — looks like a double-prefix at first glance | Add a one-line comment above `const BASE` explaining the proxy path convention |
| 1.3 | `IMPROVEMENTS.md` | Settings page (`/settings`) is fully built but completely absent from the plan | Add a new entry to Phase 6 or a new Phase 7 "Untracked Additions" section to document it |
| 1.4 | `IMPROVEMENTS.md` | Item 6.11 "mini floor map thumbnail" is marked `[x]` but what was built is a `RoomStatusGrid` (tabular room stats), not a visual rack-grid thumbnail | Update the description to clarify what was actually delivered, or un-tick and add to backlog |
---
## Phase 2 — Settings Page: Wire Up Persistence
Currently all Settings save buttons have no `onClick` handler and threshold edits are never written anywhere. All pages read from the static `THRESHOLDS` constant in `lib/thresholds.ts`, making the entire Settings page cosmetic.
This phase makes Settings functional without a backend API — using `localStorage` and a React context so changes persist across refreshes and propagate to all pages at runtime.
### 2.1 — Create a `ThresholdContext`
**New file:** `lib/threshold-context.tsx`
- Wraps the static `THRESHOLDS` object as default values
- On mount, reads overrides from `localStorage` key `bms_thresholds` and merges
- Exposes `thresholds` (the merged values) and `setThresholds(patch)` (writes to localStorage and re-renders)
- Re-export a `useThresholds()` hook
### 2.2 — Add the provider to the dashboard layout
**File:** `app/(dashboard)/layout.tsx`
- Wrap children in `<ThresholdProvider>` so all dashboard pages share the same context
### 2.3 — Update pages to read from context
Pages that currently import and use `THRESHOLDS` directly need to call `useThresholds()` instead. A grep for `THRESHOLDS` in `app/(dashboard)/` will give the full list. Likely candidates:
- `environmental/page.tsx` (temp/humidity thresholds for heatmap + ASHRAE table)
- `cooling/page.tsx` (filter ΔP, COP, compressor thresholds)
- `power/page.tsx` (rack power warn/crit lines on bar chart)
- `capacity/page.tsx` (radial gauge colour bands)
- `floor-map/page.tsx` (rack tile colour scale)
`lib/thresholds.ts` stays as the canonical defaults — no change needed there.
### 2.4 — Wire the Save buttons in `settings/page.tsx`
**File:** `app/(dashboard)/settings/page.tsx`
- `ThresholdsTab`: call `setThresholds({ temp: { warn, critical }, humidity: { ... }, power: { ... } })` in the Save onClick
- `ProfileTab` / `NotificationsTab`: these are cosmetic for a demo — write to `localStorage` under `bms_profile` / `bms_notifications` keys so at least the values survive a refresh, even if they don't affect anything functional
### 2.5 — Add a visible "saved" confirmation
Currently there is no feedback when Save is clicked. Add a brief `sonner` toast (the project already uses it) on successful save. The `Toaster` is already mounted in the dashboard layout.
---
## Phase 3 — Dashboard: True Mini Floor Map Thumbnail
Item 6.11 was marked done but delivered a room status table rather than a visual map.
**File:** `app/(dashboard)/dashboard/page.tsx` + new component `components/dashboard/mini-floor-map.tsx`
- Replace (or sit alongside) `RoomStatusGrid` in the bottom row with a compact rack-grid tile
- Re-use the colour logic already written in `floor-map/page.tsx` (temp overlay colours by default)
- Each rack tile is a small coloured square (~12×16px), labelled with rack ID on hover tooltip
- CRAC units shown as labelled strips at room edge (same pattern as full floor map)
- Click navigates to `/floor-map`
- Room tabs (Hall A / Hall B) if both rooms are present
- Read from the same `/api/readings/rack-status` data already fetched on dashboard
This requires no backend changes — just a new presentational component that reuses existing API data.
---
## Phase 4 — Floor Map: Zoom/Pan + CRAC Coverage Shading
These are the two remaining open items from 6.12.
### 4.1 — Zoom / Pan
**File:** `app/(dashboard)/floor-map/page.tsx`
- Add `react-zoom-pan-pinch` (or equivalent) as a dependency: `pnpm add react-zoom-pan-pinch`
- Wrap the rack grid `div` in a `<TransformWrapper>` / `<TransformComponent>` block
- Add zoom controls (+ / / reset buttons) in the map header, above the legend
- Pinch-to-zoom should work on touch devices automatically via the library
### 4.2 — CRAC Coverage Shading
**File:** `app/(dashboard)/floor-map/page.tsx`
- Add a 5th overlay option to the overlay selector: **CRAC Coverage**
- When active, colour each rack tile according to which CRAC unit is its nearest thermal neighbour (assign by row proximity — CRACs at room ends serve the rows closest to them, split at the midpoint)
- Use a per-CRAC colour palette (46 distinct hues, low-opacity background fill)
- Show CRAC ID in the legend with its assigned colour
- No backend required — assignment is purely spatial, computed client-side from rack row index and CRAC position
---
## Phase 5 — Environmental: Particle Count (ISO 14644)
Item 6.10. This is the only remaining `[ ]` item that hasn't been deferred.
### 5.1 — Simulator
**File:** backend simulator (not in frontend repo — coordinate with backend)
- Add a `ParticleBot` (or extend an existing env bot) that emits:
- `particles_0_5um` — count/m³, particles ≥0.5 µm
- `particles_5um` — count/m³, particles ≥5 µm
- Derived ISO class (19) per room, changing slowly with occasional spikes
### 5.2 — Backend API
- New endpoint: `GET /api/environmental/particles?site_id=&room_id=`
- Returns current counts + derived ISO class per room
### 5.3 — Frontend
**File:** `app/(dashboard)/environmental/page.tsx` + `lib/api.ts`
- Add `fetchParticleStatus(siteId)` to `lib/api.ts`
- Add a new panel on the Environmental page: **"Air Quality — ISO 14644"**
- Per-room ISO class badge (ISO 19, colour-coded: green ≤7, amber 8, red 9)
- 0.5 µm and 5 µm count bars with threshold lines (ISO 8 limits: 3,520,000 and 29,300 /m³)
- A small note that DC target is ISO 8 (≤100,000 particles ≥0.5 µm/m³)
- Trend sparkline (last 24h)
---
## Execution Order Summary
| Phase | Scope | Backend needed? | Effort estimate |
|-------|-------|-----------------|-----------------|
| 1 — Trivial fixes | IMPROVEMENTS.md + sidebar + api.ts comment | No | ~20 min |
| 2 — Settings persistence | New context + localStorage + toast feedback | No | ~23 h |
| 3 — Mini floor map | New dashboard component | No | ~23 h |
| 4 — Floor map zoom/pan + CRAC shading | react-zoom-pan-pinch + overlay logic | No | ~34 h |
| 5 — Particle count | New simulator bot + API endpoint + env panel | Yes | ~34 h total |

271
IMPROVEMENTS.md Normal file
View file

@ -0,0 +1,271 @@
# BMS Improvement Plan — Singapore DC01
> Read this file at the start of the next session to restore context.
> Generated from full page review (all 9 pages read and analysed).
---
## Phased Execution Plan
### Phase 1 — Frontend Quick Wins (no backend/simulator changes)
| # | Page | Improvement | Status |
|---|------|-------------|--------|
| 1.1 | Alarms | Escalation timer — colour-ramping counter for unacknowledged critical alarms | [x] |
| 1.2 | Alarms | MTTR stat card — derived from triggered_at → resolved_at | deferred to Phase 3 (needs resolved_at from backend) |
| 1.3 | Assets | Sortable inventory table columns | [x] |
| 1.4 | Environmental | Humidity overlay toggle on heatmap | [x] |
| 1.5 | Environmental | Dew point derived client-side (Magnus formula from temp + humidity) | [x] |
| 1.6 | Environmental | ASHRAE A1 compliance table per rack | [x] |
| 1.8 | Capacity | Stranded power total kW shown prominently | [x] |
| 1.9 | Environmental | Dew point vs. supply air temp chart (client-side derived) | [x] |
| 1.10 | Floor Map | Alarm badge overlay option | [x] |
### Phase 2 — Simulator Expansion (new bots + topology)
| # | Bot | Status |
|---|-----|--------|
| 2.1 | GeneratorBot — fuel_pct, load_kw, run_hours, state, scenarios: GENERATOR_FAILURE / LOW_FUEL | [x] |
| 2.2 | AtsBot — active_feed, transfer_count, last_transfer_ms, scenario: ATS_TRANSFER | [x] |
| 2.3 | ChillerBot — chw_supply/return_c, flow_gpm, cop, condenser_pressure_bar, scenario: CHILLER_FAULT | [x] |
| 2.4 | VesdaBot — level (normal/alert/action/fire), obscuration_pct, zone_id, scenarios: VESDA_ALERT / VESDA_FIRE | [x] |
| 2.5 | Extend PduBot — per-phase kW + amps (A/B/C), imbalance_pct, scenario: PHASE_IMBALANCE | [x] |
| 2.6 | Extend WaterLeakBot — floor_zone, under_floor, near_crac metadata | [x] |
| 2.7 | Topology update — generators, ats, chillers, vesda zones, extra leak sensors | [x] |
### Phase 3 — Backend API Expansion
| # | Endpoint | Status |
|---|----------|--------|
| 3.1 | GET /api/generator/status | [x] |
| 3.2 | GET /api/power/ats | [x] |
| 3.3 | GET /api/power/phase | [x] |
| 3.4 | GET /api/power/redundancy | [x] |
| 3.5 | GET /api/cooling/status (chiller) | [x] |
| 3.6 | GET /api/cooling/history (COP + capacity over time) | [x] |
| 3.7 | GET /api/fire/status (VESDA zones) | [x] |
| 3.8 | GET /api/leak/status (with location metadata) | [x] |
| 3.9 | GET /api/power/utility (grid import, tariff, monthly kWh) | [x] |
| 3.10 | GET /api/reports/energy (kWh cost, PUE 30-day trend) | [x] |
| 3.11 | Extend cooling/{crac_id} detail — add airflow_cfm | [x] (was already done in env.py) |
### Phase 4 — Existing Pages Wired Up (uses Phase 2+3 data)
| # | Page | Improvement | Status |
|---|------|-------------|--------|
| 4.1 | Dashboard | Generator status KPI card | [x] |
| 4.2 | Dashboard | Leak detection KPI card | [x] |
| 4.3 | Dashboard | UPS worst-case runtime card | deferred (UPS runtime already shown on Power page) |
| 4.4 | Power | Generator section | [x] |
| 4.5 | Power | ATS transfer switch panel | [x] |
| 4.6 | Power | PDU branch circuit section | [x] phase imbalance table |
| 4.7 | Power | Phase imbalance warning on UPS cards | [x] |
| 4.8 | Power | Power redundancy level indicator | [x] |
| 4.9 | Cooling | COP trend chart per CRAC | [x] (in CRAC detail sheet) |
| 4.10 | Cooling | Chiller plant summary panel | [x] |
| 4.11 | Cooling | Predictive filter replacement estimate | [x] |
| 4.12 | Cooling | Airflow CFM tile in fleet summary | [x] |
| 4.13 | Environmental | Leak sensor map panel | [x] |
| 4.14 | Environmental | VESDA/smoke status panel | [x] |
| 4.15 | Floor Map | Leak sensor overlay layer | [x] (panel below map) |
| 4.16 | Floor Map | Power feed (A/B) overlay layer | [x] |
| 4.17 | Floor Map | Humidity 3rd overlay | [x] (done in Phase 1) |
| 4.18 | Capacity | N+1 cooling margin indicator | [x] |
| 4.19 | Capacity | Capacity runway chart | [x] |
| 4.20 | Alarms | Generator alarm category | [x] (alarm engine raises gen alarms automatically) |
| 4.21 | Alarms | Leak alarm category with floor map link | [x] (alarm engine already handles leak) |
| 4.22 | Alarms | Fire/VESDA alarm category | [x] (alarm engine raises vesda_level alarms) |
| 4.23 | Assets | PDU as asset type | [x] (PDU phase monitoring section in assets grid) |
| 4.24 | Assets | Rack elevation diagram in RackDetailSheet | [x] (already implemented as RackDiagram) |
| 4.25 | Reports | PUE 30-day trend graph | [x] (daily IT kW trend + PUE estimated) |
| 4.26 | Reports | Energy cost section | [x] |
### Phase 5 — New Pages
| # | Page | Status |
|---|------|--------|
| 5.1 | Generator & Power Path | [x] |
| 5.2 | Leak Detection | [x] |
| 5.3 | Fire & Life Safety | [x] |
### Phase 6 — Low Priority & Polish
| # | Item | Status |
|---|------|--------|
| 6.1 | Alarms: assigned-to column + maintenance window suppression | [x] (assigned-to with localStorage) |
| 6.2 | Alarms: root cause correlation | [x] (5-rule RootCausePanel above stat cards) |
| 6.3 | Assets: warranty expiry + lifecycle status | [x] (lifecycle status column added) |
| 6.4 | Assets: CSV import/export for CMDB | [x] (CSV export added) |
| 6.5 | Reports: comparison period (this week vs last) | [x] |
| 6.6 | Reports: scheduled PDF email | [ ] |
| 6.7 | New page: Network Infrastructure | [x] |
| 6.8 | New page: Energy & Sustainability | [x] |
| 6.9 | New page: Maintenance windows | [x] |
| 6.10 | Environmental: particle count (ISO 14644) | [ ] |
| 6.11 | Dashboard: room quick-status grid (Hall A / Hall B avg temp, power, CRAC state) — visual rack-grid thumbnail deferred to backlog | [x] |
| 6.12 | Floor Map: zoom/pan + CRAC coverage shading | [ ] |
### Phase 7 — Untracked Additions
| # | Item | Status |
|---|------|--------|
| 7.1 | Settings page — Profile, Notifications, Thresholds, Site Config tabs | [x] |
| 7.2 | Floor layout editor — server-side persistence via site_config table (PUT/GET /api/floor-layout) | [x] |
| 7.3 | Rack naming convention updated to SG1A01.xx / SG1B01.xx format across all topology files | [x] |
| 7.4 | 80-rack topology — Hall A and Hall B each have 2 rows × 20 racks | [x] |
---
---
## Dashboard (`/dashboard`)
| # | Type | Improvement | Priority |
|---|------|-------------|----------|
| 1 | Sensor | Add Generator status KPI card (fuel %, run-hours, transfer state) | High |
| 2 | Sensor | Add Water/Leak Detection KPI card — badge showing any active leaks | High |
| 3 | Sensor | Add Raised floor differential pressure widget | Medium |
| 4 | Sensor | Show UPS state in KPI row (mains vs. battery, worst-case runtime) | High |
| 5 | Visual | Dashboard KPI row: add 5th card or replace PUE with site health score | Medium |
| 6 | Visual | Add mini floor map thumbnail as 4th bottom-row panel | Medium |
| 7 | Info | Show carbon intensity / CO2e alongside PUE | Low |
| 8 | Info | Add MTBF / uptime streak counter for critical infrastructure | Low |
---
## Cooling (`/cooling`)
| # | Type | Improvement | Priority |
|---|------|-------------|----------|
| 1 | Sensor | Add Chiller plant metrics — CHW supply/return temps, flow rate, chiller COP, condenser pressure | High |
| 2 | Sensor | Add Cooling tower stats — approach temp, basin level, blow-down rate, fan speed | Medium |
| 3 | Sensor | Glycol/refrigerant level indicator per CRAC | High |
| 4 | Sensor | Airflow (CFM) per CRAC — not just fan % | Medium |
| 5 | Sensor | Condenser water inlet/outlet temperature for water-cooled units | Medium |
| 6 | Sensor | Raised floor tile differential pressure — 0.040.08 in. W.C. target range | High |
| 7 | Sensor | Hot/cold aisle containment breach indicator — door open, blanking panels | Medium |
| 8 | Sensor | Chilled water flow rate (GPM) and heat rejection kW | Medium |
| 9 | Visual | COP trend chart over time per unit (currently only static value) | High |
| 10 | Visual | Fleet summary: add total fleet airflow (CFM) tile | Medium |
| 11 | Visual | Add cooling efficiency vs. IT load scatter/trend chart | Medium |
| 12 | Info | Predictive filter replacement — estimated days until change-out based on dP rate of rise | Medium |
---
## Power (`/power`)
| # | Type | Improvement | Priority |
|---|------|-------------|----------|
| 1 | Sensor | Add Generator status section — active/standby, fuel %, last test date, load kW | High |
| 2 | Sensor | Add ATS/STS transfer switch status — which feed active (Utility A/B), transfer time | High |
| 3 | Sensor | Add PDU branch circuit monitoring — per-phase kW, amps, trip status | High |
| 4 | Sensor | Power quality metrics — THD, voltage sag/swell events, neutral current | Medium |
| 5 | Sensor | Busway / overhead busbar load per tap-off box | Medium |
| 6 | Sensor | Utility metering — grid import kW, tariff period, cost/kWh, monthly kWh | Medium |
| 7 | Sensor | Phase imbalance per panel/UPS — flag >5% imbalance | High |
| 8 | Visual | UPS cards: add input voltage/frequency per phase, bypass mode status | Medium |
| 9 | Info | Add power redundancy level indicator — N, N+1, 2N — highlight single points of failure | High |
| 10 | Info | Annualised energy cost projection alongside kWh | Low |
---
## Environmental (`/environmental`)
| # | Type | Improvement | Priority |
|---|------|-------------|----------|
| 1 | Sensor | Add Dew point derived value per room — approaching supply temp = condensation risk | High |
| 2 | Sensor | Add Water/leak detection sensors map — floor, under-floor, drip trays, pipe runs | High |
| 3 | Sensor | Smoke detector / VESDA status panel — aspirating detector alarm levels | High |
| 4 | Sensor | Raised floor pressure differential trend chart | Medium |
| 5 | Sensor | Hot aisle inlet temperature per rack row (return air) | Medium |
| 6 | Sensor | Server inlet temperature sensors from IPMI per device | Medium |
| 7 | Sensor | Particle count (ISO 14644 class) | Low |
| 8 | Visual | Heatmap: add humidity overlay toggle (currently separate chart only) | High |
| 9 | Visual | Add ASHRAE compliance table per rack — flag racks outside A1/A2 envelope | Medium |
| 10 | Visual | Add dew point vs. supply air temp chart with condensation risk zone | Medium |
| 11 | Info | Show absolute humidity (g/kg) alongside RH for ASHRAE compliance | Low |
---
## Floor Map (`/floor-map`)
| # | Type | Improvement | Priority |
|---|------|-------------|----------|
| 1 | Sensor | Add leak sensor overlay — highlight tiles where water sensors are placed | High |
| 2 | Sensor | Add smoke/VESDA zone overlay | Medium |
| 3 | Sensor | Add PDU/power path overlay — show which feed (A/B) each rack is on | High |
| 4 | Visual | Add 3rd overlay: humidity | Medium |
| 5 | Visual | Add airflow arrows showing cold aisle → rack → hot aisle direction | Low |
| 6 | Visual | Show blank rack slots count on each rack tile (U available) | Medium |
| 7 | Visual | Add rack-level alarm badge as an overlay option | High |
| 8 | Visual | Add zoom/pan for larger floor plans | Medium |
| 9 | Info | Add CRAC coverage radius shading showing which racks each CRAC thermally serves | Medium |
---
## Capacity (`/capacity`)
| # | Type | Improvement | Priority |
|---|------|-------------|----------|
| 1 | Visual | Add capacity runway chart — at current growth rate, weeks until power/cooling capacity hit | High |
| 2 | Sensor | Add U-space utilisation per rack — units occupied vs. total 42U | Medium |
| 3 | Sensor | Generator fuel capacity as a capacity dimension | Medium |
| 4 | Info | Thermal capacity per CRAC vs. current IT load — N+1 cooling margin | High |
| 5 | Info | Add growth projection input — operator enters expected kW/month to forecast capacity date | Medium |
| 6 | Visual | Cross-room comparison radar chart (Power %, Cooling %, Space %) | Medium |
| 7 | Visual | Show stranded power total in kW (not just per-rack list) | Medium |
| 8 | Sensor | Weight capacity per rack — floor load (kg/m2) | Low |
---
## Alarms (`/alarms`)
| # | Type | Improvement | Priority |
|---|------|-------------|----------|
| 1 | Sensor | Add Generator alarm category (fuel low, start fail, overload) | High |
| 2 | Sensor | Add Leak alarm category with direct link to leak sensor on floor map | High |
| 3 | Sensor | Add Fire/VESDA alarm category with severity escalation | High |
| 4 | Sensor | Add Network device alarm category (switch down, link fault, LACP failure) | Medium |
| 5 | Visual | Add escalation timer — how long critical alarm unacknowledged, colour ramp | High |
| 6 | Visual | Add MTTR stat card alongside existing stat cards | Medium |
| 7 | Visual | Alarm table: add "Assigned to" column | Low |
| 8 | Visual | Add alarm suppression / maintenance window toggle | Medium |
| 9 | Info | Root cause correlation — surface linked alarms (e.g. rack temp high + CRAC fan low) | Medium |
---
## Assets (`/assets`)
| # | Type | Improvement | Priority |
|---|------|-------------|----------|
| 1 | Sensor | Per-device power draw from PDU outlet monitoring (not estimated) | High |
| 2 | Sensor | Server inlet temperature from IPMI/iDRAC per device | High |
| 3 | Sensor | Add PDUs as asset type with per-outlet monitoring | High |
| 4 | Sensor | Network device status (switch uptime, port count, active links) | Medium |
| 5 | Visual | Inventory table: add sortable columns (currently unsortable) | High |
| 6 | Visual | Add rack elevation diagram (visual U-space view) in RackDetailSheet | High |
| 7 | Visual | Add device age / warranty expiry column in inventory | Medium |
| 8 | Info | Add DCIM-style lifecycle status — Active / Decomm / Planned | Low |
| 9 | Info | Add asset import/export (CSV) for CMDB sync | Medium |
---
## Reports (`/reports`)
| # | Type | Improvement | Priority |
|---|------|-------------|----------|
| 1 | Sensor | Add energy cost report — kWh, estimated cost at tariff, month-to-date | High |
| 2 | Visual | Add PUE trend graph — 30-day rolling PUE vs. target | High |
| 3 | Visual | Add cooling efficiency (kW IT / kW cooling) over time | Medium |
| 4 | Visual | Add alarm MTTR and alarm volume trend per week | Medium |
| 5 | Info | Add scheduled report configuration — email PDF daily/weekly | Medium |
| 6 | Info | Add comparison period — this week vs. last week | Medium |
| 7 | Info | Add sustainability section — CO2e, renewable fraction, WUE | Low |
| 8 | Info | Add SLA compliance section — uptime %, incidents, breach risk | Medium |
| 9 | Info | Expand CSV exports: PDU branch data, CRAC detailed logs, humidity history | Medium |
---
## New Pages to Build
| Page | Description | Priority |
|------|-------------|----------|
| Generator & Power Path | ATS status, generator load, fuel level, transfer switch history | High |
| Leak Detection | Site-wide leak sensor map, sensor status, historical events | High |
| Fire & Life Safety | VESDA levels, smoke detector zones, suppression system status | High |
| Network Infrastructure | Core/edge switch health, port utilisation, link status | Medium |
| Energy & Sustainability | kWh cost, PUE trend, CO2e, WUE | Medium |
| Maintenance | Planned outages, maintenance windows, alarm suppression | Low |

112
README.md Normal file
View file

@ -0,0 +1,112 @@
# DemoBMS
Intelligent Data Center Infrastructure Management platform.
## Stack
- **Frontend:** Next.js 16 + TypeScript + shadcn/ui + Recharts
- **Backend:** Python FastAPI
- **Database:** PostgreSQL + TimescaleDB
- **Auth:** Clerk
- **Runtime:** Docker Compose
---
## Ports
| Service | Port |
|----------|------|
| Frontend | 5646 |
| Backend | 8000 (internal — not exposed publicly) |
| Database | 5432 (internal) |
The frontend is the only service that needs to be reachable. Point your reverse proxy at port **5646**.
---
## API Calls & Reverse Proxy
The frontend never hardcodes a backend hostname. All API calls use the relative path `/api/backend/*`, which Next.js rewrites to the backend on the internal Docker network (`BACKEND_INTERNAL_URL`). From the browser's perspective everything is same-origin — your reverse proxy only needs to forward to port 5646.
```
Browser → Reverse Proxy → :5646 (Next.js)
↓ server-side rewrite
:8000 (FastAPI) — internal only
```
---
## Quick Start
### 1. Configure Clerk (required for auth)
Create a free account at https://clerk.com, create an application, then fill in the keys:
**`frontend/.env.local`**
```
NEXT_PUBLIC_CLERK_PUBLISHABLE_KEY=pk_test_...
CLERK_SECRET_KEY=sk_test_...
```
**`backend/.env`**
```
CLERK_SECRET_KEY=sk_test_...
CLERK_JWKS_URL=https://your-app.clerk.accounts.dev/.well-known/jwks.json
```
### 2. Run with Docker Compose
```bash
docker compose up --build
```
- Frontend: http://your-server:5646
- API Docs: http://your-server:5646/api/backend/docs
### 3. Run locally (development)
**Frontend**
```bash
cd frontend
pnpm install
pnpm dev --port 5646
```
**Backend**
```bash
cd backend
python -m venv .venv
source .venv/bin/activate # Windows: .venv\Scripts\activate
pip install -r requirements.txt
uvicorn main:app --reload
```
**Database only (via Docker)**
```bash
docker compose up db
```
---
## Project Structure
```
/bms
/frontend Next.js app
/backend FastAPI app
/simulators Sensor bots (Phase 2)
docker-compose.yml
```
## Phase Progress
- [x] Phase 1 — Foundation (current)
- [ ] Phase 2 — Data Pipeline & Simulator Bots
- [ ] Phase 3 — Core Dashboard (live data)
- [ ] Phase 4 — Environmental Monitoring
- [ ] Phase 5 — Power Management
- [ ] Phase 6 — Cooling & AI Panel
- [ ] Phase 7 — Asset Management
- [ ] Phase 8 — Alarms & Events
- [ ] Phase 9 — Reports
- [ ] Phase 10 — Polish & Hardening

367
UI_UX_PLAN.md Normal file
View file

@ -0,0 +1,367 @@
# DemoBMS — UI/UX Improvement Plan
> Generated from full page-by-page review (16 pages + shared layout).
> Work through phases in order — each phase builds on the previous.
---
## Phase 1 — Foundation Fixes
**Goal:** Fix broken/incomplete global chrome and shared infrastructure before touching individual pages.
**Effort:** ~12 days | **Risk:** Low
### 1.1 Topbar title map — complete it
- Add missing entries for: generator, leak, fire, network, energy, maintenance
- Replace pathname-based map with a route config object shared with sidebar
### 1.2 Topbar — replace placeholder avatar with Clerk UserButton
- Swap the blue circle for `<UserButton />` from `@clerk/nextjs`
- Removes the hardcoded placeholder and provides sign-out, profile link for free
### 1.3 Topbar — remove or disable the site selector
- The dropdown is hardcoded with 3 fake sites and does nothing
- Either wire it to real site context, or remove it entirely until it's real
- A non-functional control erodes trust
### 1.4 Topbar — consolidate the alarm badge
- Remove the alarm count badge from the sidebar nav item
- Keep it only on the topbar bell icon (single canonical location)
### 1.5 Sidebar — add section groupings with dividers
- Split 15 nav items into labelled groups:
- **OVERVIEW** — Dashboard, Floor Map
- **INFRASTRUCTURE** — Power, Generator, Cooling, Environmental
- **SAFETY** — Leak Detection, Fire & Safety, Network
- **OPERATIONS** — Assets, Alarms, Capacity
- **MANAGEMENT** — Reports, Energy & CO₂, Maintenance, Settings
- Render section headers as small ALL-CAPS muted labels with a horizontal rule above
- Collapsed sidebar: show only icons, hide section headers
### 1.6 Sidebar — move collapse toggle to bottom
- Currently the toggle is an absolutely-positioned button that's easy to miss
- Move it to the bottom of the sidebar as a regular icon button above Settings
- Add a tooltip: "Collapse menu" / "Expand menu"
### 1.7 Centralise threshold constants
- Create `/lib/thresholds.ts` exporting a single const object:
```ts
export const THRESHOLDS = {
temp: { warn: 26, critical: 28 },
humidity: { warn: 65, critical: 80 },
power: { warn: 0.75, critical: 0.85 }, // fraction of capacity
filter: { warn: 80, critical: 120 }, // Pa
compressor: { warn: 0.80, critical: 0.95 },
battery: { warn: 30, critical: 20 },
fuel: { warn: 30, critical: 15 },
}
```
- Replace all hardcoded threshold values across every page with imports from this file
### 1.8 Global spacing standardisation
- Audit and enforce: all page wrappers use `space-y-6`, all cards use `p-6`, all card headers use `pb-3`
- Create a `<PageShell title="">` wrapper component used by every page to ensure consistent top padding and title rendering
### 1.9 Empty state and error boundary
- Create a reusable `<ErrorCard message="">` component shown when a fetch fails
- Create a reusable `<EmptyCard message="">` for no-data states
- Wrap every data-dependent card in a try/catch that renders `<ErrorCard>` instead of crashing
- Add a top-level React error boundary in the dashboard layout
---
## Phase 2 — Alarms Page
**Goal:** Make the alarm page genuinely usable under operational pressure.
**Effort:** ~1 day | **Risk:** Low
### 2.1 Sticky filter bar
- Make the filter row (state tabs + severity dropdown) `sticky top-0 z-10` so it stays visible while scrolling through long alarm lists
### 2.2 Bulk action at top
- Move the "Bulk Resolve" button into the filter/action row at the top, not below the table
- Add a "Select All" checkbox in the table header
### 2.3 Swap Critical stat card for Avg Age
- Replace the "Critical" count card with "Avg Age" — the mean time alarms have been open
- Display as "Xh Ym" format
- Colour red if avg age > 1h, amber if > 15 min, green if < 15 min
### 2.4 Column priority on small screens
- On mobile/tablet, keep: Severity | Message | Escalation timer | Actions
- Drop: Sensor ID, State (already conveyed by row colour)
- Escalation timer must always be visible — it is the most operationally critical column
### 2.5 Pagination
- Add simple page controls: Previous / Page X of Y / Next
- Default page size: 25 rows
- Show total count above the table: "Showing 125 of 142 alarms"
### 2.6 Embed sparkline in stat cards
- Remove the standalone 24-hour sparkline chart
- Embed a micro line chart (24 data points, 40px tall) inside each stat card below the number
- Net result: less vertical space used, same information
---
## Phase 3 — Dashboard Page
**Goal:** Reduce clutter, improve at-a-glance legibility.
**Effort:** ~1 day | **Risk:** Low
### 3.1 Uniform KPI card grid
- Standardise all 6 KPI cards to the same height and same layout template
- Use a 3×2 grid (3 cols on lg, 2 on md, 1 on sm) consistently
### 3.2 Replace mini floor map
- The mini floor map widget is too small to be useful
- Replace with a "Room Quick Status" card:
- Two rows: Hall A / Hall B
- Each row: health badge (OK / Warning / Critical), avg temp, total power, CRAC state
- Link to /floor-map for full view
### 3.3 Data freshness pill in topbar
- Move the "last updated" indicator from the dashboard page into the topbar (right side, before the bell)
- Make it global — show the last successful API poll timestamp for any page
- Colour: green if < 60s, amber if 60120s, red if > 120s (stale)
### 3.4 Alarm feed / Room status layout
- Change the bottom row from 2-col + 1-col to 50/50
- Alarm Feed: left panel
- Room Quick Status: right panel (replaces mini floor map)
---
## Phase 4 — Power Page
**Goal:** Tame the long scroll, improve information hierarchy.
**Effort:** ~1.5 days | **Risk:** LowMedium
### 4.1 Page-internal anchor navigation
- Add a sticky sub-nav bar below the topbar with anchor links:
`Site Overview | UPS | Generator | Transfer Switch | Phase Analysis`
- Each section gets an `id=""` and smooth-scroll on click
### 4.2 Power path diagram
- Add a simple horizontal flow diagram at the top of the page:
`Grid → [ATS: feed A/B] → [UPS: battery/online] → Rack Distribution`
- Use coloured nodes (green = live, amber = degraded, red = fault)
- No library needed — a flex row of icon + connector line components
### 4.3 Always-visible Phase Summary card
- Show a collapsed "Phase Balance" summary card at all times (Phase A / B / C current kW in a 3-col grid)
- Expand to full Phase Imbalance Table on click or if violations exist
### 4.4 Per-rack bar chart — full width
- The per-rack chart needs more horizontal room to show 10 bars legibly
- Move it to full width above the history chart (stack them, not side by side)
---
## Phase 5 — Cooling Page
**Goal:** Reduce card density, surface critical maintenance items earlier.
**Effort:** ~1 day | **Risk:** Low
### 5.1 Standardise fleet summary bar
- Replace the horizontal KPI tile flex row with proper KPI cards matching dashboard style
### 5.2 Promote filter replacement alert
- If any CRAC unit is within 14 days of filter replacement threshold, show a dismissible alert banner at the top of the page
- Move the Predictive Filter Replacement card to the top of the page (above CRAC cards)
### 5.3 CRAC card — progressive disclosure
- The current CRAC card has 6 stacked sections
- Keep: thermal hero (supply/return/ΔT), capacity bar, fan speed
- Collapse into a "Details" accordion: compressor pressures + electrical readings
- The thermal hero section should be 30% larger — it is the most important readout
### 5.4 Thermal hero — increase visual weight
- Increase supply/return temp font to text-3xl
- ΔT value in a coloured pill (green/amber/red based on threshold)
- Add a small up/down arrow showing trend (last 5 min)
---
## Phase 6 — Environmental Page
**Goal:** Unify interactive state across sections, reduce redundancy.
**Effort:** ~1 day | **Risk:** Low
### 6.1 Shared room tab state
- All sections on the page (heatmap, dew point, trend chart) should react to a single room tab selector at the top of the page, not per-section tabs
- Add one prominent "Hall A / Hall B" tab switcher at the page level
### 6.2 Dual-axis chart — clarify axes
- Add unit labels on the Y-axis: left axis "Temperature (°C)", right axis "Humidity (%)"
- Change the humidity line to a dashed style (already done) but also add a subtle fill under it to visually distinguish it from the temperature line
- Add a brief legend note: "Shaded areas = ASHRAE A1 safe zone"
### 6.3 VESDA and Leak panels — link to dedicated pages
- Label both panels clearly as "Summary — see Leak Detection / Fire & Safety for detail"
- Add a "View full page →" link in each panel header
- This avoids duplicating full detail here
---
## Phase 7 — Floor Map Page
**Goal:** Improve overlay controls and map legibility.
**Effort:** ~1 day | **Risk:** LowMedium
### 7.1 Overlay controls — proper segmented control
- Replace the 4 overlay buttons with a shadcn `<Tabs>` style segmented control
- Active tab: filled background, not just a subtle border change
### 7.2 Hot/cold aisle labels — improve visibility
- Increase aisle divider label font size and weight
- Add icon: 🔴 Hot Aisle / 🔵 Cold Aisle (or Lucide Flame / Snowflake)
- Increase divider bar height slightly
### 7.3 Rack tile — secondary metric on hover only
- In Temperature overlay: show °C as main value; power bar appears on hover tooltip only
- In Power overlay: show % as main value; temp appears in tooltip
- Reduces visual clutter in the tile grid
### 7.4 CRAC strip — move above rack grid
- The CRAC strip is currently below the rack grid and easy to miss
- Move it above the grid with a stronger visual separator (border + label "Cooling Unit")
### 7.5 Leak sensor panel — add zone labels
- Add a brief location description to each sensor: "Hall A — under floor, near CRAC" etc.
- Use consistent zone label chips rather than free text
---
## Phase 8 — Assets Page
**Goal:** Make each tab genuinely useful and distinct from other pages.
**Effort:** ~1 day | **Risk:** Low
### 8.1 Rack Grid tab → Rack Summary Table
- Replace the visual rack grid (duplicates Floor Map) with a sortable table:
- Columns: Rack ID | Room | Temp (°C) | Power (kW) | Power % | Alarms | Status
- Sortable by any column
- Row click opens RackDetailSheet
### 8.2 Device List — sort headers
- Add click-to-sort on every column header with a sort direction indicator (chevron icon)
- Default sort: Type then Name
### 8.3 Device type legend
- Add a compact colour legend row above the device table explaining the dot colours
- One row of: ● Server ● Switch ● PDU ● Storage ● Firewall ● KVM
### 8.4 CRAC / UPS cards → inventory rows
- Replace the large card components with compact inventory table rows:
- ID | Type | Status | Room | Rack | Last Maintenance
- Link to full detail on click (CracDetailSheet / UpsCard modal)
---
## Phase 9 — Remaining Pages
**Goal:** Individual page improvements that don't interact with other phases.
**Effort:** ~1.5 days | **Risk:** Low
### 9.1 Generator page — add fuel runtime estimate
- Add "Est. runtime at current load: X hours" as the hero stat in each generator card
- Show a small fuel consumption trend chart (last 24h) if data is available
- Clearly differentiate this page from the generator section in Power page by adding: last start log, maintenance schedule table
### 9.2 Capacity page — radial gauge values
- Ensure a large centered text value (e.g. "74%") is always visible inside the gauge arc
- Add "Headroom: X kW" as a sub-label below the gauge
### 9.3 Capacity page — runway in months
- Display runway as "~2.3 months" alongside weeks
- Add a 90-day forecast line on the per-rack chart (dotted line extrapolating current growth)
### 9.4 Fire & Safety page — fire state improvements
- Fire-level cards: increase border to 4px, add a very faint red background overlay (`bg-red-950/30`)
- Replace small status dots with a proper status row: icon + label + state text (e.g. ✓ Detector 1 — Online)
- Show raw obscuration value (%/m) on the bar, not just the bar fill
### 9.5 Leak Detection page — add history
- Add "Last triggered: X days ago" to each sensor card
- Add a 30-day trigger count badge: "0 events" / "3 events"
- This keeps the page useful when all sensors are clear
### 9.6 Network page — improve port display
- Change port headline from "72%" to "36 / 48 ports active" — then show % as sub-label
- Group card metrics: top 3 bold (state, ports, bandwidth) + secondary row (CPU, memory, temp)
### 9.7 Reports page — promote export to top
- Move the 3 export buttons to a prominent action bar at the top of the page
- Add a page-level date range picker that controls all KPIs on the page simultaneously
- Always show numeric % labels on CRAC/UPS uptime bars
### 9.8 Maintenance page — modal for create form
- Move "Create window" form into a shadcn `<Dialog>` modal triggered by the header button
- In the target selector, group options: Site / Hall A racks / Hall B racks / Cooling / Power
- Add a 7-day horizontal timeline strip below the active windows list showing when each window falls
### 9.9 Settings page — skeleton structure
- Replace "Coming soon" with a tabbed layout: Profile | Notifications | Thresholds | Site Config
- Thresholds tab: shows the values from `lib/thresholds.ts` as editable fields (even if not persisted to backend yet)
- This makes the page look intentional rather than unfinished
---
## Phase 10 — Polish & Accessibility
**Goal:** Final consistency pass, mobile, keyboard navigation.
**Effort:** ~12 days | **Risk:** Low
### 10.1 Mobile audit
- Test every page at 375px and 768px width
- Fix broken chart widths (use `ResponsiveContainer` everywhere, check it's set)
- Ensure touch targets are ≥44px tall
- Test sidebar sheet on mobile
### 10.2 Focus rings and keyboard nav
- Add `focus-visible:ring-2 focus-visible:ring-primary` to all interactive elements that are missing it
- Verify logical tab order on every page (left-to-right, top-to-bottom)
- Add `aria-label` to icon-only buttons (alarm bell, collapse toggle, overlay buttons)
### 10.3 Chart skeleton height matching
- Measure actual rendered chart heights for each chart type
- Set skeleton `h-[]` to match exactly, preventing layout shift on load
### 10.4 Dark/light mode toggle
- Add a theme toggle button in the topbar (Moon / Sun icon)
- The ThemeProvider is already wired — just needs a toggle button and `localStorage` persistence
### 10.5 Loading state audit
- Every card that fetches data must show a `<Skeleton>` during initial load
- No card should show an empty white box or flash unstyled content
### 10.6 Toast notification consistency
- Audit all `toast.error()` / `toast.success()` calls across pages
- Ensure every user action (acknowledge alarm, bulk resolve, create maintenance window, export) has a corresponding success/error toast
---
## Summary Table
| Phase | Focus | Pages Touched | Est. Effort |
|-------|-------|---------------|-------------|
| 1 | Foundation fixes (nav, thresholds, errors) | All (shared) | 12 days |
| 2 | Alarms page | Alarms | 1 day |
| 3 | Dashboard page | Dashboard | 1 day |
| 4 | Power page | Power | 1.5 days |
| 5 | Cooling page | Cooling | 1 day |
| 6 | Environmental page | Environmental | 1 day |
| 7 | Floor Map page | Floor Map | 1 day |
| 8 | Assets page | Assets | 1 day |
| 9 | Remaining pages | Generator, Capacity, Fire, Leak, Network, Reports, Maintenance, Settings | 1.5 days |
| 10 | Polish & accessibility | All | 12 days |
| **Total** | | | **~1113 days** |
---
## Dependency Order
```
Phase 1 (foundation)
└─► Phase 2 (alarms)
└─► Phase 3 (dashboard)
└─► Phase 4 (power)
└─► Phase 5 (cooling)
└─► Phase 6 (environmental)
└─► Phase 7 (floor map)
└─► Phase 8 (assets)
└─► Phase 9 (remaining pages)
└─► Phase 10 (polish — last, after all pages stable)
```
Phases 29 are independent of each other and can be parallelised once Phase 1 is complete.

17
backend/Dockerfile Normal file
View file

@ -0,0 +1,17 @@
FROM python:3.12-slim
WORKDIR /app
# curl needed for Docker healthcheck
RUN apt-get update && apt-get install -y --no-install-recommends curl && rm -rf /var/lib/apt/lists/*
# Install Python dependencies
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
# Copy app code
COPY . .
EXPOSE 8000
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"]

0
backend/__init__.py Normal file
View file

0
backend/api/__init__.py Normal file
View file

View file

View file

@ -0,0 +1,82 @@
from fastapi import APIRouter, Depends, Query, HTTPException
from sqlalchemy import text
from sqlalchemy.ext.asyncio import AsyncSession
from core.database import get_session
router = APIRouter()
@router.get("")
async def list_alarms(
site_id: str = Query(...),
state: str = Query("active", description="active | resolved | acknowledged | all"),
limit: int = Query(50, ge=1, le=200),
session: AsyncSession = Depends(get_session),
):
where = "WHERE site_id = :site_id"
if state != "all":
where += " AND state = :state"
result = await session.execute(text(f"""
SELECT id, sensor_id, site_id, room_id, rack_id,
severity, message, state, triggered_at,
acknowledged_at, resolved_at
FROM alarms
{where}
ORDER BY triggered_at DESC
LIMIT :limit
"""), {"site_id": site_id, "state": state, "limit": limit})
return [dict(r) for r in result.mappings().all()]
@router.post("/{alarm_id}/acknowledge")
async def acknowledge_alarm(
alarm_id: int,
session: AsyncSession = Depends(get_session),
):
result = await session.execute(text("""
UPDATE alarms
SET state = 'acknowledged', acknowledged_at = NOW()
WHERE id = :id AND state = 'active'
RETURNING id
"""), {"id": alarm_id})
await session.commit()
if not result.fetchone():
raise HTTPException(status_code=404, detail="Alarm not found or not active")
return {"id": alarm_id, "state": "acknowledged"}
@router.post("/{alarm_id}/resolve")
async def resolve_alarm(
alarm_id: int,
session: AsyncSession = Depends(get_session),
):
result = await session.execute(text("""
UPDATE alarms
SET state = 'resolved', resolved_at = NOW()
WHERE id = :id AND state IN ('active', 'acknowledged')
RETURNING id
"""), {"id": alarm_id})
await session.commit()
if not result.fetchone():
raise HTTPException(status_code=404, detail="Alarm not found or already resolved")
return {"id": alarm_id, "state": "resolved"}
@router.get("/stats")
async def alarm_stats(
site_id: str = Query(...),
session: AsyncSession = Depends(get_session),
):
result = await session.execute(text("""
SELECT
COUNT(*) FILTER (WHERE state = 'active') AS active,
COUNT(*) FILTER (WHERE state = 'acknowledged') AS acknowledged,
COUNT(*) FILTER (WHERE state = 'resolved') AS resolved,
COUNT(*) FILTER (WHERE state = 'active' AND severity = 'critical') AS critical,
COUNT(*) FILTER (WHERE state = 'active' AND severity = 'warning') AS warning
FROM alarms
WHERE site_id = :site_id
"""), {"site_id": site_id})
row = result.mappings().one()
return {k: int(v) for k, v in row.items()}

View file

@ -0,0 +1,344 @@
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

View file

@ -0,0 +1,110 @@
from fastapi import APIRouter, Depends, Query
from sqlalchemy import text
from sqlalchemy.ext.asyncio import AsyncSession
from core.database import get_session
router = APIRouter()
ROOMS = {
"sg-01": [
{"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"},
]
}
# Rated capacity config — would be per-asset configurable in production
RACK_POWER_CAPACITY_KW = 10.0 # max kW per rack
ROOM_POWER_CAPACITY_KW = 400.0 # 40 racks × 10 kW
CRAC_COOLING_CAPACITY_KW = 160.0 # rated cooling per CRAC
RACK_U_TOTAL = 42
@router.get("/summary")
async def capacity_summary(
site_id: str = Query(...),
session: AsyncSession = Depends(get_session),
):
"""Per-rack and per-room capacity: power used vs rated, cooling load vs rated, rack space."""
result = await session.execute(text("""
SELECT DISTINCT ON (sensor_id)
rack_id, room_id, sensor_type, value
FROM readings
WHERE site_id = :site_id
AND sensor_type IN ('power_kw', 'temperature')
AND rack_id IS NOT NULL
AND recorded_at > NOW() - INTERVAL '10 minutes'
ORDER BY sensor_id, recorded_at DESC
"""), {"site_id": site_id})
rows = result.mappings().all()
# Index: rack_id → {power_kw, temperature, room_id}
rack_idx: dict[str, dict] = {}
for row in rows:
rid = row["rack_id"]
if rid not in rack_idx:
rack_idx[rid] = {"room_id": row["room_id"]}
if row["sensor_type"] == "power_kw":
rack_idx[rid]["power_kw"] = round(float(row["value"]), 2)
elif row["sensor_type"] == "temperature":
rack_idx[rid]["temperature"] = round(float(row["value"]), 1)
rooms_out = []
racks_out = []
for room in ROOMS.get(site_id, []):
room_id = room["room_id"]
room_power = 0.0
populated = 0
for rack_id in room["racks"]:
d = rack_idx.get(rack_id, {})
power = d.get("power_kw")
temp = d.get("temperature")
if power is not None:
room_power += power
populated += 1
power_pct = round((power / RACK_POWER_CAPACITY_KW) * 100, 1) if power is not None else None
racks_out.append({
"rack_id": rack_id,
"room_id": room_id,
"power_kw": power,
"power_capacity_kw": RACK_POWER_CAPACITY_KW,
"power_pct": power_pct,
"temp": temp,
})
room_power = round(room_power, 2)
rooms_out.append({
"room_id": room_id,
"power": {
"used_kw": room_power,
"capacity_kw": ROOM_POWER_CAPACITY_KW,
"pct": round((room_power / ROOM_POWER_CAPACITY_KW) * 100, 1),
"headroom_kw": round(ROOM_POWER_CAPACITY_KW - room_power, 2),
},
"cooling": {
"load_kw": room_power, # IT power ≈ heat generated
"capacity_kw": CRAC_COOLING_CAPACITY_KW,
"pct": round(min(100.0, (room_power / CRAC_COOLING_CAPACITY_KW) * 100), 1),
"headroom_kw": round(max(0.0, CRAC_COOLING_CAPACITY_KW - room_power), 2),
},
"space": {
"racks_total": len(room["racks"]),
"racks_populated": populated,
"pct": round((populated / len(room["racks"])) * 100, 1),
},
})
return {
"site_id": site_id,
"config": {
"rack_power_kw": RACK_POWER_CAPACITY_KW,
"room_power_kw": ROOM_POWER_CAPACITY_KW,
"crac_cooling_kw": CRAC_COOLING_CAPACITY_KW,
"rack_u_total": RACK_U_TOTAL,
},
"rooms": rooms_out,
"racks": racks_out,
}

View file

@ -0,0 +1,131 @@
from datetime import datetime, timezone, timedelta
from fastapi import APIRouter, Depends, Query
from sqlalchemy import text
from sqlalchemy.ext.asyncio import AsyncSession
from core.database import get_session
router = APIRouter()
CHILLERS = {"sg-01": ["chiller-01"]}
CHILLER_FIELD_MAP = {
"chiller_chw_supply": "chw_supply_c",
"chiller_chw_return": "chw_return_c",
"chiller_chw_delta": "chw_delta_c",
"chiller_flow_gpm": "flow_gpm",
"chiller_load_kw": "cooling_load_kw",
"chiller_load_pct": "cooling_load_pct",
"chiller_cop": "cop",
"chiller_comp_load": "compressor_load_pct",
"chiller_cond_press": "condenser_pressure_bar",
"chiller_evap_press": "evaporator_pressure_bar",
"chiller_cw_supply": "cw_supply_c",
"chiller_cw_return": "cw_return_c",
"chiller_run_hours": "run_hours",
}
@router.get("/status")
async def chiller_status(
site_id: str = Query(...),
session: AsyncSession = Depends(get_session),
):
"""Latest chiller plant readings."""
types_sql = ", ".join(f"'{t}'" for t in [*CHILLER_FIELD_MAP.keys(), "chiller_state"])
result = await session.execute(text(f"""
SELECT DISTINCT ON (sensor_id)
sensor_id, sensor_type, value
FROM readings
WHERE site_id = :site_id
AND sensor_type IN ({types_sql})
AND recorded_at > NOW() - INTERVAL '5 minutes'
ORDER BY sensor_id, recorded_at DESC
"""), {"site_id": site_id})
chiller_data: dict[str, dict] = {}
for row in result.mappings().all():
parts = row["sensor_id"].split("/")
# sensor_id: {site}/{cooling/chiller}/{chiller_id}/{key} → parts[3]
if len(parts) < 4:
continue
chiller_id = parts[3]
if chiller_id not in chiller_data:
chiller_data[chiller_id] = {"chiller_id": chiller_id}
field = CHILLER_FIELD_MAP.get(row["sensor_type"])
if field:
chiller_data[chiller_id][field] = round(float(row["value"]), 2)
elif row["sensor_type"] == "chiller_state":
chiller_data[chiller_id]["state"] = "online" if float(row["value"]) > 0.5 else "fault"
out = []
for chiller_id in CHILLERS.get(site_id, []):
d = chiller_data.get(chiller_id, {"chiller_id": chiller_id, "state": "unknown"})
d.setdefault("state", "online")
out.append(d)
return out
@router.get("/history")
async def chiller_history(
site_id: str = Query(...),
chiller_id: str = Query(...),
hours: int = Query(6, ge=1, le=24),
session: AsyncSession = Depends(get_session),
):
"""Time-series COP, load kW, and CHW temps for a chiller."""
from_time = datetime.now(timezone.utc) - timedelta(hours=hours)
METRICS = ("chiller_cop", "chiller_load_kw", "chiller_load_pct",
"chiller_chw_supply", "chiller_chw_return", "chiller_comp_load")
types_sql = ", ".join(f"'{t}'" for t in METRICS)
try:
result = await session.execute(text(f"""
SELECT
time_bucket('5 minutes', recorded_at) AS bucket,
sensor_type,
ROUND(AVG(value)::numeric, 3) AS avg_val
FROM readings
WHERE site_id = :site_id
AND sensor_id LIKE :pattern
AND sensor_type IN ({types_sql})
AND recorded_at > :from_time
GROUP BY bucket, sensor_type
ORDER BY bucket ASC
"""), {"site_id": site_id,
"pattern": f"{site_id}/cooling/chiller/{chiller_id}/%",
"from_time": from_time})
except Exception:
result = await session.execute(text(f"""
SELECT
date_trunc('minute', recorded_at) AS bucket,
sensor_type,
ROUND(AVG(value)::numeric, 3) AS avg_val
FROM readings
WHERE site_id = :site_id
AND sensor_id LIKE :pattern
AND sensor_type IN ({types_sql})
AND recorded_at > :from_time
GROUP BY bucket, sensor_type
ORDER BY bucket ASC
"""), {"site_id": site_id,
"pattern": f"{site_id}/cooling/chiller/{chiller_id}/%",
"from_time": from_time})
bucket_map: dict[str, dict] = {}
for row in result.mappings().all():
b = str(row["bucket"])
if b not in bucket_map:
bucket_map[b] = {"bucket": b}
bucket_map[b][row["sensor_type"]] = float(row["avg_val"])
points = []
for b, vals in sorted(bucket_map.items()):
points.append({
"bucket": b,
"cop": vals.get("chiller_cop"),
"load_kw": vals.get("chiller_load_kw"),
"load_pct": vals.get("chiller_load_pct"),
"chw_supply_c": vals.get("chiller_chw_supply"),
"chw_return_c": vals.get("chiller_chw_return"),
"comp_load": vals.get("chiller_comp_load"),
})
return points

440
backend/api/routes/env.py Normal file
View file

@ -0,0 +1,440 @@
from datetime import datetime, timezone, timedelta
from fastapi import APIRouter, Depends, Query
from sqlalchemy import text
from sqlalchemy.ext.asyncio import AsyncSession
from core.database import get_session
router = APIRouter()
ROOMS = {
"sg-01": [
{"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"},
]
}
@router.get("/rack-readings")
async def rack_env_readings(
site_id: str = Query(...),
session: AsyncSession = Depends(get_session),
):
"""Latest temperature and humidity per rack, grouped by room."""
result = await session.execute(text("""
SELECT DISTINCT ON (sensor_id)
rack_id, room_id, sensor_type, value
FROM readings
WHERE site_id = :site_id
AND sensor_type IN ('temperature', 'humidity')
AND rack_id IS NOT NULL
AND recorded_at > NOW() - INTERVAL '10 minutes'
ORDER BY sensor_id, recorded_at DESC
"""), {"site_id": site_id})
rows = result.mappings().all()
# Index by (rack_id, sensor_type)
data: dict[tuple, float] = {(r["rack_id"], r["sensor_type"]): float(r["value"]) for r in rows}
rooms = []
for room in ROOMS.get(site_id, []):
racks = []
for rack_id in room["racks"]:
temp = data.get((rack_id, "temperature"))
hum = data.get((rack_id, "humidity"))
racks.append({
"rack_id": rack_id,
"temperature": round(temp, 1) if temp is not None else None,
"humidity": round(hum, 1) if hum is not None else None,
})
rooms.append({"room_id": room["room_id"], "racks": racks})
return rooms
@router.get("/humidity-history")
async def humidity_history(
site_id: str = Query(...),
hours: int = Query(6, ge=1, le=24),
session: AsyncSession = Depends(get_session),
):
"""Average humidity per room bucketed by 5 minutes."""
from_time = datetime.now(timezone.utc) - timedelta(hours=hours)
try:
result = await session.execute(text("""
SELECT bucket, room_id, ROUND(AVG(avg_per_rack)::numeric, 1) AS avg_humidity
FROM (
SELECT
time_bucket('5 minutes', recorded_at) AS bucket,
sensor_id, room_id,
AVG(value) AS avg_per_rack
FROM readings
WHERE site_id = :site_id
AND sensor_type = 'humidity'
AND room_id IS NOT NULL
AND recorded_at > :from_time
GROUP BY bucket, sensor_id, room_id
) per_rack
GROUP BY bucket, room_id
ORDER BY bucket ASC
"""), {"site_id": site_id, "from_time": from_time})
except Exception:
result = await session.execute(text("""
SELECT bucket, room_id, ROUND(AVG(avg_per_rack)::numeric, 1) AS avg_humidity
FROM (
SELECT
date_trunc('minute', recorded_at) AS bucket,
sensor_id, room_id,
AVG(value) AS avg_per_rack
FROM readings
WHERE site_id = :site_id
AND sensor_type = 'humidity'
AND room_id IS NOT NULL
AND recorded_at > :from_time
GROUP BY bucket, sensor_id, room_id
) per_rack
GROUP BY bucket, room_id
ORDER BY bucket ASC
"""), {"site_id": site_id, "from_time": from_time})
return [dict(r) for r in result.mappings().all()]
# All CRAC sensor types stored in the readings table
CRAC_SENSOR_TYPES = (
"cooling_supply", "cooling_return", "cooling_fan",
"cooling_supply_hum", "cooling_return_hum", "cooling_airflow", "cooling_filter_dp",
"cooling_cap_kw", "cooling_cap_pct", "cooling_cop", "cooling_shr",
"cooling_comp_state", "cooling_comp_load", "cooling_comp_power", "cooling_comp_hours",
"cooling_high_press", "cooling_low_press", "cooling_superheat", "cooling_subcooling",
"cooling_fan_rpm", "cooling_fan_power", "cooling_fan_hours",
"cooling_unit_power", "cooling_voltage", "cooling_current", "cooling_pf",
)
# sensor_type → response field name
CRAC_FIELD_MAP = {
"cooling_supply": "supply_temp",
"cooling_return": "return_temp",
"cooling_fan": "fan_pct",
"cooling_supply_hum": "supply_humidity",
"cooling_return_hum": "return_humidity",
"cooling_airflow": "airflow_cfm",
"cooling_filter_dp": "filter_dp_pa",
"cooling_cap_kw": "cooling_capacity_kw",
"cooling_cap_pct": "cooling_capacity_pct",
"cooling_cop": "cop",
"cooling_shr": "sensible_heat_ratio",
"cooling_comp_state": "compressor_state",
"cooling_comp_load": "compressor_load_pct",
"cooling_comp_power": "compressor_power_kw",
"cooling_comp_hours": "compressor_run_hours",
"cooling_high_press": "high_pressure_bar",
"cooling_low_press": "low_pressure_bar",
"cooling_superheat": "discharge_superheat_c",
"cooling_subcooling": "liquid_subcooling_c",
"cooling_fan_rpm": "fan_rpm",
"cooling_fan_power": "fan_power_kw",
"cooling_fan_hours": "fan_run_hours",
"cooling_unit_power": "total_unit_power_kw",
"cooling_voltage": "input_voltage_v",
"cooling_current": "input_current_a",
"cooling_pf": "power_factor",
}
RATED_CAPACITY_KW = 80.0
@router.get("/crac-status")
async def crac_status(
site_id: str = Query(...),
session: AsyncSession = Depends(get_session),
):
"""Latest CRAC readings — full sensor set."""
types_sql = ", ".join(f"'{t}'" for t in CRAC_SENSOR_TYPES)
result = await session.execute(text(f"""
SELECT DISTINCT ON (sensor_id)
sensor_id, sensor_type, value
FROM readings
WHERE site_id = :site_id
AND sensor_type IN ({types_sql})
AND recorded_at > NOW() - INTERVAL '10 minutes'
ORDER BY sensor_id, recorded_at DESC
"""), {"site_id": site_id})
crac_data: dict[str, dict] = {}
for row in result.mappings().all():
parts = row["sensor_id"].split("/")
if len(parts) < 3:
continue
crac_id = parts[2]
if crac_id not in crac_data:
crac_data[crac_id] = {"crac_id": crac_id}
field = CRAC_FIELD_MAP.get(row["sensor_type"])
if field:
crac_data[crac_id][field] = round(float(row["value"]), 3)
room_map = {room["crac_id"]: room["room_id"] for room in ROOMS.get(site_id, [])}
result_list = []
for crac_id, d in sorted(crac_data.items()):
supply = d.get("supply_temp")
ret = d.get("return_temp")
delta = round(ret - supply, 1) if (ret is not None and supply is not None) else None
state = "online" if supply is not None else "fault"
result_list.append({
"crac_id": crac_id,
"room_id": room_map.get(crac_id),
"state": state,
"delta": delta,
"rated_capacity_kw": RATED_CAPACITY_KW,
**{k: round(v, 2) if isinstance(v, float) else v for k, v in d.items() if k != "crac_id"},
})
# Surface CRACs with no recent readings as faulted
known = set(crac_data.keys())
for room in ROOMS.get(site_id, []):
if room["crac_id"] not in known:
result_list.append({
"crac_id": room["crac_id"],
"room_id": room["room_id"],
"state": "fault",
"delta": None,
"rated_capacity_kw": RATED_CAPACITY_KW,
})
return sorted(result_list, key=lambda x: x["crac_id"])
@router.get("/crac-history")
async def crac_history(
site_id: str = Query(...),
crac_id: str = Query(...),
hours: int = Query(6, ge=1, le=24),
session: AsyncSession = Depends(get_session),
):
"""Time-series history for a single CRAC unit — capacity, COP, compressor load, filter ΔP, temps."""
from_time = datetime.now(timezone.utc) - timedelta(hours=hours)
METRICS = (
"cooling_supply", "cooling_return", "cooling_cap_kw",
"cooling_cap_pct", "cooling_cop", "cooling_comp_load",
"cooling_filter_dp", "cooling_fan",
)
types_sql = ", ".join(f"'{t}'" for t in METRICS)
try:
result = await session.execute(text(f"""
SELECT
time_bucket('5 minutes', recorded_at) AS bucket,
sensor_type,
ROUND(AVG(value)::numeric, 3) AS avg_val
FROM readings
WHERE site_id = :site_id
AND sensor_id LIKE :pattern
AND sensor_type IN ({types_sql})
AND recorded_at > :from_time
GROUP BY bucket, sensor_type
ORDER BY bucket ASC
"""), {"site_id": site_id, "pattern": f"{site_id}/cooling/{crac_id}/%", "from_time": from_time})
except Exception:
result = await session.execute(text(f"""
SELECT
date_trunc('minute', recorded_at) AS bucket,
sensor_type,
ROUND(AVG(value)::numeric, 3) AS avg_val
FROM readings
WHERE site_id = :site_id
AND sensor_id LIKE :pattern
AND sensor_type IN ({types_sql})
AND recorded_at > :from_time
GROUP BY bucket, sensor_type
ORDER BY bucket ASC
"""), {"site_id": site_id, "pattern": f"{site_id}/cooling/{crac_id}/%", "from_time": from_time})
bucket_map: dict[str, dict] = {}
for row in result.mappings().all():
b = str(row["bucket"])
if b not in bucket_map:
bucket_map[b] = {"bucket": b}
bucket_map[b][row["sensor_type"]] = float(row["avg_val"])
points = []
for b, vals in sorted(bucket_map.items()):
supply = vals.get("cooling_supply")
ret = vals.get("cooling_return")
points.append({
"bucket": b,
"supply_temp": round(supply, 1) if supply is not None else None,
"return_temp": round(ret, 1) if ret is not None else None,
"delta_t": round(ret - supply, 1) if (supply is not None and ret is not None) else None,
"capacity_kw": vals.get("cooling_cap_kw"),
"capacity_pct": vals.get("cooling_cap_pct"),
"cop": vals.get("cooling_cop"),
"comp_load": vals.get("cooling_comp_load"),
"filter_dp": vals.get("cooling_filter_dp"),
"fan_pct": vals.get("cooling_fan"),
})
return points
@router.get("/crac-delta-history")
async def crac_delta_history(
site_id: str = Query(...),
crac_id: str = Query(...),
hours: int = Query(1, ge=1, le=24),
session: AsyncSession = Depends(get_session),
):
"""ΔT (return - supply) over time for a single CRAC unit."""
from_time = datetime.now(timezone.utc) - timedelta(hours=hours)
try:
result = await session.execute(text("""
SELECT
time_bucket('5 minutes', recorded_at) AS bucket,
sensor_type,
AVG(value) AS avg_val
FROM readings
WHERE site_id = :site_id
AND sensor_id LIKE :pattern
AND sensor_type IN ('cooling_supply', 'cooling_return')
AND recorded_at > :from_time
GROUP BY bucket, sensor_type
ORDER BY bucket ASC
"""), {"site_id": site_id, "pattern": f"{site_id}/cooling/{crac_id}/%", "from_time": from_time})
except Exception:
result = await session.execute(text("""
SELECT
date_trunc('minute', recorded_at) AS bucket,
sensor_type,
AVG(value) AS avg_val
FROM readings
WHERE site_id = :site_id
AND sensor_id LIKE :pattern
AND sensor_type IN ('cooling_supply', 'cooling_return')
AND recorded_at > :from_time
GROUP BY bucket, sensor_type
ORDER BY bucket ASC
"""), {"site_id": site_id, "pattern": f"{site_id}/cooling/{crac_id}/%", "from_time": from_time})
rows = result.mappings().all()
bucket_map: dict[str, dict] = {}
for row in rows:
b = str(row["bucket"])
if b not in bucket_map:
bucket_map[b] = {"bucket": b}
bucket_map[b][row["sensor_type"]] = float(row["avg_val"])
points = []
for b, vals in bucket_map.items():
supply = vals.get("cooling_supply")
ret = vals.get("cooling_return")
if supply is not None and ret is not None:
points.append({"bucket": b, "delta": round(ret - supply, 2)})
return sorted(points, key=lambda x: x["bucket"])
@router.get("/rack-history")
async def rack_history(
site_id: str = Query(...),
rack_id: str = Query(...),
hours: int = Query(6, ge=1, le=24),
session: AsyncSession = Depends(get_session),
):
"""Temperature and power history for a single rack."""
from_time = datetime.now(timezone.utc) - timedelta(hours=hours)
try:
result = await session.execute(text("""
SELECT
time_bucket('5 minutes', recorded_at) AS bucket,
sensor_type,
ROUND(AVG(value)::numeric, 2) AS avg_value
FROM readings
WHERE site_id = :site_id
AND rack_id = :rack_id
AND sensor_type IN ('temperature', 'humidity', 'power_kw')
AND recorded_at > :from_time
GROUP BY bucket, sensor_type
ORDER BY bucket ASC
"""), {"site_id": site_id, "rack_id": rack_id, "from_time": from_time})
except Exception:
result = await session.execute(text("""
SELECT
date_trunc('minute', recorded_at) AS bucket,
sensor_type,
ROUND(AVG(value)::numeric, 2) AS avg_value
FROM readings
WHERE site_id = :site_id
AND rack_id = :rack_id
AND sensor_type IN ('temperature', 'humidity', 'power_kw')
AND recorded_at > :from_time
GROUP BY bucket, sensor_type
ORDER BY bucket ASC
"""), {"site_id": site_id, "rack_id": rack_id, "from_time": from_time})
rows = result.mappings().all()
# Pivot into {bucket, temperature, humidity, power_kw}
bucket_map: dict[str, dict] = {}
for row in rows:
b = str(row["bucket"])
if b not in bucket_map:
bucket_map[b] = {"bucket": b}
bucket_map[b][row["sensor_type"]] = float(row["avg_value"])
# Fetch active alarms for this rack
alarms = await session.execute(text("""
SELECT id, severity, message, state, triggered_at
FROM alarms
WHERE site_id = :site_id AND rack_id = :rack_id
ORDER BY triggered_at DESC
LIMIT 10
"""), {"site_id": site_id, "rack_id": rack_id})
return {
"rack_id": rack_id,
"site_id": site_id,
"history": list(bucket_map.values()),
"alarms": [dict(r) for r in alarms.mappings().all()],
}
@router.get("/particles")
async def particle_status(
site_id: str = Query(...),
session: AsyncSession = Depends(get_session),
):
"""Latest particle counts per room."""
result = await session.execute(text("""
SELECT DISTINCT ON (sensor_id)
room_id, sensor_type, value, recorded_at
FROM readings
WHERE site_id = :site_id
AND sensor_type IN ('particles_0_5um', 'particles_5um')
AND recorded_at > NOW() - INTERVAL '10 minutes'
ORDER BY sensor_id, recorded_at DESC
"""), {"site_id": site_id})
room_data: dict[str, dict] = {}
for row in result.mappings().all():
rid = row["room_id"]
if rid not in room_data:
room_data[rid] = {}
room_data[rid][row["sensor_type"]] = round(float(row["value"]))
rooms_cfg = ROOMS.get(site_id, [])
out = []
for room in rooms_cfg:
rid = room["room_id"]
d = room_data.get(rid, {})
p05 = d.get("particles_0_5um")
p5 = d.get("particles_5um")
# Derive ISO 14644-1 class (simplified: class 8 = 3.52M @ 0.5µm)
iso_class = None
if p05 is not None:
if p05 <= 10_000: iso_class = 5
elif p05 <= 100_000: iso_class = 6
elif p05 <= 1_000_000: iso_class = 7
elif p05 <= 3_520_000: iso_class = 8
else: iso_class = 9
out.append({
"room_id": rid,
"particles_0_5um": p05,
"particles_5um": p5,
"iso_class": iso_class,
})
return out

View file

@ -0,0 +1,75 @@
from fastapi import APIRouter, Depends, Query
from sqlalchemy import text
from sqlalchemy.ext.asyncio import AsyncSession
from core.database import get_session
router = APIRouter()
VESDA_ZONES = {
"sg-01": [
{"zone_id": "vesda-hall-a", "room_id": "hall-a"},
{"zone_id": "vesda-hall-b", "room_id": "hall-b"},
]
}
LEVEL_MAP = {0: "normal", 1: "alert", 2: "action", 3: "fire"}
VESDA_TYPES = ("vesda_level", "vesda_obscuration", "vesda_det1", "vesda_det2",
"vesda_power", "vesda_flow")
@router.get("/status")
async def fire_status(
site_id: str = Query(...),
session: AsyncSession = Depends(get_session),
):
"""Latest VESDA readings per fire zone."""
types_sql = ", ".join(f"'{t}'" for t in VESDA_TYPES)
result = await session.execute(text(f"""
SELECT DISTINCT ON (sensor_id)
sensor_id, sensor_type, value
FROM readings
WHERE site_id = :site_id
AND sensor_type IN ({types_sql})
AND recorded_at > NOW() - INTERVAL '2 minutes'
ORDER BY sensor_id, recorded_at DESC
"""), {"site_id": site_id})
zone_data: dict[str, dict] = {}
for row in result.mappings().all():
parts = row["sensor_id"].split("/")
if len(parts) < 3:
continue
zone_id = parts[2]
if zone_id not in zone_data:
zone_data[zone_id] = {"zone_id": zone_id}
v = float(row["value"])
s_type = row["sensor_type"]
if s_type == "vesda_level":
zone_data[zone_id]["level"] = LEVEL_MAP.get(round(v), "normal")
elif s_type == "vesda_obscuration":
zone_data[zone_id]["obscuration_pct_m"] = round(v, 3)
elif s_type == "vesda_det1":
zone_data[zone_id]["detector_1_ok"] = v > 0.5
elif s_type == "vesda_det2":
zone_data[zone_id]["detector_2_ok"] = v > 0.5
elif s_type == "vesda_power":
zone_data[zone_id]["power_ok"] = v > 0.5
elif s_type == "vesda_flow":
zone_data[zone_id]["flow_ok"] = v > 0.5
zone_room_map = {z["zone_id"]: z["room_id"] for z in VESDA_ZONES.get(site_id, [])}
out = []
for zone_cfg in VESDA_ZONES.get(site_id, []):
zone_id = zone_cfg["zone_id"]
d = zone_data.get(zone_id, {"zone_id": zone_id})
d.setdefault("level", "normal")
d.setdefault("obscuration_pct_m", None)
d.setdefault("detector_1_ok", True)
d.setdefault("detector_2_ok", True)
d.setdefault("power_ok", True)
d.setdefault("flow_ok", True)
d["room_id"] = zone_room_map.get(zone_id)
out.append(d)
return out

View file

@ -0,0 +1,33 @@
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy import text
from core.database import get_session
router = APIRouter()
@router.get("")
async def get_floor_layout(site_id: str, db: AsyncSession = Depends(get_session)):
row = await db.execute(
text("SELECT value FROM site_config WHERE site_id = :site_id AND key = 'floor_layout'"),
{"site_id": site_id},
)
result = row.fetchone()
if result is None:
raise HTTPException(status_code=404, detail="No floor layout saved for this site")
return result[0]
@router.put("")
async def save_floor_layout(site_id: str, layout: dict, db: AsyncSession = Depends(get_session)):
await db.execute(
text("""
INSERT INTO site_config (site_id, key, value, updated_at)
VALUES (:site_id, 'floor_layout', CAST(:value AS jsonb), NOW())
ON CONFLICT (site_id, key)
DO UPDATE SET value = EXCLUDED.value, updated_at = NOW()
"""),
{"site_id": site_id, "value": __import__("json").dumps(layout)},
)
await db.commit()
return {"ok": True}

View file

@ -0,0 +1,138 @@
from datetime import datetime, timezone, timedelta
from fastapi import APIRouter, Depends, Query
from sqlalchemy import text
from sqlalchemy.ext.asyncio import AsyncSession
from core.database import get_session
router = APIRouter()
GENERATORS = {"sg-01": ["gen-01"]}
GEN_FIELD_MAP = {
"gen_fuel_pct": "fuel_pct",
"gen_fuel_l": "fuel_litres",
"gen_fuel_rate": "fuel_rate_lph",
"gen_load_kw": "load_kw",
"gen_load_pct": "load_pct",
"gen_run_hours": "run_hours",
"gen_voltage_v": "voltage_v",
"gen_freq_hz": "frequency_hz",
"gen_rpm": "engine_rpm",
"gen_oil_press": "oil_pressure_bar",
"gen_coolant_c": "coolant_temp_c",
"gen_exhaust_c": "exhaust_temp_c",
"gen_alt_temp_c": "alternator_temp_c",
"gen_pf": "power_factor",
"gen_batt_v": "battery_v",
}
STATE_MAP = {-1.0: "fault", 0.0: "standby", 1.0: "running", 2.0: "test"}
@router.get("/status")
async def generator_status(
site_id: str = Query(...),
session: AsyncSession = Depends(get_session),
):
"""Latest reading for each generator."""
types_sql = ", ".join(f"'{t}'" for t in [*GEN_FIELD_MAP.keys(), "gen_state"])
result = await session.execute(text(f"""
SELECT DISTINCT ON (sensor_id)
sensor_id, sensor_type, value
FROM readings
WHERE site_id = :site_id
AND sensor_type IN ({types_sql})
AND recorded_at > NOW() - INTERVAL '5 minutes'
ORDER BY sensor_id, recorded_at DESC
"""), {"site_id": site_id})
gen_data: dict[str, dict] = {}
for row in result.mappings().all():
parts = row["sensor_id"].split("/")
if len(parts) < 3:
continue
gen_id = parts[2]
if gen_id not in gen_data:
gen_data[gen_id] = {"gen_id": gen_id}
field = GEN_FIELD_MAP.get(row["sensor_type"])
if field:
gen_data[gen_id][field] = round(float(row["value"]), 2)
elif row["sensor_type"] == "gen_state":
v = round(float(row["value"]))
gen_data[gen_id]["state"] = STATE_MAP.get(v, "standby")
out = []
for gen_id in GENERATORS.get(site_id, []):
d = gen_data.get(gen_id, {"gen_id": gen_id, "state": "unknown"})
if "state" not in d:
d["state"] = "standby"
out.append(d)
return out
HISTORY_METRICS = (
"gen_load_pct", "gen_fuel_pct", "gen_coolant_c",
"gen_exhaust_c", "gen_freq_hz", "gen_alt_temp_c",
)
@router.get("/history")
async def generator_history(
site_id: str = Query(...),
gen_id: str = Query(...),
hours: int = Query(6, ge=1, le=24),
session: AsyncSession = Depends(get_session),
):
"""5-minute bucketed time-series for a single generator."""
from_time = datetime.now(timezone.utc) - timedelta(hours=hours)
types_sql = ", ".join(f"'{t}'" for t in HISTORY_METRICS)
try:
result = await session.execute(text(f"""
SELECT
time_bucket('5 minutes', recorded_at) AS bucket,
sensor_type,
ROUND(AVG(value)::numeric, 2) AS avg_val
FROM readings
WHERE site_id = :site_id
AND sensor_id LIKE :pattern
AND sensor_type IN ({types_sql})
AND recorded_at > :from_time
GROUP BY bucket, sensor_type
ORDER BY bucket ASC
"""), {"site_id": site_id,
"pattern": f"{site_id}/generator/{gen_id}/%",
"from_time": from_time})
except Exception:
result = await session.execute(text(f"""
SELECT
date_trunc('minute', recorded_at) AS bucket,
sensor_type,
ROUND(AVG(value)::numeric, 2) AS avg_val
FROM readings
WHERE site_id = :site_id
AND sensor_id LIKE :pattern
AND sensor_type IN ({types_sql})
AND recorded_at > :from_time
GROUP BY bucket, sensor_type
ORDER BY bucket ASC
"""), {"site_id": site_id,
"pattern": f"{site_id}/generator/{gen_id}/%",
"from_time": from_time})
# Pivot: bucket → {metric: value}
buckets: dict[str, dict] = {}
for row in result.mappings().all():
b = row["bucket"].isoformat()
buckets.setdefault(b, {"bucket": b})
key_map = {
"gen_load_pct": "load_pct",
"gen_fuel_pct": "fuel_pct",
"gen_coolant_c": "coolant_temp_c",
"gen_exhaust_c": "exhaust_temp_c",
"gen_freq_hz": "frequency_hz",
"gen_alt_temp_c":"alternator_temp_c",
}
field = key_map.get(row["sensor_type"])
if field:
buckets[b][field] = float(row["avg_val"])
return list(buckets.values())

View file

@ -0,0 +1,13 @@
from fastapi import APIRouter
from datetime import datetime, timezone
router = APIRouter()
@router.get("/health")
async def health_check():
return {
"status": "ok",
"service": "DemoBMS API",
"timestamp": datetime.now(timezone.utc).isoformat(),
}

View file

@ -0,0 +1,57 @@
from fastapi import APIRouter, Depends, Query
from sqlalchemy import text
from sqlalchemy.ext.asyncio import AsyncSession
from core.database import get_session
router = APIRouter()
# Static topology metadata — mirrors simulator config
LEAK_SENSORS = {
"sg-01": [
{"sensor_id": "leak-01", "floor_zone": "crac-zone-a", "under_floor": True, "near_crac": True, "room_id": "hall-a"},
{"sensor_id": "leak-02", "floor_zone": "server-row-b1", "under_floor": True, "near_crac": False, "room_id": "hall-b"},
{"sensor_id": "leak-03", "floor_zone": "ups-room", "under_floor": False, "near_crac": False, "room_id": None},
]
}
@router.get("/status")
async def leak_status(
site_id: str = Query(...),
session: AsyncSession = Depends(get_session),
):
"""Latest state for all leak sensors, enriched with location metadata."""
result = await session.execute(text("""
SELECT DISTINCT ON (sensor_id)
sensor_id, value, recorded_at
FROM readings
WHERE site_id = :site_id
AND sensor_type = 'leak'
AND recorded_at > NOW() - INTERVAL '5 minutes'
ORDER BY sensor_id, recorded_at DESC
"""), {"site_id": site_id})
# sensor_id format: {site_id}/leak/{sensor_id}
state_map: dict[str, dict] = {}
for row in result.mappings().all():
parts = row["sensor_id"].split("/")
if len(parts) < 3:
continue
sid = parts[2]
state_map[sid] = {
"state": "detected" if float(row["value"]) > 0.5 else "clear",
"recorded_at": str(row["recorded_at"]),
}
out = []
for cfg in LEAK_SENSORS.get(site_id, []):
sid = cfg["sensor_id"]
entry = {**cfg}
if sid in state_map:
entry["state"] = state_map[sid]["state"]
entry["recorded_at"] = state_map[sid]["recorded_at"]
else:
entry["state"] = "unknown"
entry["recorded_at"] = None
out.append(entry)
return out

View file

@ -0,0 +1,77 @@
import uuid
from datetime import datetime, timezone
from fastapi import APIRouter, HTTPException
from pydantic import BaseModel
router = APIRouter()
# In-memory store (demo — resets on restart)
_windows: list[dict] = []
class WindowCreate(BaseModel):
site_id: str
title: str
target: str # "all", a room_id like "hall-a", or a rack_id like "rack-A01"
target_label: str # human-readable label
start_dt: str # ISO 8601
end_dt: str # ISO 8601
suppress_alarms: bool = True
notes: str = ""
def _window_status(w: dict) -> str:
now = datetime.now(timezone.utc).isoformat()
if w["end_dt"] < now:
return "expired"
if w["start_dt"] <= now:
return "active"
return "scheduled"
@router.get("")
async def list_windows(site_id: str = "sg-01"):
return [
{**w, "status": _window_status(w)}
for w in _windows
if w["site_id"] == site_id
]
@router.post("", status_code=201)
async def create_window(body: WindowCreate):
window = {
"id": str(uuid.uuid4())[:8],
"site_id": body.site_id,
"title": body.title,
"target": body.target,
"target_label": body.target_label,
"start_dt": body.start_dt,
"end_dt": body.end_dt,
"suppress_alarms": body.suppress_alarms,
"notes": body.notes,
"created_at": datetime.now(timezone.utc).isoformat(),
}
_windows.append(window)
return {**window, "status": _window_status(window)}
@router.delete("/{window_id}", status_code=204)
async def delete_window(window_id: str):
global _windows
before = len(_windows)
_windows = [w for w in _windows if w["id"] != window_id]
if len(_windows) == before:
raise HTTPException(status_code=404, detail="Window not found")
@router.get("/active")
async def active_windows(site_id: str = "sg-01"):
"""Returns only currently active windows — used by alarm page for suppression check."""
now = datetime.now(timezone.utc).isoformat()
return [
w for w in _windows
if w["site_id"] == site_id
and w["start_dt"] <= now <= w["end_dt"]
and w["suppress_alarms"]
]

View file

@ -0,0 +1,69 @@
from fastapi import APIRouter, Depends, Query
from sqlalchemy import text
from sqlalchemy.ext.asyncio import AsyncSession
from core.database import get_session
router = APIRouter()
SWITCHES = {
"sg-01": [
{"switch_id": "sw-core-01", "name": "Core Switch — Hall A", "model": "Cisco Catalyst C9300-48P", "room_id": "hall-a", "rack_id": "SG1A01.01", "port_count": 48, "role": "core"},
{"switch_id": "sw-core-02", "name": "Core Switch — Hall B", "model": "Arista 7050CX3-32S", "room_id": "hall-b", "rack_id": "SG1B01.01", "port_count": 32, "role": "core"},
{"switch_id": "sw-edge-01", "name": "Edge / Uplink Switch", "model": "Juniper EX4300-48T", "room_id": "hall-a", "rack_id": "SG1A01.05", "port_count": 48, "role": "edge"},
]
}
NET_FIELD_MAP = {
"net_uptime_s": "uptime_s",
"net_active_ports": "active_ports",
"net_bw_in_mbps": "bandwidth_in_mbps",
"net_bw_out_mbps": "bandwidth_out_mbps",
"net_cpu_pct": "cpu_pct",
"net_mem_pct": "mem_pct",
"net_temp_c": "temperature_c",
"net_pkt_loss_pct": "packet_loss_pct",
}
STATE_MAP = {0.0: "up", 1.0: "degraded", 2.0: "down"}
@router.get("/status")
async def network_status(
site_id: str = Query(...),
session: AsyncSession = Depends(get_session),
):
"""Latest reading for each network switch."""
types_sql = ", ".join(f"'{t}'" for t in [*NET_FIELD_MAP.keys(), "net_state"])
result = await session.execute(text(f"""
SELECT DISTINCT ON (sensor_id)
sensor_id, sensor_type, value
FROM readings
WHERE site_id = :site_id
AND sensor_type IN ({types_sql})
AND recorded_at > NOW() - INTERVAL '5 minutes'
ORDER BY sensor_id, recorded_at DESC
"""), {"site_id": site_id})
sw_data: dict[str, dict] = {}
for row in result.mappings().all():
parts = row["sensor_id"].split("/")
if len(parts) < 3:
continue
sw_id = parts[2]
if sw_id not in sw_data:
sw_data[sw_id] = {}
field = NET_FIELD_MAP.get(row["sensor_type"])
if field:
sw_data[sw_id][field] = round(float(row["value"]), 2)
elif row["sensor_type"] == "net_state":
v = round(float(row["value"]))
sw_data[sw_id]["state"] = STATE_MAP.get(v, "unknown")
out = []
for sw_cfg in SWITCHES.get(site_id, []):
sw_id = sw_cfg["switch_id"]
d = {**sw_cfg, **sw_data.get(sw_id, {})}
if "state" not in d:
d["state"] = "unknown"
out.append(d)
return out

460
backend/api/routes/power.py Normal file
View file

@ -0,0 +1,460 @@
from datetime import datetime, timezone, timedelta
from fastapi import APIRouter, Depends, Query
from sqlalchemy import text
from sqlalchemy.ext.asyncio import AsyncSession
from core.database import get_session
router = APIRouter()
# Topology — mirrors simulator config
ROOMS = {
"sg-01": [
{"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)]},
{"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)]},
]
}
ATS_UNITS = {"sg-01": ["ats-01"]}
GENERATORS = {"sg-01": ["gen-01"]}
ACTIVE_FEED_MAP = {0.0: "utility-a", 1.0: "utility-b", 2.0: "generator"}
# Singapore commercial electricity tariff (SGD / kWh, approximate)
TARIFF_SGD_KWH = 0.298
@router.get("/rack-breakdown")
async def rack_power_breakdown(
site_id: str = Query(...),
session: AsyncSession = Depends(get_session),
):
"""Latest kW reading per rack, grouped by room."""
result = await session.execute(text("""
SELECT DISTINCT ON (sensor_id)
rack_id, room_id, value AS power_kw
FROM readings
WHERE site_id = :site_id
AND sensor_type = 'power_kw'
AND rack_id IS NOT NULL
AND recorded_at > NOW() - INTERVAL '10 minutes'
ORDER BY sensor_id, recorded_at DESC
"""), {"site_id": site_id})
rows = result.mappings().all()
rack_map: dict[str, dict] = {r["rack_id"]: dict(r) for r in rows}
rooms = []
for room in ROOMS.get(site_id, []):
racks = []
for rack_id in room["racks"]:
reading = rack_map.get(rack_id)
racks.append({
"rack_id": rack_id,
"power_kw": round(float(reading["power_kw"]), 2) if reading else None,
})
rooms.append({"room_id": room["room_id"], "racks": racks})
return rooms
@router.get("/room-history")
async def room_power_history(
site_id: str = Query(...),
hours: int = Query(6, ge=1, le=24),
session: AsyncSession = Depends(get_session),
):
"""Total power per room bucketed by 5 minutes — for a multi-line trend chart."""
from_time = datetime.now(timezone.utc) - timedelta(hours=hours)
try:
result = await session.execute(text("""
SELECT bucket, room_id, ROUND(SUM(avg_per_rack)::numeric, 1) AS total_kw
FROM (
SELECT
time_bucket('5 minutes', recorded_at) AS bucket,
sensor_id, room_id,
AVG(value) AS avg_per_rack
FROM readings
WHERE site_id = :site_id
AND sensor_type = 'power_kw'
AND room_id IS NOT NULL
AND recorded_at > :from_time
GROUP BY bucket, sensor_id, room_id
) per_rack
GROUP BY bucket, room_id
ORDER BY bucket ASC
"""), {"site_id": site_id, "from_time": from_time})
except Exception:
result = await session.execute(text("""
SELECT bucket, room_id, ROUND(SUM(avg_per_rack)::numeric, 1) AS total_kw
FROM (
SELECT
date_trunc('minute', recorded_at) AS bucket,
sensor_id, room_id,
AVG(value) AS avg_per_rack
FROM readings
WHERE site_id = :site_id
AND sensor_type = 'power_kw'
AND room_id IS NOT NULL
AND recorded_at > :from_time
GROUP BY bucket, sensor_id, room_id
) per_rack
GROUP BY bucket, room_id
ORDER BY bucket ASC
"""), {"site_id": site_id, "from_time": from_time})
return [dict(r) for r in result.mappings().all()]
@router.get("/ups")
async def ups_status(
site_id: str = Query(...),
session: AsyncSession = Depends(get_session),
):
"""Latest UPS readings."""
result = await session.execute(text("""
SELECT DISTINCT ON (sensor_id)
sensor_id, sensor_type, value
FROM readings
WHERE site_id = :site_id
AND sensor_type IN ('ups_charge', 'ups_load', 'ups_runtime', 'ups_state', 'ups_voltage')
AND recorded_at > NOW() - INTERVAL '10 minutes'
ORDER BY sensor_id, recorded_at DESC
"""), {"site_id": site_id})
rows = result.mappings().all()
# sensor_id format: sg-01/power/ups-01/charge_pct
ups_data: dict[str, dict] = {}
for row in rows:
parts = row["sensor_id"].split("/")
if len(parts) < 3:
continue
ups_id = parts[2]
if ups_id not in ups_data:
ups_data[ups_id] = {"ups_id": ups_id}
key_map = {
"ups_charge": "charge_pct",
"ups_load": "load_pct",
"ups_runtime": "runtime_min",
"ups_state": "_state_raw",
"ups_voltage": "voltage_v",
}
field = key_map.get(row["sensor_type"])
if field:
ups_data[ups_id][field] = round(float(row["value"]), 1)
STATE_MAP = {0.0: "online", 1.0: "battery", 2.0: "overload"}
result_list = []
for ups_id, d in sorted(ups_data.items()):
# Use stored state if available; fall back to charge heuristic only if state never arrived
state_raw = d.get("_state_raw")
if state_raw is not None:
state = STATE_MAP.get(round(state_raw), "online")
else:
charge = d.get("charge_pct")
state = "battery" if (charge is not None and charge < 20.0) else "online"
result_list.append({
"ups_id": ups_id,
"state": state,
"charge_pct": d.get("charge_pct"),
"load_pct": d.get("load_pct"),
"runtime_min": d.get("runtime_min"),
"voltage_v": d.get("voltage_v"),
})
return result_list
@router.get("/ups/history")
async def ups_history(
site_id: str = Query(...),
ups_id: str = Query(...),
hours: int = Query(6, ge=1, le=24),
session: AsyncSession = Depends(get_session),
):
"""5-minute bucketed trend for a single UPS: charge, load, runtime, voltage."""
from_time = datetime.now(timezone.utc) - timedelta(hours=hours)
types_sql = "'ups_charge', 'ups_load', 'ups_runtime', 'ups_voltage'"
try:
result = await session.execute(text(f"""
SELECT
time_bucket('5 minutes', recorded_at) AS bucket,
sensor_type,
ROUND(AVG(value)::numeric, 2) AS avg_val
FROM readings
WHERE site_id = :site_id
AND sensor_id LIKE :pattern
AND sensor_type IN ({types_sql})
AND recorded_at > :from_time
GROUP BY bucket, sensor_type
ORDER BY bucket ASC
"""), {"site_id": site_id,
"pattern": f"{site_id}/power/{ups_id}/%",
"from_time": from_time})
except Exception:
result = await session.execute(text(f"""
SELECT
date_trunc('minute', recorded_at) AS bucket,
sensor_type,
ROUND(AVG(value)::numeric, 2) AS avg_val
FROM readings
WHERE site_id = :site_id
AND sensor_id LIKE :pattern
AND sensor_type IN ({types_sql})
AND recorded_at > :from_time
GROUP BY bucket, sensor_type
ORDER BY bucket ASC
"""), {"site_id": site_id,
"pattern": f"{site_id}/power/{ups_id}/%",
"from_time": from_time})
KEY_MAP = {
"ups_charge": "charge_pct",
"ups_load": "load_pct",
"ups_runtime": "runtime_min",
"ups_voltage": "voltage_v",
}
buckets: dict[str, dict] = {}
for row in result.mappings().all():
b = row["bucket"].isoformat()
buckets.setdefault(b, {"bucket": b})
field = KEY_MAP.get(row["sensor_type"])
if field:
buckets[b][field] = float(row["avg_val"])
return list(buckets.values())
@router.get("/ats")
async def ats_status(
site_id: str = Query(...),
session: AsyncSession = Depends(get_session),
):
"""Latest ATS transfer switch readings."""
result = await session.execute(text("""
SELECT DISTINCT ON (sensor_id)
sensor_id, sensor_type, value
FROM readings
WHERE site_id = :site_id
AND sensor_type IN ('ats_active', 'ats_state', 'ats_xfer_count',
'ats_xfer_ms', 'ats_ua_v', 'ats_ub_v', 'ats_gen_v')
AND recorded_at > NOW() - INTERVAL '2 minutes'
ORDER BY sensor_id, recorded_at DESC
"""), {"site_id": site_id})
ats_data: dict[str, dict] = {}
for row in result.mappings().all():
parts = row["sensor_id"].split("/")
# sensor_id: {site}/power/ats/{ats_id}/{key} → parts[3]
if len(parts) < 4:
continue
ats_id = parts[3]
if ats_id not in ats_data:
ats_data[ats_id] = {"ats_id": ats_id}
v = float(row["value"])
s_type = row["sensor_type"]
if s_type == "ats_active":
ats_data[ats_id]["active_feed"] = ACTIVE_FEED_MAP.get(round(v), "utility-a")
elif s_type == "ats_state":
ats_data[ats_id]["state"] = "transferring" if v > 0.5 else "stable"
elif s_type == "ats_xfer_count":
ats_data[ats_id]["transfer_count"] = int(v)
elif s_type == "ats_xfer_ms":
ats_data[ats_id]["last_transfer_ms"] = round(v, 0) if v > 0 else None
elif s_type == "ats_ua_v":
ats_data[ats_id]["utility_a_v"] = round(v, 1)
elif s_type == "ats_ub_v":
ats_data[ats_id]["utility_b_v"] = round(v, 1)
elif s_type == "ats_gen_v":
ats_data[ats_id]["generator_v"] = round(v, 1)
out = []
for ats_id in ATS_UNITS.get(site_id, []):
d = ats_data.get(ats_id, {"ats_id": ats_id})
d.setdefault("state", "stable")
d.setdefault("active_feed", "utility-a")
d.setdefault("transfer_count", 0)
d.setdefault("last_transfer_ms", None)
out.append(d)
return out
@router.get("/phase")
async def pdu_phase_breakdown(
site_id: str = Query(...),
session: AsyncSession = Depends(get_session),
):
"""Per-phase kW, amps, and imbalance % for every rack PDU."""
result = await session.execute(text("""
SELECT DISTINCT ON (sensor_id)
rack_id, room_id, sensor_type, value
FROM readings
WHERE site_id = :site_id
AND sensor_type IN ('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 rack_id IS NOT NULL
AND recorded_at > NOW() - INTERVAL '10 minutes'
ORDER BY sensor_id, recorded_at DESC
"""), {"site_id": site_id})
FIELD_MAP = {
"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",
}
rack_map: dict[tuple, float] = {}
rack_rooms: dict[str, str] = {}
for row in result.mappings().all():
rack_id = row["rack_id"]
room_id = row["room_id"]
s_type = row["sensor_type"]
if rack_id:
rack_map[(rack_id, s_type)] = round(float(row["value"]), 2)
if room_id:
rack_rooms[rack_id] = room_id
rooms = []
for room in ROOMS.get(site_id, []):
racks = []
for rack_id in room["racks"]:
entry: dict = {"rack_id": rack_id, "room_id": room["room_id"]}
for s_type, field in FIELD_MAP.items():
entry[field] = rack_map.get((rack_id, s_type))
racks.append(entry)
rooms.append({"room_id": room["room_id"], "racks": racks})
return rooms
@router.get("/redundancy")
async def power_redundancy(
site_id: str = Query(...),
session: AsyncSession = Depends(get_session),
):
"""Compute power redundancy level: 2N, N+1, or N."""
# Count UPS units online
ups_result = await session.execute(text("""
SELECT DISTINCT ON (sensor_id)
sensor_id, value
FROM readings
WHERE site_id = :site_id
AND sensor_type = 'ups_charge'
AND recorded_at > NOW() - INTERVAL '10 minutes'
ORDER BY sensor_id, recorded_at DESC
"""), {"site_id": site_id})
ups_rows = ups_result.mappings().all()
ups_online = len([r for r in ups_rows if float(r["value"]) > 10.0])
ups_total = len(ups_rows)
# ATS active feed
ats_result = await session.execute(text("""
SELECT DISTINCT ON (sensor_id)
sensor_id, value
FROM readings
WHERE site_id = :site_id
AND sensor_type = 'ats_active'
AND recorded_at > NOW() - INTERVAL '2 minutes'
ORDER BY sensor_id, recorded_at DESC
"""), {"site_id": site_id})
ats_rows = ats_result.mappings().all()
ats_active_feed = None
if ats_rows:
ats_active_feed = ACTIVE_FEED_MAP.get(round(float(ats_rows[0]["value"])), "utility-a")
# Generator available (not fault)
gen_result = await session.execute(text("""
SELECT DISTINCT ON (sensor_id)
sensor_id, value
FROM readings
WHERE site_id = :site_id
AND sensor_type = 'gen_state'
AND recorded_at > NOW() - INTERVAL '5 minutes'
ORDER BY sensor_id, recorded_at DESC
"""), {"site_id": site_id})
gen_rows = gen_result.mappings().all()
gen_available = len([r for r in gen_rows if float(r["value"]) >= 0.0]) > 0
# Derive level
if ups_total >= 2 and ups_online >= 2 and gen_available:
level = "2N"
elif ups_online >= 1 and gen_available:
level = "N+1"
else:
level = "N"
return {
"site_id": site_id,
"level": level,
"ups_total": ups_total,
"ups_online": ups_online,
"generator_ok": gen_available,
"ats_active_feed": ats_active_feed,
"notes": (
"Dual UPS + generator = 2N" if level == "2N" else
"Single path active — reduced redundancy" if level == "N" else
"N+1 — one redundant path available"
),
}
@router.get("/utility")
async def utility_power(
site_id: str = Query(...),
session: AsyncSession = Depends(get_session),
):
"""Current total IT load and estimated monthly energy cost."""
# Latest total IT load
kw_result = await session.execute(text("""
SELECT ROUND(SUM(value)::numeric, 2) AS total_kw
FROM (
SELECT DISTINCT ON (sensor_id) sensor_id, value
FROM readings
WHERE site_id = :site_id
AND sensor_type = 'power_kw'
AND recorded_at > NOW() - INTERVAL '10 minutes'
ORDER BY sensor_id, recorded_at DESC
) latest
"""), {"site_id": site_id})
kw_row = kw_result.mappings().first()
total_kw = float(kw_row["total_kw"] or 0) if kw_row else 0.0
# Estimated month-to-date kWh (from readings since start of month)
from_month = datetime.now(timezone.utc).replace(day=1, hour=0, minute=0, second=0, microsecond=0)
kwh_result = await session.execute(text("""
SELECT ROUND((SUM(value) * 5.0 / 60.0)::numeric, 1) AS kwh_mtd
FROM (
SELECT DISTINCT ON (sensor_id, date_trunc('minute', recorded_at))
sensor_id, value
FROM readings
WHERE site_id = :site_id
AND sensor_type = 'power_kw'
AND recorded_at > :from_month
ORDER BY sensor_id, date_trunc('minute', recorded_at), recorded_at DESC
) bucketed
"""), {"site_id": site_id, "from_month": from_month})
kwh_row = kwh_result.mappings().first()
kwh_mtd = float(kwh_row["kwh_mtd"] or 0) if kwh_row else 0.0
cost_mtd = round(kwh_mtd * TARIFF_SGD_KWH, 2)
# Annualised from month-to-date pace
now = datetime.now(timezone.utc)
day_of_month = now.day
days_in_month = 30
if day_of_month > 0:
kwh_annual_est = round(kwh_mtd / day_of_month * 365, 0)
cost_annual_est = round(kwh_annual_est * TARIFF_SGD_KWH, 2)
else:
kwh_annual_est = 0.0
cost_annual_est = 0.0
return {
"site_id": site_id,
"total_kw": total_kw,
"tariff_sgd_kwh": TARIFF_SGD_KWH,
"kwh_month_to_date": kwh_mtd,
"cost_sgd_mtd": cost_mtd,
"kwh_annual_est": kwh_annual_est,
"cost_sgd_annual_est": cost_annual_est,
"currency": "SGD",
}

View file

@ -0,0 +1,229 @@
from datetime import datetime, timezone, timedelta
from fastapi import APIRouter, Depends, Query
from sqlalchemy import text
from sqlalchemy.ext.asyncio import AsyncSession
from core.database import get_session
router = APIRouter()
@router.get("/latest")
async def get_latest_readings(
site_id: str = Query(...),
session: AsyncSession = Depends(get_session),
):
"""Most recent reading per sensor for a site (last 10 minutes)."""
result = await session.execute(text("""
SELECT DISTINCT ON (sensor_id)
sensor_id, sensor_type, site_id, room_id, rack_id, value, unit, recorded_at
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})
return [dict(r) for r in result.mappings().all()]
@router.get("/kpis")
async def get_site_kpis(
site_id: str = Query(...),
session: AsyncSession = Depends(get_session),
):
"""Aggregate KPIs for the overview dashboard."""
power = await session.execute(text("""
SELECT COALESCE(SUM(value), 0) AS total_power_kw
FROM (
SELECT DISTINCT ON (sensor_id) sensor_id, value
FROM readings
WHERE site_id = :site_id AND sensor_type = 'power_kw'
AND recorded_at > NOW() - INTERVAL '5 minutes'
ORDER BY sensor_id, recorded_at DESC
) latest
"""), {"site_id": site_id})
temp = await session.execute(text("""
SELECT COALESCE(AVG(value), 0) AS avg_temp
FROM (
SELECT DISTINCT ON (sensor_id) sensor_id, value
FROM readings
WHERE site_id = :site_id AND sensor_type = 'temperature'
AND recorded_at > NOW() - INTERVAL '5 minutes'
ORDER BY sensor_id, recorded_at DESC
) latest
"""), {"site_id": site_id})
alarms = await session.execute(text("""
SELECT COUNT(*) AS alarm_count
FROM alarms
WHERE site_id = :site_id AND state = 'active'
"""), {"site_id": site_id})
total_kw = float(power.mappings().one()["total_power_kw"])
avg_temp = float(temp.mappings().one()["avg_temp"])
alarm_cnt = int(alarms.mappings().one()["alarm_count"])
pue = round(total_kw / (total_kw * 0.87), 2) if total_kw > 0 else 0.0
return {
"total_power_kw": round(total_kw, 1),
"pue": pue,
"avg_temperature": round(avg_temp, 1),
"active_alarms": alarm_cnt,
}
@router.get("/site-power-history")
async def get_site_power_history(
site_id: str = Query(...),
hours: int = Query(1, ge=1, le=24),
session: AsyncSession = Depends(get_session),
):
"""Total power (kW) bucketed by 5 minutes — for the power trend chart."""
from_time = datetime.now(timezone.utc) - timedelta(hours=hours)
try:
result = await session.execute(text("""
SELECT bucket, ROUND(SUM(avg_per_sensor)::numeric, 1) AS total_kw
FROM (
SELECT
time_bucket('5 minutes', recorded_at) AS bucket,
sensor_id,
AVG(value) AS avg_per_sensor
FROM readings
WHERE site_id = :site_id
AND sensor_type = 'power_kw'
AND recorded_at > :from_time
GROUP BY bucket, sensor_id
) per_sensor
GROUP BY bucket
ORDER BY bucket ASC
"""), {"site_id": site_id, "from_time": from_time})
except Exception:
result = await session.execute(text("""
SELECT bucket, ROUND(SUM(avg_per_sensor)::numeric, 1) AS total_kw
FROM (
SELECT
date_trunc('minute', recorded_at) AS bucket,
sensor_id,
AVG(value) AS avg_per_sensor
FROM readings
WHERE site_id = :site_id
AND sensor_type = 'power_kw'
AND recorded_at > :from_time
GROUP BY bucket, sensor_id
) per_sensor
GROUP BY bucket
ORDER BY bucket ASC
"""), {"site_id": site_id, "from_time": from_time})
return [dict(r) for r in result.mappings().all()]
@router.get("/room-temp-history")
async def get_room_temp_history(
site_id: str = Query(...),
hours: int = Query(1, ge=1, le=24),
session: AsyncSession = Depends(get_session),
):
"""Average temperature per room bucketed by 5 minutes — for the temp trend chart."""
from_time = datetime.now(timezone.utc) - timedelta(hours=hours)
try:
result = await session.execute(text("""
SELECT bucket, room_id, ROUND(AVG(avg_per_rack)::numeric, 2) AS avg_temp
FROM (
SELECT
time_bucket('5 minutes', recorded_at) AS bucket,
sensor_id, room_id,
AVG(value) AS avg_per_rack
FROM readings
WHERE site_id = :site_id
AND sensor_type = 'temperature'
AND room_id IS NOT NULL
AND recorded_at > :from_time
GROUP BY bucket, sensor_id, room_id
) per_rack
GROUP BY bucket, room_id
ORDER BY bucket ASC
"""), {"site_id": site_id, "from_time": from_time})
except Exception:
result = await session.execute(text("""
SELECT bucket, room_id, ROUND(AVG(avg_per_rack)::numeric, 2) AS avg_temp
FROM (
SELECT
date_trunc('minute', recorded_at) AS bucket,
sensor_id, room_id,
AVG(value) AS avg_per_rack
FROM readings
WHERE site_id = :site_id
AND sensor_type = 'temperature'
AND room_id IS NOT NULL
AND recorded_at > :from_time
GROUP BY bucket, sensor_id, room_id
) per_rack
GROUP BY bucket, room_id
ORDER BY bucket ASC
"""), {"site_id": site_id, "from_time": from_time})
return [dict(r) for r in result.mappings().all()]
@router.get("/room-status")
async def get_room_status(
site_id: str = Query(...),
session: AsyncSession = Depends(get_session),
):
"""Current per-room summary: avg temp, total power, rack count, alarm count."""
temp = await session.execute(text("""
SELECT room_id, ROUND(AVG(value)::numeric, 1) AS avg_temp
FROM (
SELECT DISTINCT ON (sensor_id) sensor_id, room_id, value
FROM readings
WHERE site_id = :site_id
AND sensor_type = 'temperature'
AND room_id IS NOT NULL
AND recorded_at > NOW() - INTERVAL '10 minutes'
ORDER BY sensor_id, recorded_at DESC
) latest
GROUP BY room_id
"""), {"site_id": site_id})
power = await session.execute(text("""
SELECT room_id, ROUND(SUM(value)::numeric, 1) AS total_kw
FROM (
SELECT DISTINCT ON (sensor_id) sensor_id, room_id, value
FROM readings
WHERE site_id = :site_id
AND sensor_type = 'power_kw'
AND room_id IS NOT NULL
AND recorded_at > NOW() - INTERVAL '10 minutes'
ORDER BY sensor_id, recorded_at DESC
) latest
GROUP BY room_id
"""), {"site_id": site_id})
alarm_counts = await session.execute(text("""
SELECT room_id, COUNT(*) AS alarm_count, MAX(severity) AS worst_severity
FROM alarms
WHERE site_id = :site_id AND state = 'active' AND room_id IS NOT NULL
GROUP BY room_id
"""), {"site_id": site_id})
temp_map = {r["room_id"]: float(r["avg_temp"]) for r in temp.mappings().all()}
power_map = {r["room_id"]: float(r["total_kw"]) for r in power.mappings().all()}
alarm_map = {r["room_id"]: (int(r["alarm_count"]), r["worst_severity"])
for r in alarm_counts.mappings().all()}
rooms = sorted(set(list(temp_map.keys()) + list(power_map.keys())))
result = []
for room_id in rooms:
avg_temp = temp_map.get(room_id, 0.0)
alarm_cnt, ws = alarm_map.get(room_id, (0, None))
status = "ok"
if ws == "critical" or avg_temp >= 30:
status = "critical"
elif ws == "warning" or avg_temp >= 26:
status = "warning"
result.append({
"room_id": room_id,
"avg_temp": avg_temp,
"total_kw": power_map.get(room_id, 0.0),
"alarm_count": alarm_cnt,
"status": status,
})
return result

View file

@ -0,0 +1,356 @@
import csv
import io
from datetime import datetime, timezone, timedelta
from fastapi import APIRouter, Depends, Query
from fastapi.responses import StreamingResponse
from sqlalchemy import text
from sqlalchemy.ext.asyncio import AsyncSession
from core.database import get_session
router = APIRouter()
TARIFF_SGD_KWH = 0.298
ROOMS = {
"sg-01": [
{"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_IDS = {"sg-01": ["ups-01", "ups-02"]}
@router.get("/energy")
async def energy_report(
site_id: str = Query(...),
days: int = Query(30, ge=1, le=90),
session: AsyncSession = Depends(get_session),
):
"""kWh consumption, cost, and 30-day PUE trend."""
from_time = datetime.now(timezone.utc) - timedelta(days=days)
# Total kWh over period (5-min buckets × kW / 12 = kWh per bucket)
try:
kwh_result = await session.execute(text("""
SELECT ROUND((SUM(avg_kw) / 12.0)::numeric, 1) AS kwh_total
FROM (
SELECT
time_bucket('5 minutes', recorded_at) AS bucket,
AVG(value) AS avg_kw
FROM readings
WHERE site_id = :site_id
AND sensor_type = 'power_kw'
AND recorded_at > :from_time
GROUP BY bucket
) bucketed
"""), {"site_id": site_id, "from_time": from_time})
except Exception:
kwh_result = await session.execute(text("""
SELECT ROUND((SUM(avg_kw) / 12.0)::numeric, 1) AS kwh_total
FROM (
SELECT
date_trunc('minute', recorded_at) AS bucket,
AVG(value) AS avg_kw
FROM readings
WHERE site_id = :site_id
AND sensor_type = 'power_kw'
AND recorded_at > :from_time
GROUP BY bucket
) bucketed
"""), {"site_id": site_id, "from_time": from_time})
kwh_row = kwh_result.mappings().first()
kwh_total = float(kwh_row["kwh_total"] or 0) if kwh_row else 0.0
cost_sgd = round(kwh_total * TARIFF_SGD_KWH, 2)
# PUE daily average (IT load / total facility load — approximated as IT load / 0.85 overhead)
# Since we only have IT load, estimate PUE = total_facility / it_load ≈ 1.41.6
# For a proper PUE we'd need facility meter — use a day-by-day IT load trend instead
try:
pue_result = await session.execute(text("""
SELECT
time_bucket('1 day', recorded_at) AS day,
ROUND(AVG(value)::numeric, 2) AS avg_it_kw
FROM readings
WHERE site_id = :site_id
AND sensor_type = 'power_kw'
AND recorded_at > :from_time
GROUP BY day
ORDER BY day ASC
"""), {"site_id": site_id, "from_time": from_time})
except Exception:
pue_result = await session.execute(text("""
SELECT
date_trunc('day', recorded_at) AS day,
ROUND(AVG(value)::numeric, 2) AS avg_it_kw
FROM readings
WHERE site_id = :site_id
AND sensor_type = 'power_kw'
AND recorded_at > :from_time
GROUP BY day
ORDER BY day ASC
"""), {"site_id": site_id, "from_time": from_time})
# Estimated PUE: assume ~40% overhead (cooling + lighting + UPS losses)
OVERHEAD_FACTOR = 1.40
pue_trend = [
{
"day": str(r["day"]),
"avg_it_kw": float(r["avg_it_kw"]),
"pue_est": round(OVERHEAD_FACTOR, 2),
}
for r in pue_result.mappings().all()
]
return {
"site_id": site_id,
"period_days": days,
"from_date": from_time.date().isoformat(),
"to_date": datetime.now(timezone.utc).date().isoformat(),
"kwh_total": kwh_total,
"cost_sgd": cost_sgd,
"tariff_sgd_kwh": TARIFF_SGD_KWH,
"currency": "SGD",
"pue_estimated": OVERHEAD_FACTOR,
"pue_trend": pue_trend,
}
@router.get("/summary")
async def site_summary(
site_id: str = Query(...),
session: AsyncSession = Depends(get_session),
):
"""Site-level summary: KPIs, alarm stats, CRAC uptime%, UPS uptime%."""
# KPIs
kpi_res = await session.execute(text("""
SELECT
ROUND(SUM(CASE WHEN sensor_type = 'power_kw' THEN value END)::numeric, 2) AS total_power_kw,
ROUND(AVG(CASE WHEN sensor_type = 'temperature' THEN value END)::numeric, 1) AS avg_temperature
FROM (
SELECT DISTINCT ON (sensor_id) sensor_id, sensor_type, value
FROM readings
WHERE site_id = :site_id
AND sensor_type IN ('power_kw', 'temperature')
AND recorded_at > NOW() - INTERVAL '10 minutes'
ORDER BY sensor_id, recorded_at DESC
) latest
"""), {"site_id": site_id})
kpi_row = kpi_res.mappings().first() or {}
# Alarm stats (all-time by state/severity)
alarm_res = await session.execute(text("""
SELECT state, severity, COUNT(*) AS cnt
FROM alarms
WHERE site_id = :site_id
GROUP BY state, severity
"""), {"site_id": site_id})
alarm_stats: dict = {"active": 0, "acknowledged": 0, "resolved": 0, "critical": 0, "warning": 0}
for row in alarm_res.mappings().all():
if row["state"] in alarm_stats:
alarm_stats[row["state"]] += int(row["cnt"])
if row["severity"] in ("critical", "warning"):
alarm_stats[row["severity"]] += int(row["cnt"])
# CRAC uptime % over last 24h
from_24h = datetime.now(timezone.utc) - timedelta(hours=24)
total_buckets = 24 * 12 # one 5-min bucket per 5 minutes
cracs = []
for room in ROOMS.get(site_id, []):
crac_id = room["crac_id"]
try:
r = await session.execute(text("""
SELECT COUNT(DISTINCT time_bucket('5 minutes', recorded_at)) AS buckets
FROM readings
WHERE site_id = :site_id
AND sensor_id LIKE :pattern
AND sensor_type = 'cooling_supply'
AND recorded_at > :from_time
"""), {"site_id": site_id, "pattern": f"{site_id}/cooling/{crac_id}/%", "from_time": from_24h})
except Exception:
r = await session.execute(text("""
SELECT COUNT(DISTINCT date_trunc('minute', recorded_at)) AS buckets
FROM readings
WHERE site_id = :site_id
AND sensor_id LIKE :pattern
AND sensor_type = 'cooling_supply'
AND recorded_at > :from_time
"""), {"site_id": site_id, "pattern": f"{site_id}/cooling/{crac_id}/%", "from_time": from_24h})
row = r.mappings().first()
buckets = int(row["buckets"]) if row and row["buckets"] else 0
cracs.append({
"crac_id": crac_id,
"room_id": room["room_id"],
"uptime_pct": round(min(100.0, buckets / total_buckets * 100), 1),
})
# UPS uptime % over last 24h
ups_units = []
for ups_id in UPS_IDS.get(site_id, []):
try:
r = await session.execute(text("""
SELECT COUNT(DISTINCT time_bucket('5 minutes', recorded_at)) AS buckets
FROM readings
WHERE site_id = :site_id
AND sensor_id LIKE :pattern
AND sensor_type = 'ups_charge'
AND recorded_at > :from_time
"""), {"site_id": site_id, "pattern": f"{site_id}/ups/{ups_id}/%", "from_time": from_24h})
except Exception:
r = await session.execute(text("""
SELECT COUNT(DISTINCT date_trunc('minute', recorded_at)) AS buckets
FROM readings
WHERE site_id = :site_id
AND sensor_id LIKE :pattern
AND sensor_type = 'ups_charge'
AND recorded_at > :from_time
"""), {"site_id": site_id, "pattern": f"{site_id}/ups/{ups_id}/%", "from_time": from_24h})
row = r.mappings().first()
buckets = int(row["buckets"]) if row and row["buckets"] else 0
ups_units.append({
"ups_id": ups_id,
"uptime_pct": round(min(100.0, buckets / total_buckets * 100), 1),
})
return {
"site_id": site_id,
"generated_at": datetime.now(timezone.utc).isoformat(),
"kpis": {
"total_power_kw": float(kpi_row.get("total_power_kw") or 0),
"avg_temperature": float(kpi_row.get("avg_temperature") or 0),
},
"alarm_stats": alarm_stats,
"crac_uptime": cracs,
"ups_uptime": ups_units,
}
@router.get("/export/power")
async def export_power(
site_id: str = Query(...),
hours: int = Query(24, ge=1, le=168),
session: AsyncSession = Depends(get_session),
):
"""Download power history as CSV."""
from_time = datetime.now(timezone.utc) - timedelta(hours=hours)
try:
result = await session.execute(text("""
SELECT
time_bucket('5 minutes', recorded_at) AS bucket,
room_id,
ROUND(SUM(value)::numeric, 2) AS total_kw
FROM readings
WHERE site_id = :site_id
AND sensor_type = 'power_kw'
AND room_id IS NOT NULL
AND recorded_at > :from_time
GROUP BY bucket, room_id
ORDER BY bucket ASC
"""), {"site_id": site_id, "from_time": from_time})
except Exception:
result = await session.execute(text("""
SELECT
date_trunc('minute', recorded_at) AS bucket,
room_id,
ROUND(SUM(value)::numeric, 2) AS total_kw
FROM readings
WHERE site_id = :site_id
AND sensor_type = 'power_kw'
AND room_id IS NOT NULL
AND recorded_at > :from_time
GROUP BY bucket, room_id
ORDER BY bucket ASC
"""), {"site_id": site_id, "from_time": from_time})
output = io.StringIO()
writer = csv.writer(output)
writer.writerow(["timestamp", "room_id", "total_kw"])
for row in result.mappings().all():
writer.writerow([row["bucket"], row["room_id"], row["total_kw"]])
output.seek(0)
return StreamingResponse(
iter([output.getvalue()]),
media_type="text/csv",
headers={"Content-Disposition": f"attachment; filename=power_{site_id}_{hours}h.csv"},
)
@router.get("/export/temperature")
async def export_temperature(
site_id: str = Query(...),
hours: int = Query(24, ge=1, le=168),
session: AsyncSession = Depends(get_session),
):
"""Download temperature history per rack as CSV."""
from_time = datetime.now(timezone.utc) - timedelta(hours=hours)
try:
result = await session.execute(text("""
SELECT
time_bucket('5 minutes', recorded_at) AS bucket,
rack_id, room_id,
ROUND(AVG(value)::numeric, 1) AS avg_temp
FROM readings
WHERE site_id = :site_id
AND sensor_type = 'temperature'
AND rack_id IS NOT NULL
AND recorded_at > :from_time
GROUP BY bucket, rack_id, room_id
ORDER BY bucket ASC
"""), {"site_id": site_id, "from_time": from_time})
except Exception:
result = await session.execute(text("""
SELECT
date_trunc('minute', recorded_at) AS bucket,
rack_id, room_id,
ROUND(AVG(value)::numeric, 1) AS avg_temp
FROM readings
WHERE site_id = :site_id
AND sensor_type = 'temperature'
AND rack_id IS NOT NULL
AND recorded_at > :from_time
GROUP BY bucket, rack_id, room_id
ORDER BY bucket ASC
"""), {"site_id": site_id, "from_time": from_time})
output = io.StringIO()
writer = csv.writer(output)
writer.writerow(["timestamp", "room_id", "rack_id", "avg_temp_c"])
for row in result.mappings().all():
writer.writerow([row["bucket"], row["room_id"], row["rack_id"], row["avg_temp"]])
output.seek(0)
return StreamingResponse(
iter([output.getvalue()]),
media_type="text/csv",
headers={"Content-Disposition": f"attachment; filename=temperature_{site_id}_{hours}h.csv"},
)
@router.get("/export/alarms")
async def export_alarms(
site_id: str = Query(...),
session: AsyncSession = Depends(get_session),
):
"""Download full alarm log as CSV."""
result = await session.execute(text("""
SELECT id, severity, message, state, room_id, rack_id, triggered_at
FROM alarms
WHERE site_id = :site_id
ORDER BY triggered_at DESC
"""), {"site_id": site_id})
output = io.StringIO()
writer = csv.writer(output)
writer.writerow(["id", "severity", "message", "state", "room_id", "rack_id", "triggered_at"])
for row in result.mappings().all():
writer.writerow([
row["id"], row["severity"], row["message"], row["state"],
row["room_id"], row["rack_id"], row["triggered_at"],
])
output.seek(0)
return StreamingResponse(
iter([output.getvalue()]),
media_type="text/csv",
headers={"Content-Disposition": f"attachment; filename=alarms_{site_id}.csv"},
)

View file

@ -0,0 +1,248 @@
"""
Scenario control API proxies trigger/reset commands to the MQTT broker
so the frontend can fire simulator scenarios over HTTP.
"""
import json
import asyncio
from fastapi import APIRouter, HTTPException
from pydantic import BaseModel
from typing import Optional
import aiomqtt
from core.config import settings
router = APIRouter()
# ── Scenario catalogue ───────────────────────────────────────────────────────
# Mirrors the definitions in simulators/scenarios/runner.py and compound.py.
# Kept here so the frontend has a single typed source of truth.
SCENARIOS = [
# ── Compound (multi-bot, time-sequenced) ─────────────────────────────────
{
"name": "HOT_NIGHT",
"label": "Hot Night",
"description": "CRAC-01 compressor trips silently. The backup unit overworks itself. Rack temps climb, power draw rises, VESDA alert fires.",
"duration": "~10 min",
"compound": True,
"default_target": None,
"targets": [],
},
{
"name": "GENERATOR_TEST_GONE_WRONG",
"label": "Generator Test Gone Wrong",
"description": "Planned ATS transfer to generator. Generator was low on fuel and faults after 15 min. UPS must carry full site load alone.",
"duration": "~16 min",
"compound": True,
"default_target": None,
"targets": [],
},
{
"name": "SLOW_BURN",
"label": "Slow Burn",
"description": "A dirty filter nobody noticed. Airflow degrades for 30 min. Temps creep, humidity climbs, VESDA alerts, then CRAC trips on thermal protection.",
"duration": "~30 min",
"compound": True,
"default_target": None,
"targets": [],
},
{
"name": "LAST_RESORT",
"label": "Last Resort",
"description": "Utility fails. Generator starts then faults after 2 minutes. UPS absorbs the full load, overheats, and VESDA escalates to fire.",
"duration": "~9 min",
"compound": True,
"default_target": None,
"targets": [],
},
# ── Cooling ──────────────────────────────────────────────────────────────
{
"name": "COOLING_FAILURE",
"label": "Cooling Failure",
"description": "CRAC unit goes offline — rack temperatures rise rapidly.",
"duration": "ongoing",
"compound": False,
"default_target": "crac-01",
"targets": ["crac-01", "crac-02"],
},
{
"name": "FAN_DEGRADATION",
"label": "Fan Degradation",
"description": "CRAC fan bearing wear — fan speed drops, ΔT rises over ~25 min.",
"duration": "~25 min",
"compound": False,
"default_target": "crac-01",
"targets": ["crac-01", "crac-02"],
},
{
"name": "COMPRESSOR_FAULT",
"label": "Compressor Fault",
"description": "Compressor trips — unit drops to fan-only, cooling capacity collapses to ~8%.",
"duration": "ongoing",
"compound": False,
"default_target": "crac-01",
"targets": ["crac-01", "crac-02"],
},
{
"name": "DIRTY_FILTER",
"label": "Dirty Filter",
"description": "Filter fouling — ΔP rises, airflow and capacity degrade over time.",
"duration": "ongoing",
"compound": False,
"default_target": "crac-01",
"targets": ["crac-01", "crac-02"],
},
{
"name": "HIGH_TEMPERATURE",
"label": "High Temperature",
"description": "Gradual ambient heat rise — slower than a full cooling failure.",
"duration": "ongoing",
"compound": False,
"default_target": "hall-a",
"targets": ["hall-a", "hall-b"],
},
{
"name": "HUMIDITY_SPIKE",
"label": "Humidity Spike",
"description": "Humidity climbs — condensation / humidifier fault risk.",
"duration": "ongoing",
"compound": False,
"default_target": "hall-a",
"targets": ["hall-a", "hall-b"],
},
{
"name": "CHILLER_FAULT",
"label": "Chiller Fault",
"description": "Chiller plant trips — chilled water supply lost.",
"duration": "ongoing",
"compound": False,
"default_target": "chiller-01",
"targets": ["chiller-01"],
},
# ── Power ────────────────────────────────────────────────────────────────
{
"name": "UPS_MAINS_FAILURE",
"label": "UPS Mains Failure",
"description": "Mains power lost — UPS switches to battery and drains.",
"duration": "~60 min",
"compound": False,
"default_target": "ups-01",
"targets": ["ups-01", "ups-02"],
},
{
"name": "POWER_SPIKE",
"label": "Power Spike",
"description": "PDU load surges across a room by up to 50%.",
"duration": "ongoing",
"compound": False,
"default_target": "hall-a",
"targets": ["hall-a", "hall-b"],
},
{
"name": "RACK_OVERLOAD",
"label": "Rack Overload",
"description": "Single rack redlines at ~8595% of rated 10 kW capacity.",
"duration": "ongoing",
"compound": False,
"default_target": "SG1A01.10",
"targets": ["SG1A01.10", "SG1B01.10"],
},
{
"name": "PHASE_IMBALANCE",
"label": "Phase Imbalance",
"description": "PDU phase A overloads, phase C drops — imbalance flag triggers.",
"duration": "ongoing",
"compound": False,
"default_target": "SG1A01.10/pdu",
"targets": ["SG1A01.10/pdu", "SG1B01.10/pdu"],
},
{
"name": "ATS_TRANSFER",
"label": "ATS Transfer",
"description": "Utility feed lost — ATS transfers load to generator.",
"duration": "ongoing",
"compound": False,
"default_target": "ats-01",
"targets": ["ats-01"],
},
{
"name": "GENERATOR_FAILURE",
"label": "Generator Running",
"description": "Generator starts and runs under load following a utility failure.",
"duration": "ongoing",
"compound": False,
"default_target": "gen-01",
"targets": ["gen-01"],
},
{
"name": "GENERATOR_LOW_FUEL",
"label": "Generator Low Fuel",
"description": "Generator fuel level drains to critical low.",
"duration": "ongoing",
"compound": False,
"default_target": "gen-01",
"targets": ["gen-01"],
},
{
"name": "GENERATOR_FAULT",
"label": "Generator Fault",
"description": "Generator fails — fault state, no output.",
"duration": "ongoing",
"compound": False,
"default_target": "gen-01",
"targets": ["gen-01"],
},
# ── Environmental / Life Safety ──────────────────────────────────────────
{
"name": "LEAK_DETECTED",
"label": "Leak Detected",
"description": "Water leak sensor triggers a critical alarm.",
"duration": "ongoing",
"compound": False,
"default_target": "leak-01",
"targets": ["leak-01", "leak-02", "leak-03"],
},
{
"name": "VESDA_ALERT",
"label": "VESDA Alert",
"description": "Smoke obscuration rises into the Alert/Action band.",
"duration": "ongoing",
"compound": False,
"default_target": "vesda-hall-a",
"targets": ["vesda-hall-a", "vesda-hall-b"],
},
{
"name": "VESDA_FIRE",
"label": "VESDA Fire",
"description": "Smoke obscuration escalates to Fire level.",
"duration": "ongoing",
"compound": False,
"default_target": "vesda-hall-a",
"targets": ["vesda-hall-a", "vesda-hall-b"],
},
]
# ── Request / Response models ────────────────────────────────────────────────
class TriggerRequest(BaseModel):
scenario: str
target: Optional[str] = None
# ── Endpoints ────────────────────────────────────────────────────────────────
@router.get("")
async def list_scenarios():
return SCENARIOS
@router.post("/trigger")
async def trigger_scenario(body: TriggerRequest):
payload = json.dumps({"scenario": body.scenario, "target": body.target})
try:
async with aiomqtt.Client(settings.MQTT_HOST, port=settings.MQTT_PORT) as client:
await client.publish("bms/control/scenario", payload, qos=1)
except Exception as e:
raise HTTPException(status_code=503, detail=f"MQTT unavailable: {e}")
return {"ok": True, "scenario": body.scenario, "target": body.target}

View file

@ -0,0 +1,465 @@
import json
import logging
from typing import Any
from fastapi import APIRouter, Depends, HTTPException, Query
from pydantic import BaseModel
from sqlalchemy import text
from sqlalchemy.ext.asyncio import AsyncSession
from core.database import get_session
from services.alarm_engine import invalidate_threshold_cache
from services.seed import THRESHOLD_SEED_DATA, DEFAULT_SETTINGS, SITE_ID as DEFAULT_SITE
router = APIRouter()
logger = logging.getLogger(__name__)
# ── Pydantic models ────────────────────────────────────────────────────────────
class SensorCreate(BaseModel):
device_id: str
name: str
device_type: str
room_id: str | None = None
rack_id: str | None = None
protocol: str = "mqtt"
protocol_config: dict[str, Any] = {}
enabled: bool = True
class SensorUpdate(BaseModel):
name: str | None = None
device_type: str | None = None
room_id: str | None = None
rack_id: str | None = None
protocol: str | None = None
protocol_config: dict[str, Any] | None = None
enabled: bool | None = None
class ThresholdUpdate(BaseModel):
threshold_value: float | None = None
severity: str | None = None
enabled: bool | None = None
class ThresholdCreate(BaseModel):
sensor_type: str
threshold_value: float
direction: str
severity: str
message_template: str
class SettingsUpdate(BaseModel):
value: dict[str, Any]
# ── Sensors ────────────────────────────────────────────────────────────────────
@router.get("/sensors")
async def list_sensors(
site_id: str = Query(DEFAULT_SITE),
device_type: str | None = Query(None),
room_id: str | None = Query(None),
protocol: str | None = Query(None),
session: AsyncSession = Depends(get_session),
):
"""List all sensor devices, with optional filters."""
conditions = ["site_id = :site_id"]
params: dict = {"site_id": site_id}
if device_type:
conditions.append("device_type = :device_type")
params["device_type"] = device_type
if room_id:
conditions.append("room_id = :room_id")
params["room_id"] = room_id
if protocol:
conditions.append("protocol = :protocol")
params["protocol"] = protocol
where = " AND ".join(conditions)
result = await session.execute(text(f"""
SELECT id, site_id, device_id, name, device_type, room_id, rack_id,
protocol, protocol_config, enabled, created_at, updated_at
FROM sensors
WHERE {where}
ORDER BY device_type, room_id NULLS LAST, device_id
"""), params)
return [dict(r) for r in result.mappings().all()]
@router.post("/sensors", status_code=201)
async def create_sensor(
body: SensorCreate,
site_id: str = Query(DEFAULT_SITE),
session: AsyncSession = Depends(get_session),
):
"""Register a new sensor device."""
result = await session.execute(text("""
INSERT INTO sensors
(site_id, device_id, name, device_type, room_id, rack_id,
protocol, protocol_config, enabled)
VALUES
(:site_id, :device_id, :name, :device_type, :room_id, :rack_id,
:protocol, :protocol_config, :enabled)
RETURNING id, site_id, device_id, name, device_type, room_id, rack_id,
protocol, protocol_config, enabled, created_at, updated_at
"""), {
"site_id": site_id,
"device_id": body.device_id,
"name": body.name,
"device_type": body.device_type,
"room_id": body.room_id,
"rack_id": body.rack_id,
"protocol": body.protocol,
"protocol_config": json.dumps(body.protocol_config),
"enabled": body.enabled,
})
await session.commit()
return dict(result.mappings().first())
@router.get("/sensors/{sensor_id}")
async def get_sensor(
sensor_id: int,
session: AsyncSession = Depends(get_session),
):
"""Get a single sensor device plus its most recent readings."""
result = await session.execute(text("""
SELECT id, site_id, device_id, name, device_type, room_id, rack_id,
protocol, protocol_config, enabled, created_at, updated_at
FROM sensors WHERE id = :id
"""), {"id": sensor_id})
row = result.mappings().first()
if not row:
raise HTTPException(status_code=404, detail="Sensor not found")
sensor = dict(row)
# Fetch latest readings for this device
readings_result = await session.execute(text("""
SELECT DISTINCT ON (sensor_type)
sensor_type, value, unit, recorded_at
FROM readings
WHERE site_id = :site_id
AND sensor_id LIKE :pattern
AND recorded_at > NOW() - INTERVAL '10 minutes'
ORDER BY sensor_type, recorded_at DESC
"""), {
"site_id": sensor["site_id"],
"pattern": f"{sensor['site_id']}%{sensor['device_id']}%",
})
sensor["recent_readings"] = [dict(r) for r in readings_result.mappings().all()]
return sensor
@router.put("/sensors/{sensor_id}")
async def update_sensor(
sensor_id: int,
body: SensorUpdate,
session: AsyncSession = Depends(get_session),
):
"""Update a sensor device's config or toggle enabled."""
updates = []
params: dict = {"id": sensor_id}
if body.name is not None:
updates.append("name = :name")
params["name"] = body.name
if body.device_type is not None:
updates.append("device_type = :device_type")
params["device_type"] = body.device_type
if body.room_id is not None:
updates.append("room_id = :room_id")
params["room_id"] = body.room_id
if body.rack_id is not None:
updates.append("rack_id = :rack_id")
params["rack_id"] = body.rack_id
if body.protocol is not None:
updates.append("protocol = :protocol")
params["protocol"] = body.protocol
if body.protocol_config is not None:
updates.append("protocol_config = :protocol_config")
params["protocol_config"] = json.dumps(body.protocol_config)
if body.enabled is not None:
updates.append("enabled = :enabled")
params["enabled"] = body.enabled
if not updates:
raise HTTPException(status_code=400, detail="No fields to update")
updates.append("updated_at = NOW()")
set_clause = ", ".join(updates)
result = await session.execute(text(f"""
UPDATE sensors SET {set_clause}
WHERE id = :id
RETURNING id, site_id, device_id, name, device_type, room_id, rack_id,
protocol, protocol_config, enabled, created_at, updated_at
"""), params)
row = result.mappings().first()
if not row:
raise HTTPException(status_code=404, detail="Sensor not found")
await session.commit()
return dict(row)
@router.delete("/sensors/{sensor_id}", status_code=204)
async def delete_sensor(
sensor_id: int,
session: AsyncSession = Depends(get_session),
):
"""Remove a sensor device from the registry."""
result = await session.execute(
text("DELETE FROM sensors WHERE id = :id RETURNING id"),
{"id": sensor_id},
)
if not result.fetchone():
raise HTTPException(status_code=404, detail="Sensor not found")
await session.commit()
# ── Alarm thresholds ───────────────────────────────────────────────────────────
@router.get("/thresholds")
async def list_thresholds(
site_id: str = Query(DEFAULT_SITE),
session: AsyncSession = Depends(get_session),
):
"""Return all user-editable threshold rules (locked=false)."""
result = await session.execute(text("""
SELECT id, site_id, sensor_type, threshold_value, direction,
severity, message_template, enabled, locked, created_at, updated_at
FROM alarm_thresholds
WHERE site_id = :site_id AND locked = false
ORDER BY id
"""), {"site_id": site_id})
return [dict(r) for r in result.mappings().all()]
@router.put("/thresholds/{threshold_id}")
async def update_threshold(
threshold_id: int,
body: ThresholdUpdate,
session: AsyncSession = Depends(get_session),
):
"""Update a threshold value, severity, or enabled state."""
# Refuse to update locked rules
locked_result = await session.execute(
text("SELECT locked, site_id FROM alarm_thresholds WHERE id = :id"),
{"id": threshold_id},
)
row = locked_result.mappings().first()
if not row:
raise HTTPException(status_code=404, detail="Threshold not found")
if row["locked"]:
raise HTTPException(status_code=403, detail="Cannot modify locked threshold")
updates = []
params: dict = {"id": threshold_id}
if body.threshold_value is not None:
updates.append("threshold_value = :threshold_value")
params["threshold_value"] = body.threshold_value
if body.severity is not None:
if body.severity not in ("warning", "critical"):
raise HTTPException(status_code=400, detail="severity must be warning or critical")
updates.append("severity = :severity")
params["severity"] = body.severity
if body.enabled is not None:
updates.append("enabled = :enabled")
params["enabled"] = body.enabled
if not updates:
raise HTTPException(status_code=400, detail="No fields to update")
updates.append("updated_at = NOW()")
set_clause = ", ".join(updates)
result = await session.execute(text(f"""
UPDATE alarm_thresholds SET {set_clause}
WHERE id = :id
RETURNING id, site_id, sensor_type, threshold_value, direction,
severity, message_template, enabled, locked, updated_at
"""), params)
await session.commit()
invalidate_threshold_cache(row["site_id"])
return dict(result.mappings().first())
@router.post("/thresholds", status_code=201)
async def create_threshold(
body: ThresholdCreate,
site_id: str = Query(DEFAULT_SITE),
session: AsyncSession = Depends(get_session),
):
"""Add a custom threshold rule."""
if body.direction not in ("above", "below"):
raise HTTPException(status_code=400, detail="direction must be above or below")
if body.severity not in ("warning", "critical"):
raise HTTPException(status_code=400, detail="severity must be warning or critical")
result = await session.execute(text("""
INSERT INTO alarm_thresholds
(site_id, sensor_type, threshold_value, direction, severity, message_template, enabled, locked)
VALUES
(:site_id, :sensor_type, :threshold_value, :direction, :severity, :message_template, true, false)
RETURNING id, site_id, sensor_type, threshold_value, direction,
severity, message_template, enabled, locked, created_at, updated_at
"""), {
"site_id": site_id,
"sensor_type": body.sensor_type,
"threshold_value": body.threshold_value,
"direction": body.direction,
"severity": body.severity,
"message_template": body.message_template,
})
await session.commit()
invalidate_threshold_cache(site_id)
return dict(result.mappings().first())
@router.delete("/thresholds/{threshold_id}", status_code=204)
async def delete_threshold(
threshold_id: int,
session: AsyncSession = Depends(get_session),
):
"""Delete a custom (non-locked) threshold rule."""
locked_result = await session.execute(
text("SELECT locked, site_id FROM alarm_thresholds WHERE id = :id"),
{"id": threshold_id},
)
row = locked_result.mappings().first()
if not row:
raise HTTPException(status_code=404, detail="Threshold not found")
if row["locked"]:
raise HTTPException(status_code=403, detail="Cannot delete locked threshold")
await session.execute(
text("DELETE FROM alarm_thresholds WHERE id = :id"),
{"id": threshold_id},
)
await session.commit()
invalidate_threshold_cache(row["site_id"])
@router.post("/thresholds/reset")
async def reset_thresholds(
site_id: str = Query(DEFAULT_SITE),
session: AsyncSession = Depends(get_session),
):
"""Delete all thresholds for a site and re-seed from defaults."""
await session.execute(
text("DELETE FROM alarm_thresholds WHERE site_id = :site_id"),
{"site_id": site_id},
)
for st, tv, direction, severity, msg, locked in THRESHOLD_SEED_DATA:
await session.execute(text("""
INSERT INTO alarm_thresholds
(site_id, sensor_type, threshold_value, direction, severity, message_template, enabled, locked)
VALUES
(:site_id, :sensor_type, :threshold_value, :direction, :severity, :message_template, true, :locked)
"""), {
"site_id": site_id, "sensor_type": st, "threshold_value": tv,
"direction": direction, "severity": severity,
"message_template": msg, "locked": locked,
})
await session.commit()
invalidate_threshold_cache(site_id)
logger.info(f"Alarm thresholds reset to defaults for {site_id}")
return {"ok": True, "count": len(THRESHOLD_SEED_DATA)}
# ── Generic settings (site / notifications / integrations / page_prefs) ────────
async def _get_settings(session: AsyncSession, site_id: str, category: str) -> dict:
result = await session.execute(text("""
SELECT value FROM site_settings
WHERE site_id = :site_id AND category = :category AND key = 'config'
"""), {"site_id": site_id, "category": category})
row = result.mappings().first()
if row:
return row["value"] if isinstance(row["value"], dict) else json.loads(row["value"])
return DEFAULT_SETTINGS.get(category, {})
async def _put_settings(
session: AsyncSession, site_id: str, category: str, updates: dict
) -> dict:
current = await _get_settings(session, site_id, category)
merged = {**current, **updates}
await session.execute(text("""
INSERT INTO site_settings (site_id, category, key, value, updated_at)
VALUES (:site_id, :category, 'config', :value, NOW())
ON CONFLICT (site_id, category, key)
DO UPDATE SET value = :value, updated_at = NOW()
"""), {"site_id": site_id, "category": category, "value": json.dumps(merged)})
await session.commit()
return merged
@router.get("/site")
async def get_site_settings(
site_id: str = Query(DEFAULT_SITE),
session: AsyncSession = Depends(get_session),
):
return await _get_settings(session, site_id, "site")
@router.put("/site")
async def update_site_settings(
body: SettingsUpdate,
site_id: str = Query(DEFAULT_SITE),
session: AsyncSession = Depends(get_session),
):
return await _put_settings(session, site_id, "site", body.value)
@router.get("/notifications")
async def get_notifications(
site_id: str = Query(DEFAULT_SITE),
session: AsyncSession = Depends(get_session),
):
return await _get_settings(session, site_id, "notifications")
@router.put("/notifications")
async def update_notifications(
body: SettingsUpdate,
site_id: str = Query(DEFAULT_SITE),
session: AsyncSession = Depends(get_session),
):
return await _put_settings(session, site_id, "notifications", body.value)
@router.get("/integrations")
async def get_integrations(
site_id: str = Query(DEFAULT_SITE),
session: AsyncSession = Depends(get_session),
):
return await _get_settings(session, site_id, "integrations")
@router.put("/integrations")
async def update_integrations(
body: SettingsUpdate,
site_id: str = Query(DEFAULT_SITE),
session: AsyncSession = Depends(get_session),
):
return await _put_settings(session, site_id, "integrations", body.value)
@router.get("/page-prefs")
async def get_page_prefs(
site_id: str = Query(DEFAULT_SITE),
session: AsyncSession = Depends(get_session),
):
return await _get_settings(session, site_id, "page_prefs")
@router.put("/page-prefs")
async def update_page_prefs(
body: SettingsUpdate,
site_id: str = Query(DEFAULT_SITE),
session: AsyncSession = Depends(get_session),
):
return await _put_settings(session, site_id, "page_prefs", body.value)

View file

@ -0,0 +1,36 @@
from fastapi import APIRouter
from pydantic import BaseModel
router = APIRouter()
class Site(BaseModel):
id: str
name: str
location: str
status: str
rack_count: int
total_power_kw: float
pue: float
# Static stub data — will be replaced by DB queries in Phase 2
SITES: list[Site] = [
Site(id="sg-01", name="Singapore DC01", location="Singapore", status="ok", rack_count=128, total_power_kw=847.0, pue=1.42),
Site(id="sg-02", name="Singapore DC02", location="Singapore", status="warning", rack_count=64, total_power_kw=412.0, pue=1.51),
Site(id="lon-01", name="London DC01", location="London", status="ok", rack_count=96, total_power_kw=631.0, pue=1.38),
]
@router.get("", response_model=list[Site])
async def list_sites():
return SITES
@router.get("/{site_id}", response_model=Site)
async def get_site(site_id: str):
for site in SITES:
if site.id == site_id:
return site
from fastapi import HTTPException
raise HTTPException(status_code=404, detail="Site not found")

16
backend/api/routes/ws.py Normal file
View file

@ -0,0 +1,16 @@
from fastapi import APIRouter, WebSocket, WebSocketDisconnect
from services.ws_manager import manager
router = APIRouter()
@router.websocket("/ws")
async def websocket_endpoint(ws: WebSocket):
await manager.connect(ws)
try:
while True:
# We only push from server → client.
# receive_text() keeps the connection alive.
await ws.receive_text()
except WebSocketDisconnect:
manager.disconnect(ws)

0
backend/core/__init__.py Normal file
View file

27
backend/core/config.py Normal file
View file

@ -0,0 +1,27 @@
from pydantic_settings import BaseSettings, SettingsConfigDict
class Settings(BaseSettings):
model_config = SettingsConfigDict(env_file=".env", extra="ignore")
# App
APP_NAME: str = "DemoBMS API"
DEBUG: bool = False
# Database
DATABASE_URL: str = "postgresql+asyncpg://dcim:dcim_pass@db:5432/dcim"
# MQTT broker
MQTT_HOST: str = "localhost"
MQTT_PORT: int = 1883
# CORS
CORS_ORIGINS: list[str] = []
# Clerk
CLERK_PUBLISHABLE_KEY: str = ""
CLERK_SECRET_KEY: str = ""
CLERK_JWKS_URL: str = ""
settings = Settings()

130
backend/core/database.py Normal file
View file

@ -0,0 +1,130 @@
import logging
from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession, async_sessionmaker
from sqlalchemy import text
from core.config import settings
logger = logging.getLogger(__name__)
engine = create_async_engine(settings.DATABASE_URL, echo=False, pool_size=10, max_overflow=20)
AsyncSessionLocal = async_sessionmaker(engine, expire_on_commit=False)
async def init_db() -> None:
async with engine.begin() as conn:
# Enable TimescaleDB
await conn.execute(text("CREATE EXTENSION IF NOT EXISTS timescaledb CASCADE"))
# Sensor readings — core time-series table
await conn.execute(text("""
CREATE TABLE IF NOT EXISTS readings (
recorded_at TIMESTAMPTZ NOT NULL,
sensor_id VARCHAR(120) NOT NULL,
sensor_type VARCHAR(50) NOT NULL,
site_id VARCHAR(50) NOT NULL,
room_id VARCHAR(50),
rack_id VARCHAR(50),
value DOUBLE PRECISION NOT NULL,
unit VARCHAR(20)
)
"""))
# Convert to hypertable — no-op if already one
try:
await conn.execute(text(
"SELECT create_hypertable('readings', by_range('recorded_at'), if_not_exists => TRUE)"
))
except Exception:
try:
await conn.execute(text(
"SELECT create_hypertable('readings', 'recorded_at', if_not_exists => TRUE)"
))
except Exception as e:
logger.warning(f"Hypertable setup skipped (table still works): {e}")
await conn.execute(text("""
CREATE INDEX IF NOT EXISTS idx_readings_sensor_time
ON readings (sensor_id, recorded_at DESC)
"""))
# Alarms table
await conn.execute(text("""
CREATE TABLE IF NOT EXISTS alarms (
id BIGSERIAL PRIMARY KEY,
sensor_id VARCHAR(120),
site_id VARCHAR(50),
room_id VARCHAR(50),
rack_id VARCHAR(50),
severity VARCHAR(20) NOT NULL,
message TEXT NOT NULL,
state VARCHAR(20) NOT NULL DEFAULT 'active',
triggered_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
acknowledged_at TIMESTAMPTZ,
resolved_at TIMESTAMPTZ
)
"""))
# Site config — generic key/value JSON store (used for floor layout etc.)
await conn.execute(text("""
CREATE TABLE IF NOT EXISTS site_config (
site_id VARCHAR(50) NOT NULL,
key VARCHAR(100) NOT NULL,
value JSONB NOT NULL,
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
PRIMARY KEY (site_id, key)
)
"""))
# Sensor device registry
await conn.execute(text("""
CREATE TABLE IF NOT EXISTS sensors (
id SERIAL PRIMARY KEY,
site_id VARCHAR(50) NOT NULL,
device_id VARCHAR(100) NOT NULL,
name VARCHAR(200) NOT NULL,
device_type VARCHAR(50) NOT NULL,
room_id VARCHAR(50),
rack_id VARCHAR(50),
protocol VARCHAR(30) NOT NULL DEFAULT 'mqtt',
protocol_config JSONB NOT NULL DEFAULT '{}',
enabled BOOLEAN NOT NULL DEFAULT true,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE(site_id, device_id)
)
"""))
# Configurable alarm thresholds (replaces hard-coded list at runtime)
await conn.execute(text("""
CREATE TABLE IF NOT EXISTS alarm_thresholds (
id SERIAL PRIMARY KEY,
site_id VARCHAR(50) NOT NULL,
sensor_type VARCHAR(50) NOT NULL,
threshold_value FLOAT NOT NULL,
direction VARCHAR(10) NOT NULL,
severity VARCHAR(20) NOT NULL,
message_template TEXT NOT NULL,
enabled BOOLEAN NOT NULL DEFAULT true,
locked BOOLEAN NOT NULL DEFAULT false,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
)
"""))
# Site-level settings (profile, notifications, integrations, page prefs)
await conn.execute(text("""
CREATE TABLE IF NOT EXISTS site_settings (
site_id VARCHAR(50) NOT NULL,
category VARCHAR(50) NOT NULL,
key VARCHAR(100) NOT NULL,
value JSONB NOT NULL,
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
PRIMARY KEY (site_id, category, key)
)
"""))
logger.info("Database initialised")
async def get_session():
async with AsyncSessionLocal() as session:
yield session

73
backend/main.py Normal file
View file

@ -0,0 +1,73 @@
import asyncio
import logging
from contextlib import asynccontextmanager
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from core.config import settings
from core.database import init_db, AsyncSessionLocal
from services.mqtt_subscriber import run_subscriber
from services.seed import run_all_seeds
from api.routes import (
health, sites, readings, alarms, ws, assets,
power, env, reports, capacity,
generator, fire, cooling, leak, network, maintenance, floor_layout,
scenarios, settings as settings_router,
)
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(name)s %(levelname)s %(message)s")
logger = logging.getLogger(__name__)
@asynccontextmanager
async def lifespan(app: FastAPI):
logger.info(f"Starting {settings.APP_NAME}")
await init_db()
async with AsyncSessionLocal() as session:
await run_all_seeds(session)
# Start MQTT subscriber as a background task
task = asyncio.create_task(run_subscriber())
yield
task.cancel()
try:
await task
except asyncio.CancelledError:
pass
logger.info("Shutdown complete")
app = FastAPI(
title=settings.APP_NAME,
version="0.2.0",
docs_url="/docs",
redoc_url="/redoc",
lifespan=lifespan,
)
app.add_middleware(
CORSMiddleware,
allow_origins=settings.CORS_ORIGINS,
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
app.include_router(health.router, prefix="/api", tags=["health"])
app.include_router(sites.router, prefix="/api/sites", tags=["sites"])
app.include_router(readings.router, prefix="/api/readings", tags=["readings"])
app.include_router(alarms.router, prefix="/api/alarms", tags=["alarms"])
app.include_router(ws.router, prefix="/api", tags=["websocket"])
app.include_router(assets.router, prefix="/api/assets", tags=["assets"])
app.include_router(power.router, prefix="/api/power", tags=["power"])
app.include_router(env.router, prefix="/api/env", tags=["env"])
app.include_router(reports.router, prefix="/api/reports", tags=["reports"])
app.include_router(capacity.router, prefix="/api/capacity", tags=["capacity"])
app.include_router(generator.router, prefix="/api/generator", tags=["generator"])
app.include_router(fire.router, prefix="/api/fire", tags=["fire"])
app.include_router(cooling.router, prefix="/api/cooling", tags=["cooling"])
app.include_router(leak.router, prefix="/api/leak", tags=["leak"])
app.include_router(network.router, prefix="/api/network", tags=["network"])
app.include_router(maintenance.router, prefix="/api/maintenance", tags=["maintenance"])
app.include_router(floor_layout.router, prefix="/api/floor-layout", tags=["floor-layout"])
app.include_router(scenarios.router, prefix="/api/scenarios", tags=["scenarios"])
app.include_router(settings_router.router, prefix="/api/settings", tags=["settings"])

View file

11
backend/requirements.txt Normal file
View file

@ -0,0 +1,11 @@
fastapi==0.115.6
uvicorn[standard]==0.32.1
pydantic==2.10.4
pydantic-settings==2.7.0
python-dotenv==1.0.1
asyncpg==0.30.0
sqlalchemy[asyncio]==2.0.36
aiomqtt==2.3.0
httpx==0.28.1
python-jose[cryptography]==3.3.0
python-multipart==0.0.20

View file

View file

@ -0,0 +1,152 @@
import logging
from sqlalchemy import text
from sqlalchemy.ext.asyncio import AsyncSession
logger = logging.getLogger(__name__)
# ── In-memory threshold cache ──────────────────────────────────────────────────
# Loaded from DB on first use; invalidated by settings API after updates.
# Falls back to hard-coded defaults if DB has no rows yet (pre-seed).
_caches: dict[str, list[dict]] = {}
_dirty_sites: set[str] = {"sg-01"} # start dirty so first request loads from DB
def invalidate_threshold_cache(site_id: str = "sg-01") -> None:
"""Mark a site's cache as stale. Called by settings API after threshold changes."""
_dirty_sites.add(site_id)
async def _ensure_cache(session: AsyncSession, site_id: str) -> None:
if site_id not in _dirty_sites and site_id in _caches:
return
result = await session.execute(text("""
SELECT sensor_type, threshold_value, direction, severity, message_template
FROM alarm_thresholds
WHERE site_id = :site_id AND enabled = true
ORDER BY id
"""), {"site_id": site_id})
rows = result.mappings().all()
if rows:
_caches[site_id] = [dict(r) for r in rows]
else:
# DB not yet seeded — fall back to hard-coded defaults
_caches[site_id] = _FALLBACK_RULES
_dirty_sites.discard(site_id)
logger.info(f"Loaded {len(_caches[site_id])} threshold rules for {site_id}")
async def check_and_update_alarms(
session: AsyncSession,
sensor_id: str,
sensor_type: str,
site_id: str,
room_id: str | None,
rack_id: str | None,
value: float,
) -> None:
await _ensure_cache(session, site_id)
for rule in _caches.get(site_id, []):
if rule["sensor_type"] != sensor_type:
continue
threshold = rule["threshold_value"]
direction = rule["direction"]
severity = rule["severity"]
msg_tpl = rule["message_template"]
breached = (
(direction == "above" and value > threshold) or
(direction == "below" and value < threshold)
)
if breached:
existing = await session.execute(text("""
SELECT id FROM alarms
WHERE sensor_id = :sid AND severity = :sev AND state = 'active'
LIMIT 1
"""), {"sid": sensor_id, "sev": severity})
if not existing.fetchone():
message = msg_tpl.format(value=value, sensor_id=sensor_id)
await session.execute(text("""
INSERT INTO alarms
(sensor_id, site_id, room_id, rack_id, severity, message, state, triggered_at)
VALUES
(:sensor_id, :site_id, :room_id, :rack_id, :severity, :message, 'active', NOW())
"""), {
"sensor_id": sensor_id, "site_id": site_id,
"room_id": room_id, "rack_id": rack_id,
"severity": severity, "message": message,
})
logger.info(f"Alarm raised [{severity}]: {message}")
else:
await session.execute(text("""
UPDATE alarms
SET state = 'resolved', resolved_at = NOW()
WHERE sensor_id = :sid AND severity = :sev AND state = 'active'
"""), {"sid": sensor_id, "sev": severity})
# ── Hard-coded fallback (used before DB seed runs) ─────────────────────────────
_FALLBACK_RULES: list[dict] = [
{"sensor_type": st, "threshold_value": tv, "direction": d, "severity": s, "message_template": m}
for st, tv, d, s, m in [
("temperature", 28.0, "above", "warning", "Temperature elevated at {sensor_id}: {value:.1f}°C"),
("temperature", 32.0, "above", "critical", "Temperature critical at {sensor_id}: {value:.1f}°C"),
("humidity", 65.0, "above", "warning", "Humidity elevated at {sensor_id}: {value:.0f}%"),
("power_kw", 7.5, "above", "warning", "PDU load elevated at {sensor_id}: {value:.1f} kW"),
("power_kw", 9.5, "above", "critical", "PDU load critical at {sensor_id}: {value:.1f} kW"),
("ups_charge", 80.0, "below", "warning", "UPS battery low at {sensor_id}: {value:.0f}%"),
("ups_charge", 50.0, "below", "critical", "UPS battery critical at {sensor_id}: {value:.0f}%"),
("ups_state", 0.5, "above", "critical", "UPS switched to battery at {sensor_id} — mains power lost"),
("ups_state", 1.5, "above", "critical", "UPS overloaded at {sensor_id} — immediate risk of failure"),
("ups_load", 85.0, "above", "warning", "UPS load high at {sensor_id}: {value:.0f}%"),
("ups_load", 95.0, "above", "critical", "UPS load critical at {sensor_id}: {value:.0f}% — overload"),
("ups_runtime", 15.0, "below", "warning", "UPS runtime low at {sensor_id}: {value:.0f} min remaining"),
("ups_runtime", 5.0, "below", "critical", "UPS runtime critical at {sensor_id}: {value:.0f} min — imminent shutdown"),
("leak", 0.5, "above", "critical", "Water leak detected at {sensor_id}!"),
("cooling_cap_pct", 90.0, "above", "warning", "CRAC near capacity limit at {sensor_id}: {value:.1f}%"),
("cooling_cop", 1.5, "below", "warning", "CRAC running inefficiently at {sensor_id}: COP {value:.2f}"),
("cooling_comp_load", 95.0, "above", "warning", "CRAC compressor overloaded at {sensor_id}: {value:.1f}%"),
("cooling_high_press", 22.0, "above", "critical", "CRAC high refrigerant pressure at {sensor_id}: {value:.1f} bar"),
("cooling_low_press", 3.0, "below", "critical", "CRAC low refrigerant pressure at {sensor_id}: {value:.1f} bar — possible leak"),
("cooling_superheat", 16.0, "above", "warning", "CRAC discharge superheat high at {sensor_id}: {value:.1f}°C"),
("cooling_filter_dp", 80.0, "above", "warning", "CRAC filter requires attention at {sensor_id}: {value:.0f} Pa"),
("cooling_filter_dp", 120.0, "above", "critical", "CRAC filter critically blocked at {sensor_id}: {value:.0f} Pa — replace now"),
("cooling_return", 36.0, "above", "warning", "CRAC return air temperature high at {sensor_id}: {value:.1f}°C"),
("cooling_return", 42.0, "above", "critical", "CRAC return air temperature critical at {sensor_id}: {value:.1f}°C"),
("gen_fuel_pct", 25.0, "below", "warning", "Generator fuel low at {sensor_id}: {value:.1f}%"),
("gen_fuel_pct", 10.0, "below", "critical", "Generator fuel critical at {sensor_id}: {value:.1f}%"),
("gen_state", 0.5, "above", "warning", "Generator running at {sensor_id} — site is on standby power"),
("gen_state", -0.5, "below", "critical", "Generator fault at {sensor_id} — no standby power available"),
("gen_load_pct", 85.0, "above", "warning", "Generator load high at {sensor_id}: {value:.1f}%"),
("gen_load_pct", 95.0, "above", "critical", "Generator overloaded at {sensor_id}: {value:.1f}%"),
("gen_coolant_c", 95.0, "above", "warning", "Generator coolant temperature high at {sensor_id}: {value:.1f}°C"),
("gen_coolant_c", 105.0, "above", "critical", "Generator coolant critical at {sensor_id}: {value:.1f}°C — risk of shutdown"),
("gen_oil_press", 2.0, "below", "critical", "Generator oil pressure low at {sensor_id}: {value:.1f} bar"),
("pdu_imbalance", 5.0, "above", "warning", "PDU phase imbalance at {sensor_id}: {value:.1f}%"),
("pdu_imbalance", 15.0, "above", "critical", "PDU phase imbalance critical at {sensor_id}: {value:.1f}%"),
("ats_active", 1.5, "above", "warning", "ATS transferred to generator at {sensor_id} — utility power lost"),
("ats_ua_v", 50.0, "below", "critical", "Utility A power failure at {sensor_id} — supply lost"),
("chiller_state", 0.5, "below", "critical", "Chiller fault at {sensor_id} — CHW supply lost"),
("chiller_cop", 2.5, "below", "warning", "Chiller running inefficiently at {sensor_id}: COP {value:.2f}"),
("vesda_level", 0.5, "above", "warning", "VESDA smoke detected at {sensor_id}: level elevated"),
("vesda_level", 1.5, "above", "warning", "VESDA action threshold reached at {sensor_id}"),
("vesda_level", 2.5, "above", "critical", "VESDA FIRE ALARM at {sensor_id}!"),
("vesda_flow", 0.5, "below", "critical", "VESDA aspirator flow fault at {sensor_id} — detector may be compromised"),
("vesda_det1", 0.5, "below", "warning", "VESDA detector 1 fault at {sensor_id}"),
("vesda_det2", 0.5, "below", "warning", "VESDA detector 2 fault at {sensor_id}"),
("net_state", 0.5, "above", "warning", "Network switch degraded at {sensor_id}"),
("net_state", 1.5, "above", "critical", "Network switch down at {sensor_id} — connectivity lost"),
("net_pkt_loss_pct", 1.0, "above", "warning", "Packet loss detected at {sensor_id}: {value:.1f}%"),
("net_pkt_loss_pct", 5.0, "above", "critical", "High packet loss at {sensor_id}: {value:.1f}%"),
("net_temp_c", 65.0, "above", "warning", "Switch temperature high at {sensor_id}: {value:.1f}°C"),
("net_temp_c", 75.0, "above", "critical", "Switch temperature critical at {sensor_id}: {value:.1f}°C"),
]
]

View file

@ -0,0 +1,328 @@
import asyncio
import json
import logging
from datetime import datetime, timezone
import aiomqtt
from sqlalchemy import text
from core.config import settings
from core.database import AsyncSessionLocal
from services.alarm_engine import check_and_update_alarms
from services.ws_manager import manager as ws_manager
logger = logging.getLogger(__name__)
def parse_topic(topic: str) -> dict | None:
"""
Topic formats:
bms/{site_id}/{room_id}/{rack_id}/env rack environment
bms/{site_id}/{room_id}/{rack_id}/power rack PDU power
bms/{site_id}/cooling/{crac_id} CRAC unit
bms/{site_id}/cooling/chiller/{chiller_id} chiller plant
bms/{site_id}/power/{ups_id} UPS unit
bms/{site_id}/power/ats/{ats_id} ATS transfer switch
bms/{site_id}/generator/{gen_id} diesel generator
bms/{site_id}/fire/{zone_id} VESDA fire zone
bms/{site_id}/leak/{sensor_id} water leak sensor
"""
parts = topic.split("/")
if len(parts) < 4 or parts[0] != "bms":
return None
site_id = parts[1]
# 5-part: rack env/power OR cooling/chiller/{id} OR power/ats/{id}
if len(parts) == 5:
if parts[4] in ("env", "power"):
return {
"site_id": site_id, "room_id": parts[2],
"rack_id": parts[3], "device_id": None, "msg_type": parts[4],
}
if parts[2] == "cooling" and parts[3] == "chiller":
return {
"site_id": site_id, "room_id": None, "rack_id": None,
"device_id": parts[4], "msg_type": "chiller",
}
if parts[2] == "power" and parts[3] == "ats":
return {
"site_id": site_id, "room_id": None, "rack_id": None,
"device_id": parts[4], "msg_type": "ats",
}
# 4-part: bms/{site_id}/{room_id}/particles
if len(parts) == 4 and parts[3] == "particles":
return {
"site_id": site_id, "room_id": parts[2], "rack_id": None,
"device_id": None, "msg_type": "particles",
}
# 4-part: known subsystem topics
if len(parts) == 4 and parts[2] in ("cooling", "power", "leak", "generator", "fire", "network"):
return {
"site_id": site_id, "room_id": None, "rack_id": None,
"device_id": parts[3], "msg_type": parts[2],
}
return None
async def process_message(topic: str, payload: dict) -> None:
meta = parse_topic(topic)
if not meta:
return
site_id = meta["site_id"]
room_id = meta["room_id"]
rack_id = meta["rack_id"]
device_id = meta["device_id"]
msg_type = meta["msg_type"]
now = datetime.now(timezone.utc)
# Build list of (sensor_id, sensor_type, value, unit) tuples
readings: list[tuple[str, str, float, str]] = []
if msg_type == "env" and rack_id:
base = f"{site_id}/{room_id}/{rack_id}"
if "temperature" in payload:
readings.append((f"{base}/temperature", "temperature", float(payload["temperature"]), "°C"))
if "humidity" in payload:
readings.append((f"{base}/humidity", "humidity", float(payload["humidity"]), "%"))
elif msg_type == "power" and rack_id:
base = f"{site_id}/{room_id}/{rack_id}"
if "load_kw" in payload:
readings.append((f"{base}/power_kw", "power_kw", float(payload["load_kw"]), "kW"))
# Per-phase PDU data
for key, s_type, unit in [
("phase_a_kw", "pdu_phase_a_kw", "kW"),
("phase_b_kw", "pdu_phase_b_kw", "kW"),
("phase_c_kw", "pdu_phase_c_kw", "kW"),
("phase_a_a", "pdu_phase_a_a", "A"),
("phase_b_a", "pdu_phase_b_a", "A"),
("phase_c_a", "pdu_phase_c_a", "A"),
("imbalance_pct", "pdu_imbalance", "%"),
]:
if payload.get(key) is not None:
readings.append((f"{base}/{key}", s_type, float(payload[key]), unit))
elif msg_type == "cooling" and device_id:
base = f"{site_id}/cooling/{device_id}"
crac_fields = [
# (payload_key, sensor_type, unit)
("supply_temp", "cooling_supply", "°C"),
("return_temp", "cooling_return", "°C"),
("fan_pct", "cooling_fan", "%"),
("supply_humidity", "cooling_supply_hum", "%"),
("return_humidity", "cooling_return_hum", "%"),
("airflow_cfm", "cooling_airflow", "CFM"),
("filter_dp_pa", "cooling_filter_dp", "Pa"),
("cooling_capacity_kw", "cooling_cap_kw", "kW"),
("cooling_capacity_pct", "cooling_cap_pct", "%"),
("cop", "cooling_cop", ""),
("sensible_heat_ratio", "cooling_shr", ""),
("compressor_state", "cooling_comp_state", ""),
("compressor_load_pct", "cooling_comp_load", "%"),
("compressor_power_kw", "cooling_comp_power", "kW"),
("compressor_run_hours", "cooling_comp_hours", "h"),
("high_pressure_bar", "cooling_high_press", "bar"),
("low_pressure_bar", "cooling_low_press", "bar"),
("discharge_superheat_c", "cooling_superheat", "°C"),
("liquid_subcooling_c", "cooling_subcooling", "°C"),
("fan_rpm", "cooling_fan_rpm", "RPM"),
("fan_power_kw", "cooling_fan_power", "kW"),
("fan_run_hours", "cooling_fan_hours", "h"),
("total_unit_power_kw", "cooling_unit_power", "kW"),
("input_voltage_v", "cooling_voltage", "V"),
("input_current_a", "cooling_current", "A"),
("power_factor", "cooling_pf", ""),
]
for key, s_type, unit in crac_fields:
if payload.get(key) is not None:
readings.append((f"{base}/{key}", s_type, float(payload[key]), unit))
elif msg_type == "power" and device_id:
base = f"{site_id}/power/{device_id}"
for key, s_type, unit in [
("charge_pct", "ups_charge", "%"),
("load_pct", "ups_load", "%"),
("runtime_min", "ups_runtime", "min"),
("voltage", "ups_voltage", "V"),
]:
if key in payload:
readings.append((f"{base}/{key}", s_type, float(payload[key]), unit))
# Store state explicitly: 0.0 = online, 1.0 = on_battery, 2.0 = overload
if "state" in payload:
state_val = {"online": 0.0, "on_battery": 1.0, "overload": 2.0}.get(payload["state"], 0.0)
readings.append((f"{base}/state", "ups_state", state_val, ""))
elif msg_type == "generator" and device_id:
base = f"{site_id}/generator/{device_id}"
state_map = {"standby": 0.0, "running": 1.0, "test": 2.0, "fault": -1.0}
for key, s_type, unit in [
("fuel_pct", "gen_fuel_pct", "%"),
("fuel_litres", "gen_fuel_l", "L"),
("fuel_rate_lph", "gen_fuel_rate", "L/h"),
("load_kw", "gen_load_kw", "kW"),
("load_pct", "gen_load_pct", "%"),
("run_hours", "gen_run_hours", "h"),
("voltage_v", "gen_voltage_v", "V"),
("frequency_hz", "gen_freq_hz", "Hz"),
("engine_rpm", "gen_rpm", "RPM"),
("oil_pressure_bar", "gen_oil_press", "bar"),
("coolant_temp_c", "gen_coolant_c", "°C"),
("exhaust_temp_c", "gen_exhaust_c", "°C"),
("alternator_temp_c", "gen_alt_temp_c", "°C"),
("power_factor", "gen_pf", ""),
("battery_v", "gen_batt_v", "V"),
]:
if payload.get(key) is not None:
readings.append((f"{base}/{key}", s_type, float(payload[key]), unit))
if "state" in payload:
readings.append((f"{base}/state", "gen_state", state_map.get(payload["state"], 0.0), ""))
elif msg_type == "ats" and device_id:
base = f"{site_id}/power/ats/{device_id}"
feed_map = {"utility-a": 0.0, "utility-b": 1.0, "generator": 2.0}
for key, s_type, unit in [
("transfer_count", "ats_xfer_count", ""),
("last_transfer_ms", "ats_xfer_ms", "ms"),
("utility_a_v", "ats_ua_v", "V"),
("utility_b_v", "ats_ub_v", "V"),
("generator_v", "ats_gen_v", "V"),
]:
if payload.get(key) is not None:
readings.append((f"{base}/{key}", s_type, float(payload[key]), unit))
if "active_feed" in payload:
readings.append((f"{base}/active_feed", "ats_active",
feed_map.get(payload["active_feed"], 0.0), ""))
if "state" in payload:
readings.append((f"{base}/state", "ats_state",
1.0 if payload["state"] == "transferring" else 0.0, ""))
elif msg_type == "chiller" and device_id:
base = f"{site_id}/cooling/chiller/{device_id}"
for key, s_type, unit in [
("chw_supply_c", "chiller_chw_supply", "°C"),
("chw_return_c", "chiller_chw_return", "°C"),
("chw_delta_c", "chiller_chw_delta", "°C"),
("flow_gpm", "chiller_flow_gpm", "GPM"),
("cooling_load_kw", "chiller_load_kw", "kW"),
("cooling_load_pct", "chiller_load_pct", "%"),
("cop", "chiller_cop", ""),
("compressor_load_pct", "chiller_comp_load", "%"),
("condenser_pressure_bar", "chiller_cond_press", "bar"),
("evaporator_pressure_bar", "chiller_evap_press", "bar"),
("cw_supply_c", "chiller_cw_supply", "°C"),
("cw_return_c", "chiller_cw_return", "°C"),
("run_hours", "chiller_run_hours", "h"),
]:
if payload.get(key) is not None:
readings.append((f"{base}/{key}", s_type, float(payload[key]), unit))
if "state" in payload:
readings.append((f"{base}/state", "chiller_state",
1.0 if payload["state"] == "online" else 0.0, ""))
elif msg_type == "fire" and device_id:
base = f"{site_id}/fire/{device_id}"
level_map = {"normal": 0.0, "alert": 1.0, "action": 2.0, "fire": 3.0}
if "level" in payload:
readings.append((f"{base}/level", "vesda_level",
level_map.get(payload["level"], 0.0), ""))
if "obscuration_pct_m" in payload:
readings.append((f"{base}/obscuration", "vesda_obscuration",
float(payload["obscuration_pct_m"]), "%/m"))
for key, s_type in [
("detector_1_ok", "vesda_det1"),
("detector_2_ok", "vesda_det2"),
("power_ok", "vesda_power"),
("flow_ok", "vesda_flow"),
]:
if key in payload:
readings.append((f"{base}/{key}", s_type,
1.0 if payload[key] else 0.0, ""))
elif msg_type == "network" and device_id:
base = f"{site_id}/network/{device_id}"
state_map = {"up": 0.0, "degraded": 1.0, "down": 2.0}
for key, s_type, unit in [
("uptime_s", "net_uptime_s", "s"),
("active_ports", "net_active_ports", ""),
("bandwidth_in_mbps", "net_bw_in_mbps", "Mbps"),
("bandwidth_out_mbps","net_bw_out_mbps", "Mbps"),
("cpu_pct", "net_cpu_pct", "%"),
("mem_pct", "net_mem_pct", "%"),
("temperature_c", "net_temp_c", "°C"),
("packet_loss_pct", "net_pkt_loss_pct", "%"),
]:
if payload.get(key) is not None:
readings.append((f"{base}/{key}", s_type, float(payload[key]), unit))
if "state" in payload:
readings.append((f"{base}/state", "net_state",
state_map.get(payload["state"], 0.0), ""))
elif msg_type == "leak" and device_id:
state = payload.get("state", "clear")
readings.append((
f"{site_id}/leak/{device_id}", "leak",
1.0 if state == "detected" else 0.0, "",
))
elif msg_type == "particles":
base = f"{site_id}/{room_id}/particles"
if "particles_0_5um" in payload:
readings.append((f"{base}/0_5um", "particles_0_5um", float(payload["particles_0_5um"]), "/m³"))
if "particles_5um" in payload:
readings.append((f"{base}/5um", "particles_5um", float(payload["particles_5um"]), "/m³"))
if not readings:
return
async with AsyncSessionLocal() as session:
for sensor_id, sensor_type, value, unit in readings:
await session.execute(text("""
INSERT INTO readings
(recorded_at, sensor_id, sensor_type, site_id, room_id, rack_id, value, unit)
VALUES
(:ts, :sid, :stype, :site, :room, :rack, :val, :unit)
"""), {
"ts": now, "sid": sensor_id, "stype": sensor_type,
"site": site_id, "room": room_id, "rack": rack_id,
"val": value, "unit": unit,
})
await check_and_update_alarms(
session, sensor_id, sensor_type, site_id, room_id, rack_id, value
)
await session.commit()
# Push to any connected WebSocket clients
await ws_manager.broadcast({
"topic": topic,
"site_id": site_id,
"room_id": room_id,
"rack_id": rack_id,
"readings": [
{"sensor_id": s, "type": t, "value": v, "unit": u}
for s, t, v, u in readings
],
"timestamp": now.isoformat(),
})
async def run_subscriber() -> None:
"""Runs forever, reconnecting on any failure."""
while True:
try:
logger.info(f"Connecting to MQTT at {settings.MQTT_HOST}:{settings.MQTT_PORT}")
async with aiomqtt.Client(settings.MQTT_HOST, port=settings.MQTT_PORT) as client:
logger.info("MQTT connected — subscribing to bms/#")
await client.subscribe("bms/#")
async for message in client.messages:
try:
payload = json.loads(message.payload.decode())
await process_message(str(message.topic), payload)
except Exception as e:
logger.error(f"Error processing message on {message.topic}: {e}")
except Exception as e:
logger.error(f"MQTT connection failed: {e} — retrying in 5s")
await asyncio.sleep(5)

234
backend/services/seed.py Normal file
View file

@ -0,0 +1,234 @@
"""
Seed the database with default sensor registry and alarm threshold rules.
Runs on startup if tables are empty subsequent restarts are no-ops.
"""
import json
import logging
from sqlalchemy import text
from sqlalchemy.ext.asyncio import AsyncSession
logger = logging.getLogger(__name__)
SITE_ID = "sg-01"
# ── Threshold seed data ────────────────────────────────────────────────────────
# (sensor_type, threshold_value, direction, severity, message_template, locked)
# locked=True → state-machine encoding, hidden from UI
# locked=False → numeric setpoint, user-editable
THRESHOLD_SEED_DATA: list[tuple] = [
# Rack environment
("temperature", 28.0, "above", "warning", "Temperature elevated at {sensor_id}: {value:.1f}°C", False),
("temperature", 32.0, "above", "critical", "Temperature critical at {sensor_id}: {value:.1f}°C", False),
("humidity", 65.0, "above", "warning", "Humidity elevated at {sensor_id}: {value:.0f}%", False),
# PDU / rack power
("power_kw", 7.5, "above", "warning", "PDU load elevated at {sensor_id}: {value:.1f} kW", False),
("power_kw", 9.5, "above", "critical", "PDU load critical at {sensor_id}: {value:.1f} kW", False),
# UPS — numeric setpoints
("ups_charge", 80.0, "below", "warning", "UPS battery low at {sensor_id}: {value:.0f}%", False),
("ups_charge", 50.0, "below", "critical", "UPS battery critical at {sensor_id}: {value:.0f}%", False),
("ups_load", 85.0, "above", "warning", "UPS load high at {sensor_id}: {value:.0f}%", False),
("ups_load", 95.0, "above", "critical", "UPS load critical at {sensor_id}: {value:.0f}% — overload", False),
("ups_runtime", 15.0, "below", "warning", "UPS runtime low at {sensor_id}: {value:.0f} min remaining", False),
("ups_runtime", 5.0, "below", "critical", "UPS runtime critical at {sensor_id}: {value:.0f} min — imminent shutdown", False),
# UPS — state transitions (locked)
("ups_state", 0.5, "above", "critical", "UPS switched to battery at {sensor_id} — mains power lost", True),
("ups_state", 1.5, "above", "critical", "UPS overloaded at {sensor_id} — immediate risk of failure", True),
# Leak (locked — binary)
("leak", 0.5, "above", "critical", "Water leak detected at {sensor_id}!", True),
# CRAC capacity & efficiency
("cooling_cap_pct", 90.0, "above", "warning", "CRAC near capacity limit at {sensor_id}: {value:.1f}%", False),
("cooling_cop", 1.5, "below", "warning", "CRAC running inefficiently at {sensor_id}: COP {value:.2f}", False),
# CRAC compressor
("cooling_comp_load", 95.0, "above", "warning", "CRAC compressor overloaded at {sensor_id}: {value:.1f}%", False),
("cooling_high_press", 22.0, "above", "critical", "CRAC high refrigerant pressure at {sensor_id}: {value:.1f} bar", False),
("cooling_low_press", 3.0, "below", "critical", "CRAC low refrigerant pressure at {sensor_id}: {value:.1f} bar — possible leak", False),
("cooling_superheat", 16.0, "above", "warning", "CRAC discharge superheat high at {sensor_id}: {value:.1f}°C", False),
# CRAC filter
("cooling_filter_dp", 80.0, "above", "warning", "CRAC filter requires attention at {sensor_id}: {value:.0f} Pa", False),
("cooling_filter_dp", 120.0, "above", "critical", "CRAC filter critically blocked at {sensor_id}: {value:.0f} Pa — replace now", False),
# CRAC return air
("cooling_return", 36.0, "above", "warning", "CRAC return air temperature high at {sensor_id}: {value:.1f}°C", False),
("cooling_return", 42.0, "above", "critical", "CRAC return air temperature critical at {sensor_id}: {value:.1f}°C", False),
# Generator — numeric setpoints
("gen_fuel_pct", 25.0, "below", "warning", "Generator fuel low at {sensor_id}: {value:.1f}%", False),
("gen_fuel_pct", 10.0, "below", "critical", "Generator fuel critical at {sensor_id}: {value:.1f}%", False),
("gen_load_pct", 85.0, "above", "warning", "Generator load high at {sensor_id}: {value:.1f}%", False),
("gen_load_pct", 95.0, "above", "critical", "Generator overloaded at {sensor_id}: {value:.1f}%", False),
("gen_coolant_c", 95.0, "above", "warning", "Generator coolant temperature high at {sensor_id}: {value:.1f}°C", False),
("gen_coolant_c", 105.0, "above", "critical", "Generator coolant critical at {sensor_id}: {value:.1f}°C — risk of shutdown", False),
("gen_oil_press", 2.0, "below", "critical", "Generator oil pressure low at {sensor_id}: {value:.1f} bar", False),
# Generator — state transitions (locked)
("gen_state", 0.5, "above", "warning", "Generator running at {sensor_id} — site is on standby power", True),
("gen_state", -0.5, "below", "critical", "Generator fault at {sensor_id} — no standby power available", True),
# PDU phase imbalance
("pdu_imbalance", 5.0, "above", "warning", "PDU phase imbalance at {sensor_id}: {value:.1f}%", False),
("pdu_imbalance", 15.0, "above", "critical", "PDU phase imbalance critical at {sensor_id}: {value:.1f}%", False),
# ATS — numeric
("ats_ua_v", 50.0, "below", "critical", "Utility A power failure at {sensor_id} — supply lost", False),
# ATS — state (locked)
("ats_active", 1.5, "above", "warning", "ATS transferred to generator at {sensor_id} — utility power lost", True),
# Chiller — numeric
("chiller_cop", 2.5, "below", "warning", "Chiller running inefficiently at {sensor_id}: COP {value:.2f}", False),
# Chiller — state (locked)
("chiller_state", 0.5, "below", "critical", "Chiller fault at {sensor_id} — CHW supply lost", True),
# VESDA fire — state (all locked)
("vesda_level", 0.5, "above", "warning", "VESDA smoke detected at {sensor_id}: level elevated", True),
("vesda_level", 1.5, "above", "warning", "VESDA action threshold reached at {sensor_id}", True),
("vesda_level", 2.5, "above", "critical", "VESDA FIRE ALARM at {sensor_id}!", True),
("vesda_flow", 0.5, "below", "critical", "VESDA aspirator flow fault at {sensor_id} — detector may be compromised", True),
("vesda_det1", 0.5, "below", "warning", "VESDA detector 1 fault at {sensor_id}", True),
("vesda_det2", 0.5, "below", "warning", "VESDA detector 2 fault at {sensor_id}", True),
# Network — numeric
("net_pkt_loss_pct", 1.0, "above", "warning", "Packet loss detected at {sensor_id}: {value:.1f}%", False),
("net_pkt_loss_pct", 5.0, "above", "critical", "High packet loss at {sensor_id}: {value:.1f}%", False),
("net_temp_c", 65.0, "above", "warning", "Switch temperature high at {sensor_id}: {value:.1f}°C", False),
("net_temp_c", 75.0, "above", "critical", "Switch temperature critical at {sensor_id}: {value:.1f}°C", False),
# Network — state (locked)
("net_state", 0.5, "above", "warning", "Network switch degraded at {sensor_id}", True),
("net_state", 1.5, "above", "critical", "Network switch down at {sensor_id} — connectivity lost", True),
]
# ── Sensor seed data ────────────────────────────────────────────────────────────
def _build_sensor_list() -> list[dict]:
sensors = [
{"device_id": "gen-01", "name": "Diesel Generator 1", "device_type": "generator", "room_id": None, "rack_id": None, "protocol": "mqtt", "protocol_config": {"topic": "bms/sg-01/generator/gen-01"}},
{"device_id": "ups-01", "name": "UPS Unit 1", "device_type": "ups", "room_id": None, "rack_id": None, "protocol": "mqtt", "protocol_config": {"topic": "bms/sg-01/power/ups-01"}},
{"device_id": "ups-02", "name": "UPS Unit 2", "device_type": "ups", "room_id": None, "rack_id": None, "protocol": "mqtt", "protocol_config": {"topic": "bms/sg-01/power/ups-02"}},
{"device_id": "ats-01", "name": "Transfer Switch 1", "device_type": "ats", "room_id": None, "rack_id": None, "protocol": "mqtt", "protocol_config": {"topic": "bms/sg-01/power/ats/ats-01"}},
{"device_id": "chiller-01", "name": "Chiller Plant 1", "device_type": "chiller", "room_id": None, "rack_id": None, "protocol": "mqtt", "protocol_config": {"topic": "bms/sg-01/cooling/chiller/chiller-01"}},
{"device_id": "crac-01", "name": "CRAC Unit — Hall A", "device_type": "crac", "room_id": "hall-a", "rack_id": None, "protocol": "mqtt", "protocol_config": {"topic": "bms/sg-01/cooling/crac-01"}},
{"device_id": "crac-02", "name": "CRAC Unit — Hall B", "device_type": "crac", "room_id": "hall-b", "rack_id": None, "protocol": "mqtt", "protocol_config": {"topic": "bms/sg-01/cooling/crac-02"}},
{"device_id": "vesda-hall-a","name": "VESDA Fire Zone — Hall A","device_type": "fire_zone", "room_id": "hall-a", "rack_id": None, "protocol": "mqtt", "protocol_config": {"topic": "bms/sg-01/fire/vesda-hall-a"}},
{"device_id": "vesda-hall-b","name": "VESDA Fire Zone — Hall B","device_type": "fire_zone", "room_id": "hall-b", "rack_id": None, "protocol": "mqtt", "protocol_config": {"topic": "bms/sg-01/fire/vesda-hall-b"}},
{"device_id": "leak-01", "name": "Leak Sensor — CRAC Zone A","device_type": "leak", "room_id": "hall-a", "rack_id": None, "protocol": "mqtt", "protocol_config": {"topic": "bms/sg-01/leak/leak-01"}},
{"device_id": "leak-02", "name": "Leak Sensor — Server Row B1","device_type": "leak", "room_id": "hall-b", "rack_id": None, "protocol": "mqtt", "protocol_config": {"topic": "bms/sg-01/leak/leak-02"}},
{"device_id": "leak-03", "name": "Leak Sensor — UPS Room", "device_type": "leak", "room_id": None, "rack_id": None, "protocol": "mqtt", "protocol_config": {"topic": "bms/sg-01/leak/leak-03"}},
{"device_id": "sw-core-01", "name": "Core Switch — Hall A", "device_type": "network_switch","room_id": "hall-a", "rack_id": "SG1A01.01", "protocol": "mqtt", "protocol_config": {"topic": "bms/sg-01/network/sw-core-01"}},
{"device_id": "sw-core-02", "name": "Core Switch — Hall B", "device_type": "network_switch","room_id": "hall-b", "rack_id": "SG1B01.01", "protocol": "mqtt", "protocol_config": {"topic": "bms/sg-01/network/sw-core-02"}},
{"device_id": "sw-edge-01", "name": "Edge / Uplink Switch", "device_type": "network_switch","room_id": "hall-a", "rack_id": "SG1A01.05", "protocol": "mqtt", "protocol_config": {"topic": "bms/sg-01/network/sw-edge-01"}},
]
# Generate racks
for room_id, row_prefix in [("hall-a", "SG1A"), ("hall-b", "SG1B")]:
for row_num in ["01", "02"]:
for rack_num in range(1, 21):
rack_id = f"{row_prefix}{row_num}.{rack_num:02d}"
sensors.append({
"device_id": rack_id,
"name": f"Rack PDU — {rack_id}",
"device_type": "rack",
"room_id": room_id,
"rack_id": rack_id,
"protocol": "mqtt",
"protocol_config": {
"env_topic": f"bms/sg-01/{room_id}/{rack_id}/env",
"pdu_topic": f"bms/sg-01/{room_id}/{rack_id}/power",
},
})
return sensors
SENSOR_SEED_DATA = _build_sensor_list()
# ── Default settings ────────────────────────────────────────────────────────────
DEFAULT_SETTINGS: dict[str, dict] = {
"site": {
"name": "Singapore DC01",
"timezone": "Asia/Singapore",
"description": "Production data centre — Singapore",
},
"notifications": {
"critical_alarms": True,
"warning_alarms": True,
"generator_events": True,
"maintenance_reminders": True,
"webhook_url": "",
"email_recipients": "",
},
"integrations": {
"mqtt_host": "mqtt",
"mqtt_port": 1883,
},
"page_prefs": {
"default_time_range_hours": 6,
"refresh_interval_seconds": 30,
},
}
# ── Seed functions ──────────────────────────────────────────────────────────────
async def seed_thresholds(session: AsyncSession) -> None:
result = await session.execute(
text("SELECT COUNT(*) FROM alarm_thresholds WHERE site_id = :s"),
{"s": SITE_ID},
)
if result.scalar() > 0:
return
for st, tv, direction, severity, msg, locked in THRESHOLD_SEED_DATA:
await session.execute(text("""
INSERT INTO alarm_thresholds
(site_id, sensor_type, threshold_value, direction, severity, message_template, enabled, locked)
VALUES
(:site_id, :sensor_type, :threshold_value, :direction, :severity, :message_template, true, :locked)
"""), {
"site_id": SITE_ID, "sensor_type": st, "threshold_value": tv,
"direction": direction, "severity": severity,
"message_template": msg, "locked": locked,
})
await session.commit()
logger.info(f"Seeded {len(THRESHOLD_SEED_DATA)} alarm threshold rules")
async def seed_sensors(session: AsyncSession) -> None:
result = await session.execute(
text("SELECT COUNT(*) FROM sensors WHERE site_id = :s"),
{"s": SITE_ID},
)
if result.scalar() > 0:
return
for s in SENSOR_SEED_DATA:
await session.execute(text("""
INSERT INTO sensors
(site_id, device_id, name, device_type, room_id, rack_id, protocol, protocol_config, enabled)
VALUES
(:site_id, :device_id, :name, :device_type, :room_id, :rack_id, :protocol, :protocol_config, true)
ON CONFLICT (site_id, device_id) DO NOTHING
"""), {
"site_id": SITE_ID,
"device_id": s["device_id"],
"name": s["name"],
"device_type": s["device_type"],
"room_id": s.get("room_id"),
"rack_id": s.get("rack_id"),
"protocol": s["protocol"],
"protocol_config": json.dumps(s["protocol_config"]),
})
await session.commit()
logger.info(f"Seeded {len(SENSOR_SEED_DATA)} sensor devices")
async def seed_settings(session: AsyncSession) -> None:
for category, defaults in DEFAULT_SETTINGS.items():
result = await session.execute(text("""
SELECT COUNT(*) FROM site_settings
WHERE site_id = :s AND category = :cat AND key = 'config'
"""), {"s": SITE_ID, "cat": category})
if result.scalar() > 0:
continue
await session.execute(text("""
INSERT INTO site_settings (site_id, category, key, value)
VALUES (:site_id, :category, 'config', :value)
ON CONFLICT (site_id, category, key) DO NOTHING
"""), {"site_id": SITE_ID, "category": category, "value": json.dumps(defaults)})
await session.commit()
logger.info("Seeded site settings defaults")
async def run_all_seeds(session: AsyncSession) -> None:
await seed_thresholds(session)
await seed_sensors(session)
await seed_settings(session)

View file

@ -0,0 +1,35 @@
import json
import logging
from fastapi import WebSocket
logger = logging.getLogger(__name__)
class ConnectionManager:
def __init__(self) -> None:
self._connections: set[WebSocket] = set()
async def connect(self, ws: WebSocket) -> None:
await ws.accept()
self._connections.add(ws)
logger.info(f"WS client connected. Total: {len(self._connections)}")
def disconnect(self, ws: WebSocket) -> None:
self._connections.discard(ws)
logger.info(f"WS client disconnected. Total: {len(self._connections)}")
async def broadcast(self, data: dict) -> None:
if not self._connections:
return
message = json.dumps(data, default=str)
dead: set[WebSocket] = set()
for ws in self._connections:
try:
await ws.send_text(message)
except Exception:
dead.add(ws)
self._connections -= dead
# Singleton — imported by both the MQTT subscriber and the WS route
manager = ConnectionManager()

107
docker-compose.yml Normal file
View file

@ -0,0 +1,107 @@
services:
# ── MQTT Broker ──────────────────────────────────────────────────
mqtt:
image: eclipse-mosquitto:2
container_name: dcim_mqtt
restart: unless-stopped
ports:
- "1883:1883"
volumes:
- ./infra/mosquitto/mosquitto.conf:/mosquitto/config/mosquitto.conf:ro
healthcheck:
test: ["CMD-SHELL", "mosquitto_sub -t '$$SYS/#' -C 1 -i healthcheck -W 3"]
interval: 10s
timeout: 5s
retries: 5
# ── PostgreSQL + TimescaleDB ─────────────────────────────────────
db:
image: timescale/timescaledb:latest-pg16
container_name: dcim_db
restart: unless-stopped
environment:
POSTGRES_USER: dcim
POSTGRES_PASSWORD: dcim_pass
POSTGRES_DB: dcim
ports:
- "5433:5432" # host 5433 → container 5432 (avoids conflict with existing Postgres)
volumes:
- db_data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U dcim -d dcim"]
interval: 10s
timeout: 5s
retries: 5
# ── FastAPI backend ──────────────────────────────────────────────
backend:
build:
context: ./backend
dockerfile: Dockerfile
container_name: dcim_backend
restart: unless-stopped
env_file:
- ./backend/.env
environment:
DATABASE_URL: postgresql+asyncpg://dcim:dcim_pass@db:5432/dcim
MQTT_HOST: mqtt
MQTT_PORT: "1883"
ports:
- "8000:8000"
depends_on:
db:
condition: service_healthy
mqtt:
condition: service_healthy
volumes:
- ./backend:/app
command: uvicorn main:app --host 0.0.0.0 --port 8000 --reload
healthcheck:
test: ["CMD-SHELL", "curl -sf http://localhost:8000/api/health || exit 1"]
interval: 10s
timeout: 5s
retries: 10
start_period: 20s
# ── Simulator bots (seed first, then run bots) ───────────────────
simulators:
build:
context: ./simulators
dockerfile: Dockerfile
container_name: dcim_simulators
restart: unless-stopped
environment:
MQTT_HOST: mqtt
MQTT_PORT: "1883"
DATABASE_URL: postgresql://dcim:dcim_pass@db:5432/dcim
SEED_MINUTES: "30"
depends_on:
db:
condition: service_healthy
mqtt:
condition: service_healthy
backend:
condition: service_healthy
volumes:
- ./simulators:/app
# ── Next.js frontend ─────────────────────────────────────────────
frontend:
build:
context: ./frontend
dockerfile: Dockerfile
container_name: dcim_frontend
restart: unless-stopped
env_file:
- ./frontend/.env.local
ports:
- "5646:5646"
depends_on:
- backend
environment:
PORT: "5646"
BACKEND_INTERNAL_URL: http://backend:8000
volumes:
db_data:

41
frontend/.gitignore vendored Normal file
View file

@ -0,0 +1,41 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
/.pnp
.pnp.*
.yarn/*
!.yarn/patches
!.yarn/plugins
!.yarn/releases
!.yarn/versions
# testing
/coverage
# next.js
/.next/
/out/
# production
/build
# misc
.DS_Store
*.pem
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.pnpm-debug.log*
# env files (can opt-in for committing if needed)
.env*
# vercel
.vercel
# typescript
*.tsbuildinfo
next-env.d.ts

36
frontend/Dockerfile Normal file
View file

@ -0,0 +1,36 @@
FROM node:22-alpine AS base
# Install dependencies only when needed
FROM base AS deps
RUN apk add --no-cache libc6-compat
WORKDIR /app
COPY package.json pnpm-lock.yaml* ./
RUN corepack enable pnpm && pnpm install --frozen-lockfile
# Build the app
FROM base AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
ARG BACKEND_INTERNAL_URL=http://backend:8000
ENV BACKEND_INTERNAL_URL=$BACKEND_INTERNAL_URL
RUN corepack enable pnpm && pnpm build
# Production runner
FROM base AS runner
WORKDIR /app
ENV NODE_ENV=production
RUN addgroup --system --gid 1001 nodejs
RUN adduser --system --uid 1001 nextjs
COPY --from=builder /app/public ./public
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
USER nextjs
EXPOSE 5646
ENV PORT=5646
ENV HOSTNAME="0.0.0.0"
CMD ["node", "server.js"]

36
frontend/README.md Normal file
View file

@ -0,0 +1,36 @@
This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app).
## Getting Started
First, run the development server:
```bash
npm run dev
# or
yarn dev
# or
pnpm dev
# or
bun dev
```
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel.
## Learn More
To learn more about Next.js, take a look at the following resources:
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome!
## Deploy on Vercel
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details.

View file

@ -0,0 +1,753 @@
"use client";
import { useEffect, useState, useCallback, useMemo, useRef } from "react";
import { useRouter } from "next/navigation";
import { toast } from "sonner";
import {
fetchAlarms, fetchAlarmStats, acknowledgeAlarm, resolveAlarm,
type Alarm, type AlarmStats,
} from "@/lib/api";
import { RackDetailSheet } from "@/components/dashboard/rack-detail-sheet";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Skeleton } from "@/components/ui/skeleton";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
import {
AlertTriangle, CheckCircle2, Clock, XCircle, Bell,
ChevronsUpDown, ChevronUp, ChevronDown, Activity,
} from "lucide-react";
import {
BarChart, Bar, XAxis, Tooltip, ResponsiveContainer, Cell,
} from "recharts";
import { cn } from "@/lib/utils";
const SITE_ID = "sg-01";
const PAGE_SIZE = 25;
type StateFilter = "active" | "acknowledged" | "resolved" | "all";
type SeverityFilter = "all" | "critical" | "warning" | "info";
type SortKey = "severity" | "triggered_at" | "state";
type SortDir = "asc" | "desc";
function timeAgo(iso: string): string {
const diff = Date.now() - new Date(iso).getTime();
const m = Math.floor(diff / 60000);
if (m < 1) return "just now";
if (m < 60) return `${m}m`;
const h = Math.floor(m / 60);
if (h < 24) return `${h}h`;
return `${Math.floor(h / 24)}d`;
}
function useNow(intervalMs = 30_000): number {
const [now, setNow] = useState(() => Date.now());
useEffect(() => {
const id = setInterval(() => setNow(Date.now()), intervalMs);
return () => clearInterval(id);
}, [intervalMs]);
return now;
}
function escalationMinutes(triggeredAt: string, now: number): number {
return Math.floor((now - new Date(triggeredAt).getTime()) / 60_000);
}
function EscalationTimer({ triggeredAt, now }: { triggeredAt: string; now: number }) {
const mins = escalationMinutes(triggeredAt, now);
const h = Math.floor(mins / 60);
const m = mins % 60;
const label = h > 0 ? `${h}h ${m}m` : `${m}m`;
const colorClass =
mins >= 60 ? "text-destructive" :
mins >= 15 ? "text-amber-400" :
mins >= 5 ? "text-amber-300" :
"text-muted-foreground";
const pulse = mins >= 60;
return (
<span className={cn(
"inline-flex items-center gap-1 text-xs font-mono font-semibold tabular-nums",
colorClass,
pulse && "animate-pulse",
)}>
<Clock className="w-3 h-3 shrink-0" />
{label}
</span>
);
}
function alarmCategory(sensorId: string | null | undefined): { label: string; className: string } {
if (!sensorId) return { label: "System", className: "bg-muted/50 text-muted-foreground" };
const s = sensorId.toLowerCase();
if (s.includes("cooling") || s.includes("crac") || s.includes("refrigerant") || s.includes("cop"))
return { label: "Refrigerant", className: "bg-cyan-500/10 text-cyan-400" };
if (s.includes("temp") || s.includes("thermal") || s.includes("humidity") || s.includes("hum"))
return { label: "Thermal", className: "bg-orange-500/10 text-orange-400" };
if (s.includes("power") || s.includes("ups") || s.includes("pdu") || s.includes("kw") || s.includes("watt"))
return { label: "Power", className: "bg-yellow-500/10 text-yellow-400" };
if (s.includes("leak") || s.includes("water") || s.includes("flood"))
return { label: "Leak", className: "bg-blue-500/10 text-blue-400" };
return { label: "System", className: "bg-muted/50 text-muted-foreground" };
}
const severityConfig: Record<string, { label: string; bg: string; dot: string }> = {
critical: { label: "Critical", bg: "bg-destructive/15 text-destructive border-destructive/30", dot: "bg-destructive" },
warning: { label: "Warning", bg: "bg-amber-500/15 text-amber-400 border-amber-500/30", dot: "bg-amber-500" },
info: { label: "Info", bg: "bg-blue-500/15 text-blue-400 border-blue-500/30", dot: "bg-blue-500" },
};
const stateConfig: Record<string, { label: string; className: string }> = {
active: { label: "Active", className: "bg-destructive/10 text-destructive" },
acknowledged: { label: "Acknowledged", className: "bg-amber-500/10 text-amber-400" },
resolved: { label: "Resolved", className: "bg-green-500/10 text-green-400" },
};
function SeverityBadge({ severity }: { severity: string }) {
const c = severityConfig[severity] ?? severityConfig.info;
return (
<span className={cn("inline-flex items-center gap-1.5 px-2 py-0.5 rounded-full text-[10px] font-semibold uppercase tracking-wide border", c.bg)}>
<span className={cn("w-1.5 h-1.5 rounded-full", c.dot)} />
{c.label}
</span>
);
}
function StatCard({ label, value, icon: Icon, highlight }: { label: string; value: number; icon: React.ElementType; highlight?: boolean }) {
return (
<Card>
<CardContent className="p-4 flex items-center gap-3">
<div className={cn("p-2 rounded-lg", highlight && value > 0 ? "bg-destructive/10" : "bg-muted")}>
<Icon className={cn("w-4 h-4", highlight && value > 0 ? "text-destructive" : "text-muted-foreground")} />
</div>
<div>
<p className={cn("text-2xl font-bold", highlight && value > 0 ? "text-destructive" : "")}>{value}</p>
<p className="text-xs text-muted-foreground">{label}</p>
</div>
</CardContent>
</Card>
);
}
function AvgAgeCard({ alarms }: { alarms: Alarm[] }) {
const activeAlarms = alarms.filter(a => a.state === "active");
const avgMins = useMemo(() => {
if (activeAlarms.length === 0) return 0;
const now = Date.now();
const totalMins = activeAlarms.reduce((sum, a) => {
return sum + Math.floor((now - new Date(a.triggered_at).getTime()) / 60_000);
}, 0);
return Math.round(totalMins / activeAlarms.length);
}, [activeAlarms]);
const label = avgMins >= 60
? `${Math.floor(avgMins / 60)}h ${avgMins % 60}m`
: `${avgMins}m`;
const colorClass = avgMins > 60 ? "text-destructive"
: avgMins > 15 ? "text-amber-400"
: "text-green-400";
const iconColor = avgMins > 60 ? "text-destructive"
: avgMins > 15 ? "text-amber-400"
: "text-muted-foreground";
const bgColor = avgMins > 60 ? "bg-destructive/10"
: avgMins > 15 ? "bg-amber-500/10"
: "bg-muted";
return (
<Card>
<CardContent className="p-4 flex items-center gap-3">
<div className={cn("p-2 rounded-lg", bgColor)}>
<Clock className={cn("w-4 h-4", iconColor)} />
</div>
<div>
<p className={cn("text-2xl font-bold", activeAlarms.length > 0 ? colorClass : "")}>{activeAlarms.length > 0 ? label : "—"}</p>
<p className="text-xs text-muted-foreground">Avg Age</p>
</div>
</CardContent>
</Card>
);
}
type Correlation = {
id: string
title: string
severity: "critical" | "warning"
description: string
alarmIds: number[]
}
function correlateAlarms(alarms: Alarm[]): Correlation[] {
const active = alarms.filter(a => a.state === "active");
const results: Correlation[] = [];
// Rule 1: ≥2 thermal alarms in the same room → probable CRAC issue
const thermalByRoom = new Map<string, Alarm[]>();
for (const a of active) {
const isThermal = a.sensor_id
? /temp|thermal|humidity|hum/i.test(a.sensor_id)
: /temp|thermal|hot|cool/i.test(a.message);
const room = a.room_id;
if (isThermal && room) {
if (!thermalByRoom.has(room)) thermalByRoom.set(room, []);
thermalByRoom.get(room)!.push(a);
}
}
for (const [room, roomAlarms] of thermalByRoom.entries()) {
if (roomAlarms.length >= 2) {
results.push({
id: `thermal-${room}`,
title: `Thermal event — ${room.replace("hall-", "Hall ")}`,
severity: roomAlarms.some(a => a.severity === "critical") ? "critical" : "warning",
description: `${roomAlarms.length} thermal alarms in the same room. Probable cause: CRAC cooling degradation or containment breach.`,
alarmIds: roomAlarms.map(a => a.id),
});
}
}
// Rule 2: ≥3 power alarms across different racks → PDU or UPS path issue
const powerAlarms = active.filter(a =>
a.sensor_id ? /power|pdu|ups|kw|watt/i.test(a.sensor_id) : /power|overload|circuit/i.test(a.message)
);
const powerRacks = new Set(powerAlarms.map(a => a.rack_id).filter(Boolean));
if (powerRacks.size >= 2) {
results.push({
id: "power-multi-rack",
title: "Multi-rack power event",
severity: powerAlarms.some(a => a.severity === "critical") ? "critical" : "warning",
description: `Power alarms on ${powerRacks.size} racks simultaneously. Probable cause: upstream PDU, busway tap, or UPS transfer.`,
alarmIds: powerAlarms.map(a => a.id),
});
}
// Rule 3: Generator + ATS alarms together → power path / utility failure
const genAlarm = active.find(a => a.sensor_id ? /gen/i.test(a.sensor_id) : /generator/i.test(a.message));
const atsAlarm = active.find(a => a.sensor_id ? /ats/i.test(a.sensor_id) : /transfer|utility/i.test(a.message));
if (genAlarm && atsAlarm) {
results.push({
id: "gen-ats-event",
title: "Power path event — generator + ATS",
severity: "critical",
description: "Generator and ATS alarms are co-active. Possible utility failure with generator transfer in progress.",
alarmIds: [genAlarm.id, atsAlarm.id],
});
}
// Rule 4: ≥2 leak alarms → site-wide leak / pipe burst
const leakAlarms = active.filter(a =>
a.sensor_id ? /leak|water|flood/i.test(a.sensor_id) : /leak|water/i.test(a.message)
);
if (leakAlarms.length >= 2) {
results.push({
id: "multi-leak",
title: "Multiple leak sensors triggered",
severity: "critical",
description: `${leakAlarms.length} leak sensors active. Probable cause: pipe burst, chilled water leak, or CRAC drain overflow.`,
alarmIds: leakAlarms.map(a => a.id),
});
}
// Rule 5: VESDA + high temp in same room → fire / smoke event
const vesdaAlarm = active.find(a => a.sensor_id ? /vesda|fire/i.test(a.sensor_id) : /fire|smoke|vesda/i.test(a.message));
const hotRooms = new Set(active.filter(a => a.severity === "critical" && a.room_id && /temp/i.test(a.message + (a.sensor_id ?? ""))).map(a => a.room_id));
if (vesdaAlarm && hotRooms.size > 0) {
results.push({
id: "fire-temp-event",
title: "Fire / smoke event suspected",
severity: "critical",
description: "VESDA alarm co-active with critical temperature alarms. Possible fire or smoke event — check fire safety systems immediately.",
alarmIds: active.filter(a => hotRooms.has(a.room_id)).map(a => a.id).concat(vesdaAlarm.id),
});
}
return results;
}
function RootCausePanel({ alarms }: { alarms: Alarm[] }) {
const correlations = correlateAlarms(alarms);
if (correlations.length === 0) return null;
return (
<Card className="border-amber-500/30 bg-amber-500/5">
<CardHeader className="pb-2">
<CardTitle className="text-sm font-semibold flex items-center gap-2">
<Activity className="w-4 h-4 text-amber-400" />
Root Cause Analysis
<span className="text-[10px] font-normal text-muted-foreground ml-1">
{correlations.length} pattern{correlations.length > 1 ? "s" : ""} detected
</span>
</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
{correlations.map(c => (
<div key={c.id} className={cn(
"flex items-start gap-3 rounded-lg px-3 py-2.5 border",
c.severity === "critical"
? "bg-destructive/10 border-destructive/20"
: "bg-amber-500/10 border-amber-500/20",
)}>
<AlertTriangle className={cn(
"w-3.5 h-3.5 shrink-0 mt-0.5",
c.severity === "critical" ? "text-destructive" : "text-amber-400",
)} />
<div className="space-y-0.5 min-w-0">
<p className={cn(
"text-xs font-semibold",
c.severity === "critical" ? "text-destructive" : "text-amber-400",
)}>
{c.title}
<span className="text-muted-foreground font-normal ml-2">
({c.alarmIds.length} alarm{c.alarmIds.length !== 1 ? "s" : ""})
</span>
</p>
<p className="text-[11px] text-muted-foreground">{c.description}</p>
</div>
</div>
))}
</CardContent>
</Card>
);
}
export default function AlarmsPage() {
const router = useRouter();
const now = useNow(30_000);
const [alarms, setAlarms] = useState<Alarm[]>([]);
const [allAlarms, setAllAlarms] = useState<Alarm[]>([]);
const [stats, setStats] = useState<AlarmStats | null>(null);
const [stateFilter, setStateFilter] = useState<StateFilter>("active");
const [sevFilter, setSevFilter] = useState<SeverityFilter>("all");
const [sortKey, setSortKey] = useState<SortKey>("triggered_at");
const [sortDir, setSortDir] = useState<SortDir>("desc");
const [loading, setLoading] = useState(true);
const [acting, setActing] = useState<number | null>(null);
const [selected, setSelected] = useState<Set<number>>(new Set());
const [bulkActing, setBulkActing] = useState(false);
const [selectedRack, setSelectedRack] = useState<string | null>(null);
const [assignments, setAssignments] = useState<Record<number, string>>({});
const [page, setPage] = useState(1);
useEffect(() => {
try {
setAssignments(JSON.parse(localStorage.getItem("alarm-assignments") ?? "{}"));
} catch {}
}, []);
function setAssignment(id: number, assignee: string) {
const next = { ...assignments, [id]: assignee };
setAssignments(next);
localStorage.setItem("alarm-assignments", JSON.stringify(next));
}
const load = useCallback(async () => {
try {
const [a, s, all] = await Promise.all([
fetchAlarms(SITE_ID, stateFilter),
fetchAlarmStats(SITE_ID),
fetchAlarms(SITE_ID, "all", 200),
]);
setAlarms(a);
setStats(s);
setAllAlarms(all);
} catch {
toast.error("Failed to load alarms");
} finally {
setLoading(false);
}
}, [stateFilter]);
useEffect(() => {
setLoading(true);
load();
const id = setInterval(load, 15_000);
return () => clearInterval(id);
}, [load]);
// Reset page when filters change
useEffect(() => {
setPage(1);
}, [stateFilter, sevFilter]);
async function handleAcknowledge(id: number) {
setActing(id);
try { await acknowledgeAlarm(id); toast.success("Alarm acknowledged"); await load(); } finally { setActing(null); }
}
async function handleResolve(id: number) {
setActing(id);
try { await resolveAlarm(id); toast.success("Alarm resolved"); await load(); } finally { setActing(null); }
}
async function handleBulkResolve() {
setBulkActing(true);
const count = selected.size;
try {
await Promise.all(Array.from(selected).map((id) => resolveAlarm(id)));
toast.success(`${count} alarm${count !== 1 ? "s" : ""} resolved`);
setSelected(new Set());
await load();
} finally { setBulkActing(false); }
}
function toggleSelect(id: number) {
setSelected((prev) => {
const next = new Set(prev);
next.has(id) ? next.delete(id) : next.add(id);
return next;
});
}
function toggleSelectAll() {
const resolvable = visible.filter((a) => a.state !== "resolved").map((a) => a.id);
if (resolvable.every((id) => selected.has(id))) {
setSelected(new Set());
} else {
setSelected(new Set(resolvable));
}
}
const sevOrder: Record<string, number> = { critical: 0, warning: 1, info: 2 };
const stateOrder: Record<string, number> = { active: 0, acknowledged: 1, resolved: 2 };
function toggleSort(key: SortKey) {
if (sortKey === key) setSortDir((d) => d === "asc" ? "desc" : "asc");
else { setSortKey(key); setSortDir("desc"); }
}
function SortIcon({ col }: { col: SortKey }) {
if (sortKey !== col) return <ChevronsUpDown className="w-3 h-3 opacity-40" />;
return sortDir === "asc" ? <ChevronUp className="w-3 h-3" /> : <ChevronDown className="w-3 h-3" />;
}
const visible = (sevFilter === "all" ? alarms : alarms.filter((a) => a.severity === sevFilter))
.slice()
.sort((a, b) => {
let cmp = 0;
if (sortKey === "severity") cmp = (sevOrder[a.severity] ?? 9) - (sevOrder[b.severity] ?? 9);
if (sortKey === "triggered_at") cmp = new Date(a.triggered_at).getTime() - new Date(b.triggered_at).getTime();
if (sortKey === "state") cmp = (stateOrder[a.state] ?? 9) - (stateOrder[b.state] ?? 9);
return sortDir === "asc" ? cmp : -cmp;
});
const pageCount = Math.ceil(visible.length / PAGE_SIZE);
const paginated = visible.slice((page - 1) * PAGE_SIZE, page * PAGE_SIZE);
return (
<div className="p-6 space-y-6">
<RackDetailSheet siteId={SITE_ID} rackId={selectedRack} onClose={() => setSelectedRack(null)} />
<div>
<h1 className="text-xl font-semibold">Alarms &amp; Events</h1>
<p className="text-sm text-muted-foreground">Singapore DC01 refreshes every 15s</p>
</div>
{/* Escalation banner — longest unacknowledged critical */}
{(() => {
const critActive = alarms.filter(a => a.severity === "critical" && a.state === "active");
if (critActive.length === 0) return null;
const oldest = critActive.reduce((a, b) =>
new Date(a.triggered_at) < new Date(b.triggered_at) ? a : b
);
const mins = escalationMinutes(oldest.triggered_at, now);
const urgency = mins >= 60 ? "bg-destructive/10 border-destructive/30 text-destructive"
: mins >= 15 ? "bg-amber-500/10 border-amber-500/30 text-amber-400"
: "bg-amber-500/5 border-amber-500/20 text-amber-300";
return (
<div className={cn("flex items-center gap-3 rounded-lg border px-4 py-2.5 text-xs", urgency)}>
<AlertTriangle className="w-3.5 h-3.5 shrink-0" />
<span>
<strong>{critActive.length} critical alarm{critActive.length > 1 ? "s" : ""}</strong> unacknowledged
{" — "}longest open for <strong><EscalationTimer triggeredAt={oldest.triggered_at} now={now} /></strong>
</span>
</div>
);
})()}
{/* Root cause correlation panel */}
{!loading && <RootCausePanel alarms={allAlarms} />}
{/* Stat cards */}
<div className="grid grid-cols-2 sm:grid-cols-4 gap-4">
{stats ? (
<>
<StatCard label="Active" value={stats.active} icon={Bell} highlight />
<AvgAgeCard alarms={allAlarms} />
<StatCard label="Acknowledged" value={stats.acknowledged} icon={Clock} />
<StatCard label="Resolved" value={stats.resolved} icon={CheckCircle2} />
</>
) : (
Array.from({ length: 4 }).map((_, i) => <Skeleton key={i} className="h-20" />)
)}
</div>
{/* Sticky filter bar */}
<div className="sticky top-0 z-10 bg-background/95 backdrop-blur-sm -mx-6 px-6 py-3 border-b border-border/30">
<div className="flex flex-wrap items-center gap-3">
<Tabs value={stateFilter} onValueChange={(v) => setStateFilter(v as StateFilter)}>
<TabsList>
<TabsTrigger value="active">Active</TabsTrigger>
<TabsTrigger value="acknowledged">Acknowledged</TabsTrigger>
<TabsTrigger value="resolved">Resolved</TabsTrigger>
<TabsTrigger value="all">All</TabsTrigger>
</TabsList>
</Tabs>
<div className="flex items-center gap-1">
{(["all", "critical", "warning", "info"] as SeverityFilter[]).map((s) => (
<button
key={s}
onClick={() => setSevFilter(s)}
className={cn(
"px-2.5 py-1 rounded-md text-xs font-medium capitalize transition-colors",
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary",
sevFilter === s
? "bg-primary text-primary-foreground"
: "text-muted-foreground hover:text-foreground hover:bg-muted"
)}
>
{s}
</button>
))}
</div>
{/* Bulk actions inline in filter row */}
{selected.size > 0 && (
<div className="flex items-center gap-3 ml-auto">
<span className="text-xs text-muted-foreground">{selected.size} selected</span>
<Button
size="sm"
variant="outline"
className="h-7 text-xs text-green-400 border-green-500/30 hover:bg-green-500/10"
disabled={bulkActing}
onClick={handleBulkResolve}
>
<XCircle className="w-3 h-3 mr-1" />
Resolve selected ({selected.size})
</Button>
<button
onClick={() => setSelected(new Set())}
className="text-xs text-muted-foreground hover:text-foreground transition-colors"
>
Clear
</button>
</div>
)}
</div>
</div>
{/* Row count */}
{!loading && (
<p className="text-xs text-muted-foreground">
{visible.length} alarm{visible.length !== 1 ? "s" : ""} matching filter
</p>
)}
{/* Table */}
<Card>
<CardContent className="p-0">
{loading ? (
<div className="p-4 space-y-3">
{Array.from({ length: 5 }).map((_, i) => <Skeleton key={i} className="h-12 w-full" />)}
</div>
) : visible.length === 0 ? (
<div className="flex flex-col items-center justify-center py-16 gap-2 text-muted-foreground">
<CheckCircle2 className="w-8 h-8 text-green-500/50" />
<p className="text-sm">No alarms matching this filter</p>
</div>
) : (
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-border text-xs text-muted-foreground uppercase tracking-wide">
<th className="px-4 py-3 w-8">
<input
type="checkbox"
className="rounded"
checked={visible.filter((a) => a.state !== "resolved").every((a) => selected.has(a.id))}
onChange={toggleSelectAll}
/>
</th>
<th className="text-left px-4 py-3 font-medium">
<button onClick={() => toggleSort("severity")} className="flex items-center gap-1 hover:text-foreground transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary rounded">
Severity <SortIcon col="severity" />
</button>
</th>
<th className="text-left px-4 py-3 font-medium">Message</th>
<th className="text-left px-4 py-3 font-medium">Location</th>
<th className="text-left px-4 py-3 font-medium hidden xl:table-cell">Sensor</th>
<th className="text-left px-4 py-3 font-medium hidden md:table-cell">Category</th>
<th className="text-left px-4 py-3 font-medium">
<button onClick={() => toggleSort("state")} className="flex items-center gap-1 hover:text-foreground transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary rounded">
State <SortIcon col="state" />
</button>
</th>
<th className="text-left px-4 py-3 font-medium">
<button onClick={() => toggleSort("triggered_at")} className="flex items-center gap-1 hover:text-foreground transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary rounded">
Age <SortIcon col="triggered_at" />
</button>
</th>
<th className="text-left px-4 py-3 font-medium hidden md:table-cell">Escalation</th>
<th className="text-left px-4 py-3 font-medium hidden md:table-cell">Assigned</th>
<th className="text-right px-4 py-3 font-medium">Actions</th>
</tr>
</thead>
<tbody className="divide-y divide-border">
{paginated.map((alarm) => {
const sc = stateConfig[alarm.state] ?? stateConfig.active;
const cat = alarmCategory(alarm.sensor_id);
return (
<tr key={alarm.id} className={cn("hover:bg-muted/30 transition-colors", selected.has(alarm.id) && "bg-muted/20")}>
<td className="px-4 py-3 w-8">
{alarm.state !== "resolved" && (
<input
type="checkbox"
className="rounded"
checked={selected.has(alarm.id)}
onChange={() => toggleSelect(alarm.id)}
/>
)}
</td>
<td className="px-4 py-3">
<SeverityBadge severity={alarm.severity} />
</td>
<td className="px-4 py-3 max-w-xs">
<span className="line-clamp-2">{alarm.message}</span>
</td>
<td className="px-4 py-3 text-xs">
{(alarm.room_id || alarm.rack_id) ? (
<div className="flex items-center gap-1 flex-wrap">
{alarm.room_id && (
<button
onClick={() => router.push("/environmental")}
className="text-muted-foreground hover:text-primary transition-colors underline-offset-2 hover:underline"
>
{alarm.room_id}
</button>
)}
{alarm.room_id && alarm.rack_id && <span className="text-muted-foreground/40">/</span>}
{alarm.rack_id && (
<button
onClick={() => setSelectedRack(alarm.rack_id)}
className="text-muted-foreground hover:text-primary transition-colors underline-offset-2 hover:underline"
>
{alarm.rack_id}
</button>
)}
</div>
) : <span className="text-muted-foreground"></span>}
</td>
<td className="px-4 py-3 hidden xl:table-cell">
{alarm.sensor_id ? (
<span className="font-mono text-[10px] text-muted-foreground bg-muted/40 px-1.5 py-0.5 rounded">
{alarm.sensor_id.split("/").slice(-1)[0]}
</span>
) : <span className="text-muted-foreground text-xs"></span>}
</td>
<td className="px-4 py-3 hidden md:table-cell">
<span className={cn("text-[10px] font-semibold px-2 py-0.5 rounded-full", cat.className)}>
{cat.label}
</span>
</td>
<td className="px-4 py-3">
<Badge className={cn("text-[10px] font-semibold border-0", sc.className)}>
{sc.label}
</Badge>
</td>
<td className="px-4 py-3 text-muted-foreground text-xs whitespace-nowrap tabular-nums">
{timeAgo(alarm.triggered_at)}
</td>
<td className="px-4 py-3 hidden md:table-cell">
{alarm.state !== "resolved" && alarm.severity === "critical" ? (
<EscalationTimer triggeredAt={alarm.triggered_at} now={now} />
) : (
<span className="text-muted-foreground/30 text-xs"></span>
)}
</td>
<td className="px-4 py-3 hidden md:table-cell">
<select
value={assignments[alarm.id] ?? ""}
onChange={(e) => setAssignment(alarm.id, e.target.value)}
className="text-[10px] bg-muted/30 border border-border rounded px-1.5 py-0.5 text-foreground/80 focus:outline-none focus:ring-1 focus:ring-primary/50 cursor-pointer"
onClick={(e) => e.stopPropagation()}
>
<option value=""> Unassigned</option>
<option value="Alice T.">Alice T.</option>
<option value="Bob K.">Bob K.</option>
<option value="Charlie L.">Charlie L.</option>
<option value="Dave M.">Dave M.</option>
</select>
</td>
<td className="px-4 py-3 text-right">
<div className="flex items-center justify-end gap-2">
{alarm.state === "active" && (
<Button
size="sm"
variant="outline"
className="h-7 text-xs"
disabled={acting === alarm.id}
onClick={() => handleAcknowledge(alarm.id)}
>
<Clock className="w-3 h-3 mr-1" />
Ack
</Button>
)}
{(alarm.state === "active" || alarm.state === "acknowledged") && (
<Button
size="sm"
variant="outline"
className="h-7 text-xs text-green-400 border-green-500/30 hover:bg-green-500/10"
disabled={acting === alarm.id}
onClick={() => handleResolve(alarm.id)}
>
<XCircle className="w-3 h-3 mr-1" />
Resolve
</Button>
)}
</div>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
)}
</CardContent>
</Card>
{/* Pagination bar */}
{!loading && visible.length > PAGE_SIZE && (
<div className="flex items-center justify-between text-xs text-muted-foreground">
<span>
Showing {(page - 1) * PAGE_SIZE + 1}{Math.min(page * PAGE_SIZE, visible.length)} of {visible.length} alarms
</span>
<div className="flex items-center gap-2">
<Button
size="sm"
variant="outline"
className="h-7 text-xs"
disabled={page <= 1}
onClick={() => setPage(p => p - 1)}
>
Previous
</Button>
<span className="px-2">{page} / {pageCount}</span>
<Button
size="sm"
variant="outline"
className="h-7 text-xs"
disabled={page >= pageCount}
onClick={() => setPage(p => p + 1)}
>
Next
</Button>
</div>
</div>
)}
</div>
);
}

View file

@ -0,0 +1,703 @@
"use client";
import { useEffect, useState, useMemo } from "react";
import { toast } from "sonner";
import {
fetchAssets, fetchAllDevices, fetchPduReadings,
type AssetsData, type RackAsset, type CracAsset, type UpsAsset, type Device, type PduReading,
} from "@/lib/api";
import { RackDetailSheet } from "@/components/dashboard/rack-detail-sheet";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Skeleton } from "@/components/ui/skeleton";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import {
Thermometer, Zap, Wind, Battery, AlertTriangle,
CheckCircle2, HelpCircle, LayoutGrid, List, Download,
} from "lucide-react";
import { cn } from "@/lib/utils";
const SITE_ID = "sg-01";
const roomLabels: Record<string, string> = { "hall-a": "Hall A", "hall-b": "Hall B" };
// ── Status helpers ────────────────────────────────────────────────────────────
const statusStyles: Record<string, { dot: string; border: string }> = {
ok: { dot: "bg-green-500", border: "border-green-500/20" },
warning: { dot: "bg-amber-500", border: "border-amber-500/30" },
critical: { dot: "bg-destructive", border: "border-destructive/30" },
unknown: { dot: "bg-muted", border: "border-border" },
};
const TYPE_STYLES: Record<string, { dot: string; label: string }> = {
server: { dot: "bg-blue-400", label: "Server" },
switch: { dot: "bg-green-400", label: "Switch" },
patch_panel: { dot: "bg-slate-400", label: "Patch Panel" },
pdu: { dot: "bg-amber-400", label: "PDU" },
storage: { dot: "bg-purple-400", label: "Storage" },
firewall: { dot: "bg-red-400", label: "Firewall" },
kvm: { dot: "bg-teal-400", label: "KVM" },
};
// ── Compact CRAC row ──────────────────────────────────────────────────────────
function CracRow({ crac }: { crac: CracAsset }) {
const online = crac.state === "online";
const fault = crac.state === "fault";
return (
<div className="flex items-center gap-3 px-3 py-2 text-xs">
<Wind className="w-3.5 h-3.5 text-primary shrink-0" />
<span className="font-semibold font-mono w-20 shrink-0">{crac.crac_id.toUpperCase()}</span>
<span className={cn(
"flex items-center gap-1 text-[10px] font-semibold px-1.5 py-0.5 rounded-full uppercase tracking-wide shrink-0",
fault ? "bg-destructive/10 text-destructive" :
online ? "bg-green-500/10 text-green-400" :
"bg-muted text-muted-foreground",
)}>
{fault ? <AlertTriangle className="w-2.5 h-2.5" /> :
online ? <CheckCircle2 className="w-2.5 h-2.5" /> :
<HelpCircle className="w-2.5 h-2.5" />}
{fault ? "Fault" : online ? "Online" : "Unk"}
</span>
<span className="text-muted-foreground">Supply: <span className="text-foreground font-medium">{crac.supply_temp !== null ? `${crac.supply_temp}°C` : "—"}</span></span>
<span className="text-muted-foreground">Return: <span className="text-foreground font-medium">{crac.return_temp !== null ? `${crac.return_temp}°C` : "—"}</span></span>
<span className="text-muted-foreground">Fan: <span className="text-foreground font-medium">{crac.fan_pct !== null ? `${crac.fan_pct}%` : "—"}</span></span>
</div>
);
}
// ── Compact UPS row ───────────────────────────────────────────────────────────
function UpsRow({ ups }: { ups: UpsAsset }) {
const onBattery = ups.state === "battery";
return (
<div className="flex items-center gap-3 px-3 py-2 text-xs">
<Battery className="w-3.5 h-3.5 text-primary shrink-0" />
<span className="font-semibold font-mono w-20 shrink-0">{ups.ups_id.toUpperCase()}</span>
<span className={cn(
"flex items-center gap-1 text-[10px] font-semibold px-1.5 py-0.5 rounded-full uppercase tracking-wide shrink-0",
onBattery ? "bg-amber-500/10 text-amber-400" :
ups.state === "online" ? "bg-green-500/10 text-green-400" :
"bg-muted text-muted-foreground",
)}>
{onBattery ? <AlertTriangle className="w-2.5 h-2.5" /> : <CheckCircle2 className="w-2.5 h-2.5" />}
{onBattery ? "Battery" : ups.state === "online" ? "Mains" : "Unk"}
</span>
<span className="text-muted-foreground">Charge: <span className="text-foreground font-medium">{ups.charge_pct !== null ? `${ups.charge_pct}%` : "—"}</span></span>
<span className="text-muted-foreground">Load: <span className="text-foreground font-medium">{ups.load_pct !== null ? `${ups.load_pct}%` : "—"}</span></span>
</div>
);
}
// ── Rack sortable table ───────────────────────────────────────────────────────
type RackSortCol = "rack_id" | "temp" | "power_kw" | "power_pct" | "alarm_count" | "status";
type SortDir = "asc" | "desc";
function RackTable({
racks, roomId, statusFilter, onRackClick,
}: {
racks: RackAsset[];
roomId: string;
statusFilter: "all" | "warning" | "critical";
onRackClick: (id: string) => void;
}) {
const [sortCol, setSortCol] = useState<RackSortCol>("rack_id");
const [sortDir, setSortDir] = useState<SortDir>("asc");
function toggleSort(col: RackSortCol) {
if (sortCol === col) setSortDir(d => d === "asc" ? "desc" : "asc");
else { setSortCol(col); setSortDir("asc"); }
}
function SortIcon({ col }: { col: RackSortCol }) {
if (sortCol !== col) return <span className="opacity-30 ml-0.5"></span>;
return <span className="ml-0.5">{sortDir === "asc" ? "↑" : "↓"}</span>;
}
const filtered = useMemo(() => {
const base = statusFilter === "all" ? racks : racks.filter(r => r.status === statusFilter);
return [...base].sort((a, b) => {
let cmp = 0;
if (sortCol === "temp" || sortCol === "power_kw" || sortCol === "alarm_count") {
cmp = ((a[sortCol] ?? 0) as number) - ((b[sortCol] ?? 0) as number);
} else if (sortCol === "power_pct") {
const aP = a.power_kw !== null ? a.power_kw / 10 * 100 : 0;
const bP = b.power_kw !== null ? b.power_kw / 10 * 100 : 0;
cmp = aP - bP;
} else {
cmp = String(a[sortCol]).localeCompare(String(b[sortCol]));
}
return sortDir === "asc" ? cmp : -cmp;
});
}, [racks, statusFilter, sortCol, sortDir]);
type ColDef = { col: RackSortCol; label: string };
const cols: ColDef[] = [
{ col: "rack_id", label: "Rack ID" },
{ col: "temp", label: "Temp (°C)" },
{ col: "power_kw", label: "Power (kW)" },
{ col: "power_pct", label: "Power%" },
{ col: "alarm_count", label: "Alarms" },
{ col: "status", label: "Status" },
];
return (
<div className="rounded-lg border border-border overflow-hidden">
<table className="w-full text-xs">
<thead>
<tr className="border-b border-border bg-muted/40 text-muted-foreground select-none">
{cols.map(({ col, label }) => (
<th key={col} className="text-left px-3 py-2">
<button
onClick={() => toggleSort(col)}
className="font-semibold hover:text-foreground transition-colors flex items-center gap-0.5"
>
{label}<SortIcon col={col} />
</button>
</th>
))}
{/* Room column header */}
<th className="text-left px-3 py-2 font-semibold text-muted-foreground">Room</th>
</tr>
</thead>
<tbody>
{filtered.length === 0 ? (
<tr>
<td colSpan={7} className="text-center py-8 text-muted-foreground">No racks matching this filter</td>
</tr>
) : (
filtered.map(rack => {
const powerPct = rack.power_kw !== null ? (rack.power_kw / 10) * 100 : null;
const tempCls = rack.temp !== null
? rack.temp >= 30 ? "text-destructive" : rack.temp >= 28 ? "text-amber-400" : ""
: "";
const pctCls = powerPct !== null
? powerPct >= 85 ? "text-destructive" : powerPct >= 75 ? "text-amber-400" : ""
: "";
const s = statusStyles[rack.status] ?? statusStyles.unknown;
return (
<tr
key={rack.rack_id}
onClick={() => onRackClick(rack.rack_id)}
className="border-b border-border/40 last:border-0 hover:bg-muted/20 transition-colors cursor-pointer"
>
<td className="px-3 py-2 font-mono font-semibold">{rack.rack_id.toUpperCase()}</td>
<td className={cn("px-3 py-2 tabular-nums font-medium", tempCls)}>
{rack.temp !== null ? rack.temp : "—"}
</td>
<td className="px-3 py-2 tabular-nums text-muted-foreground">
{rack.power_kw !== null ? rack.power_kw : "—"}
</td>
<td className={cn("px-3 py-2 tabular-nums font-medium", pctCls)}>
{powerPct !== null ? `${powerPct.toFixed(0)}%` : "—"}
</td>
<td className="px-3 py-2">
{rack.alarm_count > 0
? <span className="font-bold text-destructive">{rack.alarm_count}</span>
: <span className="text-muted-foreground">0</span>}
</td>
<td className="px-3 py-2">
<div className="flex items-center gap-1.5">
<span className={cn("w-2 h-2 rounded-full", s.dot)} />
<span className="capitalize text-muted-foreground">{rack.status}</span>
</div>
</td>
<td className="px-3 py-2 text-muted-foreground">{roomLabels[roomId] ?? roomId}</td>
</tr>
);
})
)}
</tbody>
</table>
</div>
);
}
// ── Inventory table ───────────────────────────────────────────────────────────
type SortCol = "name" | "type" | "rack_id" | "room_id" | "u_start" | "power_draw_w";
function InventoryTable({ siteId, onRackClick }: { siteId: string; onRackClick: (rackId: string) => void }) {
const [devices, setDevices] = useState<Device[]>([]);
const [loading, setLoading] = useState(true);
const [search, setSearch] = useState("");
const [typeFilter, setTypeFilter] = useState<string>("all");
const [roomFilter, setRoomFilter] = useState<string>("all");
const [sortCol, setSortCol] = useState<SortCol>("name");
const [sortDir, setSortDir] = useState<SortDir>("asc");
useEffect(() => {
fetchAllDevices(siteId)
.then(setDevices)
.catch(() => {})
.finally(() => setLoading(false));
}, [siteId]);
function toggleSort(col: SortCol) {
if (sortCol === col) setSortDir(d => d === "asc" ? "desc" : "asc");
else { setSortCol(col); setSortDir("asc"); }
}
function SortIcon({ col }: { col: SortCol }) {
if (sortCol !== col) return <span className="opacity-30 ml-0.5"></span>;
return <span className="ml-0.5">{sortDir === "asc" ? "↑" : "↓"}</span>;
}
const filtered = useMemo(() => {
const q = search.toLowerCase();
const base = devices.filter(d => {
if (typeFilter !== "all" && d.type !== typeFilter) return false;
if (roomFilter !== "all" && d.room_id !== roomFilter) return false;
if (q && !d.name.toLowerCase().includes(q) && !d.rack_id.includes(q) && !d.ip.includes(q) && !d.serial.toLowerCase().includes(q)) return false;
return true;
});
return [...base].sort((a, b) => {
let cmp = 0;
if (sortCol === "power_draw_w" || sortCol === "u_start") {
cmp = (a[sortCol] ?? 0) - (b[sortCol] ?? 0);
} else {
cmp = String(a[sortCol]).localeCompare(String(b[sortCol]));
}
return sortDir === "asc" ? cmp : -cmp;
});
}, [devices, search, typeFilter, roomFilter, sortCol, sortDir]);
const totalPower = filtered.reduce((s, d) => s + d.power_draw_w, 0);
const types = Array.from(new Set(devices.map(d => d.type))).sort();
function downloadCsv() {
const headers = ["Device", "Type", "Rack", "Room", "U Start", "U Height", "IP", "Serial", "Power (W)", "Status"];
const rows = filtered.map((d) => [
d.name, TYPE_STYLES[d.type]?.label ?? d.type, d.rack_id.toUpperCase(),
roomLabels[d.room_id] ?? d.room_id, d.u_start, d.u_height,
d.ip !== "-" ? d.ip : "", d.serial, d.power_draw_w, d.status,
]);
const csv = [headers, ...rows]
.map((r) => r.map((v) => `"${String(v ?? "").replace(/"/g, '""')}"`).join(","))
.join("\n");
const blob = new Blob([csv], { type: "text/csv;charset=utf-8;" });
const url = URL.createObjectURL(blob);
const a = Object.assign(document.createElement("a"), {
href: url, download: `bms-inventory-${new Date().toISOString().slice(0, 10)}.csv`,
});
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
toast.success("Export downloaded");
}
if (loading) {
return (
<div className="space-y-2 mt-4">
{Array.from({ length: 8 }).map((_, i) => <Skeleton key={i} className="h-10 w-full" />)}
</div>
);
}
return (
<div className="space-y-4">
{/* Device type legend */}
<div className="flex flex-wrap gap-3 items-center">
{Object.entries(TYPE_STYLES).map(([key, { dot, label }]) => (
<div key={key} className="flex items-center gap-1.5 text-xs text-muted-foreground">
<span className={cn("w-2.5 h-2.5 rounded-full shrink-0", dot)} />
{label}
</div>
))}
</div>
{/* Filters */}
<div className="flex flex-wrap gap-2 items-center">
<input
type="text"
placeholder="Search name, rack, IP, serial…"
value={search}
onChange={e => setSearch(e.target.value)}
className="flex-1 min-w-48 h-8 rounded-md border border-border bg-muted/30 px-3 text-xs focus:outline-none focus:ring-1 focus:ring-primary"
/>
<select
value={typeFilter}
onChange={e => setTypeFilter(e.target.value)}
className="h-8 rounded-md border border-border bg-muted/30 px-2 text-xs focus:outline-none"
>
<option value="all">All types</option>
{types.map(t => <option key={t} value={t}>{TYPE_STYLES[t]?.label ?? t}</option>)}
</select>
<select
value={roomFilter}
onChange={e => setRoomFilter(e.target.value)}
className="h-8 rounded-md border border-border bg-muted/30 px-2 text-xs focus:outline-none"
>
<option value="all">All rooms</option>
<option value="hall-a">Hall A</option>
<option value="hall-b">Hall B</option>
</select>
<span className="text-xs text-muted-foreground ml-auto">
{filtered.length} devices · {(totalPower / 1000).toFixed(1)} kW
</span>
<button
onClick={downloadCsv}
className="flex items-center gap-1.5 h-8 px-3 rounded-md border border-border bg-muted/30 text-xs text-muted-foreground hover:text-foreground hover:bg-muted/50 transition-colors"
>
<Download className="w-3.5 h-3.5" /> Export CSV
</button>
</div>
{/* Table */}
<div className="rounded-lg border border-border overflow-hidden">
<table className="w-full text-xs">
<thead>
<tr className="border-b border-border bg-muted/40 text-muted-foreground select-none">
{([
{ col: "name" as SortCol, label: "Device", cls: "text-left px-3 py-2" },
{ col: "type" as SortCol, label: "Type", cls: "text-left px-3 py-2" },
{ col: "rack_id" as SortCol, label: "Rack", cls: "text-left px-3 py-2" },
{ col: "room_id" as SortCol, label: "Room", cls: "text-left px-3 py-2 hidden sm:table-cell" },
{ col: "u_start" as SortCol, label: "U", cls: "text-left px-3 py-2 hidden md:table-cell" },
]).map(({ col, label, cls }) => (
<th key={col} className={cls}>
<button
onClick={() => toggleSort(col)}
className="font-semibold hover:text-foreground transition-colors flex items-center gap-0.5"
>
{label}<SortIcon col={col} />
</button>
</th>
))}
<th className="text-left px-3 py-2 font-semibold hidden md:table-cell">IP</th>
<th className="text-right px-3 py-2">
<button
onClick={() => toggleSort("power_draw_w")}
className="font-semibold hover:text-foreground transition-colors flex items-center gap-0.5 ml-auto"
>
Power<SortIcon col="power_draw_w" />
</button>
</th>
<th className="text-left px-3 py-2 font-semibold">Status</th>
<th className="text-left px-3 py-2 font-semibold hidden lg:table-cell">Lifecycle</th>
</tr>
</thead>
<tbody>
{filtered.length === 0 ? (
<tr>
<td colSpan={9} className="text-center py-8 text-muted-foreground">
No devices match your filters.
</td>
</tr>
) : (
filtered.map(d => {
const ts = TYPE_STYLES[d.type];
return (
<tr key={d.device_id} className="border-b border-border/40 last:border-0 hover:bg-muted/20 transition-colors cursor-pointer" onClick={() => onRackClick(d.rack_id)}>
<td className="px-3 py-2">
<div className="font-medium truncate max-w-[180px]">{d.name}</div>
<div className="text-[10px] text-muted-foreground font-mono">{d.serial}</div>
</td>
<td className="px-3 py-2">
<div className="flex items-center gap-1.5">
<span className={cn("w-1.5 h-1.5 rounded-full shrink-0", ts?.dot ?? "bg-muted")} />
<span className="text-muted-foreground">{ts?.label ?? d.type}</span>
</div>
</td>
<td className="px-3 py-2 font-mono">{d.rack_id.toUpperCase()}</td>
<td className="px-3 py-2 hidden sm:table-cell text-muted-foreground">
{roomLabels[d.room_id] ?? d.room_id}
</td>
<td className="px-3 py-2 hidden md:table-cell text-muted-foreground font-mono">
U{d.u_start}{d.u_height > 1 ? `U${d.u_start + d.u_height - 1}` : ""}
</td>
<td className="px-3 py-2 hidden md:table-cell font-mono text-muted-foreground">
{d.ip !== "-" ? d.ip : "—"}
</td>
<td className="px-3 py-2 text-right font-mono">{d.power_draw_w} W</td>
<td className="px-3 py-2">
<span className="text-[10px] font-semibold px-1.5 py-0.5 rounded-full bg-green-500/10 text-green-400">
online
</span>
</td>
<td className="px-3 py-2 hidden lg:table-cell">
<span className={cn(
"text-[10px] font-semibold px-1.5 py-0.5 rounded-full",
d.status === "online" ? "bg-blue-500/10 text-blue-400" :
d.status === "offline" ? "bg-amber-500/10 text-amber-400" :
"bg-muted/50 text-muted-foreground"
)}>
{d.status === "online" ? "Active" : d.status === "offline" ? "Offline" : "Unknown"}
</span>
</td>
</tr>
);
})
)}
</tbody>
</table>
</div>
</div>
);
}
// ── PDU Monitoring ────────────────────────────────────────────────────────────
function PduMonitoringSection({ siteId }: { siteId: string }) {
const [pdus, setPdus] = useState<PduReading[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
fetchPduReadings(siteId)
.then(setPdus)
.catch(() => {})
.finally(() => setLoading(false));
const id = setInterval(() => fetchPduReadings(siteId).then(setPdus).catch(() => {}), 30_000);
return () => clearInterval(id);
}, [siteId]);
const critical = pdus.filter(p => p.status === "critical").length;
const warning = pdus.filter(p => p.status === "warning").length;
return (
<Card>
<CardHeader className="pb-3">
<div className="flex items-center justify-between">
<CardTitle className="text-sm font-semibold flex items-center gap-2">
<Zap className="w-4 h-4 text-amber-400" /> PDU Phase Monitoring
</CardTitle>
<div className="flex items-center gap-2 text-[10px]">
{critical > 0 && <span className="text-destructive font-semibold">{critical} critical</span>}
{warning > 0 && <span className="text-amber-400 font-semibold">{warning} warning</span>}
{critical === 0 && warning === 0 && !loading && (
<span className="text-green-400 font-semibold">All balanced</span>
)}
</div>
</div>
</CardHeader>
<CardContent className="p-0">
{loading ? (
<div className="p-4 space-y-2">
{Array.from({ length: 4 }).map((_, i) => <Skeleton key={i} className="h-8 w-full" />)}
</div>
) : (
<div className="overflow-x-auto">
<table className="w-full text-xs">
<thead>
<tr className="border-b border-border bg-muted/30 text-muted-foreground">
<th className="text-left px-4 py-2 font-semibold">Rack</th>
<th className="text-left px-4 py-2 font-semibold hidden sm:table-cell">Room</th>
<th className="text-right px-4 py-2 font-semibold">Total kW</th>
<th className="text-right px-4 py-2 font-semibold hidden md:table-cell">Ph-A kW</th>
<th className="text-right px-4 py-2 font-semibold hidden md:table-cell">Ph-B kW</th>
<th className="text-right px-4 py-2 font-semibold hidden md:table-cell">Ph-C kW</th>
<th className="text-right px-4 py-2 font-semibold hidden lg:table-cell">Ph-A A</th>
<th className="text-right px-4 py-2 font-semibold hidden lg:table-cell">Ph-B A</th>
<th className="text-right px-4 py-2 font-semibold hidden lg:table-cell">Ph-C A</th>
<th className="text-right px-4 py-2 font-semibold">Imbalance</th>
</tr>
</thead>
<tbody className="divide-y divide-border/40">
{pdus.map(p => (
<tr key={p.rack_id} className={cn(
"hover:bg-muted/20 transition-colors",
p.status === "critical" && "bg-destructive/5",
p.status === "warning" && "bg-amber-500/5",
)}>
<td className="px-4 py-2 font-mono font-medium">{p.rack_id.toUpperCase().replace("RACK-", "")}</td>
<td className="px-4 py-2 hidden sm:table-cell text-muted-foreground">
{roomLabels[p.room_id] ?? p.room_id}
</td>
<td className="px-4 py-2 text-right tabular-nums font-medium">
{p.total_kw !== null ? p.total_kw.toFixed(2) : "—"}
</td>
<td className="px-4 py-2 text-right tabular-nums hidden md:table-cell text-muted-foreground">
{p.phase_a_kw !== null ? p.phase_a_kw.toFixed(2) : "—"}
</td>
<td className="px-4 py-2 text-right tabular-nums hidden md:table-cell text-muted-foreground">
{p.phase_b_kw !== null ? p.phase_b_kw.toFixed(2) : "—"}
</td>
<td className="px-4 py-2 text-right tabular-nums hidden md:table-cell text-muted-foreground">
{p.phase_c_kw !== null ? p.phase_c_kw.toFixed(2) : "—"}
</td>
<td className="px-4 py-2 text-right tabular-nums hidden lg:table-cell text-muted-foreground">
{p.phase_a_a !== null ? p.phase_a_a.toFixed(1) : "—"}
</td>
<td className="px-4 py-2 text-right tabular-nums hidden lg:table-cell text-muted-foreground">
{p.phase_b_a !== null ? p.phase_b_a.toFixed(1) : "—"}
</td>
<td className="px-4 py-2 text-right tabular-nums hidden lg:table-cell text-muted-foreground">
{p.phase_c_a !== null ? p.phase_c_a.toFixed(1) : "—"}
</td>
<td className="px-4 py-2 text-right tabular-nums">
{p.imbalance_pct !== null ? (
<span className={cn(
"font-semibold",
p.status === "critical" ? "text-destructive" :
p.status === "warning" ? "text-amber-400" : "text-green-400",
)}>
{p.imbalance_pct.toFixed(1)}%
</span>
) : "—"}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</CardContent>
</Card>
);
}
// ── Page ──────────────────────────────────────────────────────────────────────
export default function AssetsPage() {
const [data, setData] = useState<AssetsData | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(false);
const [selectedRack, setSelectedRack] = useState<string | null>(null);
const [statusFilter, setStatusFilter] = useState<"all" | "warning" | "critical">("all");
const [view, setView] = useState<"grid" | "inventory">("grid");
async function load() {
try { const d = await fetchAssets(SITE_ID); setData(d); setError(false); }
catch { setError(true); toast.error("Failed to load asset data"); }
finally { setLoading(false); }
}
useEffect(() => {
load();
const id = setInterval(load, 30_000);
return () => clearInterval(id);
}, []);
if (loading) {
return (
<div className="p-6 space-y-4">
<Skeleton className="h-8 w-48" />
<div className="grid grid-cols-2 sm:grid-cols-4 lg:grid-cols-5 gap-3">
{Array.from({ length: 10 }).map((_, i) => <Skeleton key={i} className="h-20" />)}
</div>
</div>
);
}
if (error || !data) {
return (
<div className="p-6 flex items-center justify-center h-64 text-sm text-muted-foreground">
Unable to load asset data.
</div>
);
}
const defaultTab = data.rooms[0]?.room_id ?? "";
const totalRacks = data.rooms.reduce((s, r) => s + r.racks.length, 0);
const critCount = data.rooms.flatMap(r => r.racks).filter(r => r.status === "critical").length;
const warnCount = data.rooms.flatMap(r => r.racks).filter(r => r.status === "warning").length;
return (
<div className="p-6 space-y-6">
{/* Header */}
<div className="flex items-start justify-between">
<div>
<h1 className="text-xl font-semibold">Asset Registry</h1>
<p className="text-sm text-muted-foreground">
Singapore DC01 · {totalRacks} racks
{critCount > 0 && <span className="text-destructive ml-2">· {critCount} critical</span>}
{warnCount > 0 && <span className="text-amber-400 ml-2">· {warnCount} warning</span>}
</p>
</div>
{/* View toggle */}
<div className="flex items-center gap-1 rounded-lg border border-border p-1">
<button
onClick={() => setView("grid")}
className={cn(
"flex items-center gap-1.5 px-3 py-1.5 rounded-md text-xs font-medium transition-colors",
view === "grid" ? "bg-primary text-primary-foreground" : "text-muted-foreground hover:text-foreground",
)}
>
<LayoutGrid className="w-3.5 h-3.5" /> Grid
</button>
<button
onClick={() => setView("inventory")}
className={cn(
"flex items-center gap-1.5 px-3 py-1.5 rounded-md text-xs font-medium transition-colors",
view === "inventory" ? "bg-primary text-primary-foreground" : "text-muted-foreground hover:text-foreground",
)}
>
<List className="w-3.5 h-3.5" /> Inventory
</button>
</div>
</div>
<RackDetailSheet siteId={SITE_ID} rackId={selectedRack} onClose={() => setSelectedRack(null)} />
{view === "inventory" ? (
<InventoryTable siteId={SITE_ID} onRackClick={setSelectedRack} />
) : (
<>
{/* Compact UPS + CRAC rows */}
<div className="rounded-lg border border-border divide-y divide-border/50">
{data.ups_units.map(ups => <UpsRow key={ups.ups_id} ups={ups} />)}
{data.rooms.map(room => <CracRow key={room.crac.crac_id} crac={room.crac} />)}
</div>
{/* PDU phase monitoring */}
<PduMonitoringSection siteId={SITE_ID} />
{/* Per-room rack table */}
<Tabs defaultValue={defaultTab}>
<TabsList>
{data.rooms.map(room => (
<TabsTrigger key={room.room_id} value={room.room_id}>
{roomLabels[room.room_id] ?? room.room_id}
</TabsTrigger>
))}
</TabsList>
{data.rooms.map(room => {
const rWarn = room.racks.filter(r => r.status === "warning").length;
const rCrit = room.racks.filter(r => r.status === "critical").length;
return (
<TabsContent key={room.room_id} value={room.room_id} className="space-y-4 mt-4">
<div>
<div className="flex items-center justify-between mb-3">
<h2 className="text-xs font-semibold uppercase tracking-widest text-muted-foreground">
Racks {roomLabels[room.room_id] ?? room.room_id}
</h2>
<div className="flex items-center gap-1">
{(["all", "warning", "critical"] as const).map(f => (
<button
key={f}
onClick={() => setStatusFilter(f)}
className={cn(
"px-2.5 py-1 rounded-md text-xs font-medium capitalize transition-colors",
statusFilter === f
? "bg-primary text-primary-foreground"
: "text-muted-foreground hover:text-foreground hover:bg-muted",
)}
>
{f === "all" ? `All (${room.racks.length})`
: f === "warning" ? `Warn (${rWarn})`
: `Crit (${rCrit})`}
</button>
))}
</div>
</div>
<RackTable
racks={room.racks}
roomId={room.room_id}
statusFilter={statusFilter}
onRackClick={setSelectedRack}
/>
</div>
</TabsContent>
);
})}
</Tabs>
</>
)}
</div>
);
}

View file

@ -0,0 +1,596 @@
"use client";
import { useEffect, useState, useCallback } from "react";
import { toast } from "sonner";
import { fetchCapacitySummary, type CapacitySummary, type RoomCapacity, type RackCapacity } from "@/lib/api";
import { PageShell } from "@/components/layout/page-shell";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Skeleton } from "@/components/ui/skeleton";
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ReferenceLine, ResponsiveContainer, Cell } from "recharts";
import { Zap, Wind, Server, RefreshCw, AlertTriangle, TrendingDown, TrendingUp, Clock } from "lucide-react";
import { cn } from "@/lib/utils";
const SITE_ID = "sg-01";
const ROOM_LABELS: Record<string, string> = { "hall-a": "Hall A", "hall-b": "Hall B" };
// ── Radial gauge ──────────────────────────────────────────────────────────────
function RadialGauge({ pct, warn, crit, headroom, unit }: { pct: number; warn: number; crit: number; headroom?: number; unit?: string }) {
const r = 36;
const circumference = 2 * Math.PI * r;
const arc = circumference * 0.75; // 270° sweep
const filled = Math.min(pct / 100, 1) * arc;
const color =
pct >= crit ? "#ef4444" :
pct >= warn ? "#f59e0b" :
"#22c55e";
const textColor =
pct >= crit ? "text-destructive" :
pct >= warn ? "text-amber-400" :
"text-green-400";
return (
<div className="relative flex items-center justify-center py-1">
<svg viewBox="0 0 100 100" className="w-28 h-28 -rotate-[135deg]" style={{ overflow: "visible" }}>
{/* Track */}
<circle
cx="50" cy="50" r={r}
fill="none"
strokeWidth="9"
className="stroke-muted"
strokeDasharray={`${arc} ${circumference}`}
strokeLinecap="round"
/>
{/* Fill */}
<circle
cx="50" cy="50" r={r}
fill="none"
strokeWidth="9"
stroke={color}
strokeDasharray={`${filled} ${circumference}`}
strokeLinecap="round"
style={{ transition: "stroke-dasharray 0.7s ease" }}
/>
</svg>
<div className="absolute text-center pointer-events-none">
<span className={cn("text-3xl font-bold tabular-nums leading-none", textColor)}>
{pct.toFixed(1)}
</span>
<span className="text-xs text-muted-foreground">%</span>
{headroom !== undefined && unit !== undefined && (
<p className="text-[9px] text-muted-foreground leading-tight mt-0.5">
{headroom.toFixed(1)} {unit}
</p>
)}
</div>
</div>
);
}
// ── Capacity gauge card ────────────────────────────────────────────────────────
function CapacityGauge({
label, used, capacity, unit, pct, headroom, icon: Icon, warn = 70, crit = 85,
}: {
label: string; used: number; capacity: number; unit: string; pct: number;
headroom: number; icon: React.ElementType; warn?: number; crit?: number;
}) {
const textColor = pct >= crit ? "text-destructive" : pct >= warn ? "text-amber-400" : "text-green-400";
const status = pct >= crit ? "Critical" : pct >= warn ? "Warning" : "OK";
return (
<div className="space-y-2 rounded-xl border border-border bg-muted/10 p-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2 text-sm font-medium">
<Icon className="w-4 h-4 text-primary" />
{label}
</div>
<span className={cn("text-[10px] font-semibold px-2 py-0.5 rounded-full uppercase", textColor,
pct >= crit ? "bg-destructive/10" : pct >= warn ? "bg-amber-500/10" : "bg-green-500/10"
)}>
{status}
</span>
</div>
<RadialGauge pct={pct} warn={warn} crit={crit} headroom={headroom} unit={unit} />
<div className="flex justify-between text-xs text-muted-foreground">
<span><strong className="text-foreground">{used.toFixed(1)}</strong> {unit} used</span>
<span><strong className="text-foreground">{capacity.toFixed(0)}</strong> {unit} rated</span>
</div>
<div className={cn(
"rounded-lg px-3 py-2 text-xs",
pct >= crit ? "bg-destructive/10 text-destructive" :
pct >= warn ? "bg-amber-500/10 text-amber-400" :
"bg-green-500/10 text-green-400"
)}>
{headroom.toFixed(1)} {unit} headroom remaining
</div>
</div>
);
}
// ── Capacity runway component ──────────────────────────────────────
// Assumes ~0.5 kW/week average growth rate to forecast when limits are hit
const GROWTH_KW_WEEK = 0.5;
const WARN_PCT = 85;
function RunwayCard({ rooms }: { rooms: RoomCapacity[] }) {
return (
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-semibold flex items-center gap-2">
<Clock className="w-4 h-4 text-muted-foreground" />
Capacity Runway
<span className="text-[10px] text-muted-foreground font-normal ml-1">
(assuming {GROWTH_KW_WEEK} kW/week growth)
</span>
</CardTitle>
</CardHeader>
<CardContent>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
{rooms.map((room) => {
const powerHeadroomToWarn = Math.max(0, room.power.capacity_kw * (WARN_PCT / 100) - room.power.used_kw);
const coolHeadroomToWarn = Math.max(0, room.cooling.capacity_kw * (WARN_PCT / 100) - room.cooling.load_kw);
const powerRunwayWeeks = Math.round(powerHeadroomToWarn / GROWTH_KW_WEEK);
const coolRunwayWeeks = Math.round(coolHeadroomToWarn / GROWTH_KW_WEEK);
const constrainedBy = powerRunwayWeeks <= coolRunwayWeeks ? "power" : "cooling";
const minRunway = Math.min(powerRunwayWeeks, coolRunwayWeeks);
const runwayColor =
minRunway < 4 ? "text-destructive" :
minRunway < 12 ? "text-amber-400" :
"text-green-400";
// N+1 cooling: at 1 CRAC per room, losing it means all load hits chillers/other rooms
const n1Margin = room.cooling.capacity_kw - room.cooling.load_kw;
const n1Ok = n1Margin > room.cooling.capacity_kw * 0.2; // 20% spare = N+1 safe
return (
<div key={room.room_id} className="rounded-xl border border-border bg-muted/10 p-4 space-y-3">
<div className="flex items-center justify-between">
<span className="text-sm font-semibold">{ROOM_LABELS[room.room_id] ?? room.room_id}</span>
<div className="flex items-center gap-2">
<span className={cn(
"text-[10px] font-semibold px-2 py-0.5 rounded-full uppercase",
n1Ok ? "bg-green-500/10 text-green-400" : "bg-amber-500/10 text-amber-400",
)}>
{n1Ok ? "N+1 OK" : "N+1 marginal"}
</span>
</div>
</div>
<div className="flex items-center gap-3">
<TrendingUp className={cn("w-8 h-8 shrink-0", runwayColor)} />
<div>
<p className={cn("text-2xl font-bold tabular-nums leading-none", runwayColor)}>
{minRunway}w
</p>
<p className={cn("text-xs tabular-nums text-muted-foreground leading-none mt-0.5")}>
{(minRunway / 4.33).toFixed(1)}mo
</p>
<p className="text-[10px] text-muted-foreground mt-0.5">
until {WARN_PCT}% {constrainedBy} limit
</p>
</div>
</div>
<div className="grid grid-cols-2 gap-2 text-[11px]">
<div className={cn(
"rounded-lg px-2.5 py-2",
powerRunwayWeeks < 4 ? "bg-destructive/10" :
powerRunwayWeeks < 12 ? "bg-amber-500/10" : "bg-muted/30",
)}>
<p className="text-muted-foreground mb-0.5">Power runway</p>
<p className={cn(
"font-bold",
powerRunwayWeeks < 4 ? "text-destructive" :
powerRunwayWeeks < 12 ? "text-amber-400" : "text-green-400",
)}>
{powerRunwayWeeks}w / {(powerRunwayWeeks / 4.33).toFixed(1)}mo
</p>
<p className="text-muted-foreground">{powerHeadroomToWarn.toFixed(1)} kW free</p>
</div>
<div className={cn(
"rounded-lg px-2.5 py-2",
coolRunwayWeeks < 4 ? "bg-destructive/10" :
coolRunwayWeeks < 12 ? "bg-amber-500/10" : "bg-muted/30",
)}>
<p className="text-muted-foreground mb-0.5">Cooling runway</p>
<p className={cn(
"font-bold",
coolRunwayWeeks < 4 ? "text-destructive" :
coolRunwayWeeks < 12 ? "text-amber-400" : "text-green-400",
)}>
{coolRunwayWeeks}w / {(coolRunwayWeeks / 4.33).toFixed(1)}mo
</p>
<p className="text-muted-foreground">{coolHeadroomToWarn.toFixed(1)} kW free</p>
</div>
</div>
</div>
);
})}
</div>
</CardContent>
</Card>
);
}
// ── Room summary strip ────────────────────────────────────────────────────────
function RoomSummaryStrip({ rooms }: { rooms: RoomCapacity[] }) {
return (
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
{rooms.map((room) => {
const powerPct = room.power.pct;
const coolPct = room.cooling.pct;
const worstPct = Math.max(powerPct, coolPct);
const worstColor =
worstPct >= 85 ? "border-destructive/40 bg-destructive/5" :
worstPct >= 70 ? "border-amber-500/40 bg-amber-500/5" :
"border-border bg-muted/10";
return (
<div key={room.room_id} className={cn("rounded-xl border px-4 py-3 space-y-2", worstColor)}>
<div className="flex items-center justify-between">
<span className="text-sm font-semibold">{ROOM_LABELS[room.room_id] ?? room.room_id}</span>
<span className={cn(
"text-[10px] font-semibold px-2 py-0.5 rounded-full uppercase",
worstPct >= 85 ? "bg-destructive/10 text-destructive" :
worstPct >= 70 ? "bg-amber-500/10 text-amber-400" :
"bg-green-500/10 text-green-400"
)}>
{worstPct >= 85 ? "Critical" : worstPct >= 70 ? "Warning" : "OK"}
</span>
</div>
<div className="grid grid-cols-3 gap-3 text-xs">
<div>
<p className="text-muted-foreground mb-1">Power</p>
<p className={cn("font-bold text-sm", powerPct >= 85 ? "text-destructive" : powerPct >= 70 ? "text-amber-400" : "text-green-400")}>
{powerPct.toFixed(1)}%
</p>
<p className="text-[10px] text-muted-foreground">{room.power.used_kw.toFixed(1)} / {room.power.capacity_kw} kW</p>
</div>
<div>
<p className="text-muted-foreground mb-1">Cooling</p>
<p className={cn("font-bold text-sm", coolPct >= 80 ? "text-destructive" : coolPct >= 65 ? "text-amber-400" : "text-green-400")}>
{coolPct.toFixed(1)}%
</p>
<p className="text-[10px] text-muted-foreground">{room.cooling.load_kw.toFixed(1)} / {room.cooling.capacity_kw} kW</p>
</div>
<div>
<p className="text-muted-foreground mb-1">Space</p>
<p className="font-bold text-sm text-foreground">{room.space.racks_populated} / {room.space.racks_total}</p>
<p className="text-[10px] text-muted-foreground">{room.space.racks_total - room.space.racks_populated} slots free</p>
</div>
</div>
</div>
);
})}
</div>
);
}
// ── Room capacity section ─────────────────────────────────────────────────────
function RoomCapacityPanel({ room, racks, config }: {
room: RoomCapacity;
racks: RackCapacity[];
config: CapacitySummary["config"];
}) {
const roomRacks = racks.filter((r) => r.room_id === room.room_id);
const chartData = roomRacks
.map((r) => ({
rack: r.rack_id.replace("rack-", "").toUpperCase(),
rack_id: r.rack_id,
pct: r.power_pct ?? 0,
kw: r.power_kw ?? 0,
temp: r.temp,
}))
.sort((a, b) => b.pct - a.pct);
const forecastPct = Math.min(100, (chartData.reduce((s, d) => s + d.pct, 0) / Math.max(1, chartData.length)) + (GROWTH_KW_WEEK * 13 / config.rack_power_kw * 100));
const highLoad = roomRacks.filter((r) => (r.power_pct ?? 0) >= 75);
const stranded = roomRacks.filter((r) => r.power_kw !== null && (r.power_pct ?? 0) < 20);
const strandedKw = stranded.reduce((s, r) => s + ((config.rack_power_kw - (r.power_kw ?? 0))), 0);
return (
<div className="space-y-6">
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
<CapacityGauge
label="Power"
used={room.power.used_kw}
capacity={room.power.capacity_kw}
unit="kW"
pct={room.power.pct}
headroom={room.power.headroom_kw}
icon={Zap}
/>
<CapacityGauge
label="Cooling"
used={room.cooling.load_kw}
capacity={room.cooling.capacity_kw}
unit="kW"
pct={room.cooling.pct}
headroom={room.cooling.headroom_kw}
icon={Wind}
warn={65}
crit={80}
/>
<div className="space-y-2 rounded-xl border border-border bg-muted/10 p-4">
<div className="flex items-center gap-2 text-sm font-medium">
<Server className="w-4 h-4 text-primary" /> Space
</div>
<div className="flex items-center justify-center py-1">
<div className="relative w-28 h-28 flex items-center justify-center">
<svg viewBox="0 0 100 100" className="w-28 h-28 -rotate-[135deg]" style={{ overflow: "visible" }}>
<circle cx="50" cy="50" r="36" fill="none" strokeWidth="9" className="stroke-muted"
strokeDasharray={`${2 * Math.PI * 36 * 0.75} ${2 * Math.PI * 36}`} strokeLinecap="round" />
<circle cx="50" cy="50" r="36" fill="none" strokeWidth="9" stroke="oklch(0.62 0.17 212)"
strokeDasharray={`${(room.space.pct / 100) * 2 * Math.PI * 36 * 0.75} ${2 * Math.PI * 36}`}
strokeLinecap="round" style={{ transition: "stroke-dasharray 0.7s ease" }} />
</svg>
<div className="absolute text-center">
<span className="text-2xl font-bold tabular-nums leading-none text-foreground">
{room.space.racks_populated}
</span>
<span className="text-xs text-muted-foreground">/{room.space.racks_total}</span>
</div>
</div>
</div>
<div className="flex justify-between text-xs text-muted-foreground">
<span><strong className="text-foreground">{room.space.racks_populated}</strong> active</span>
<span><strong className="text-foreground">{room.space.racks_total - room.space.racks_populated}</strong> free</span>
</div>
<div className="rounded-lg px-3 py-2 text-xs bg-muted/40 text-muted-foreground">
Each rack rated {config.rack_u_total}U / {config.rack_power_kw} kW max
</div>
</div>
</div>
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-semibold flex items-center gap-2">
<Zap className="w-4 h-4 text-primary" /> Per-rack Power Utilisation
</CardTitle>
</CardHeader>
<CardContent>
{chartData.length === 0 ? (
<div className="h-48 flex items-center justify-center text-sm text-muted-foreground">
No rack data available
</div>
) : (
<ResponsiveContainer width="100%" height={220}>
<BarChart data={chartData} margin={{ top: 4, right: 16, left: -10, bottom: 0 }}>
<CartesianGrid strokeDasharray="3 3" stroke="oklch(1 0 0 / 8%)" vertical={false} />
<XAxis
dataKey="rack"
tick={{ fontSize: 10, fill: "oklch(0.65 0.04 257)" }}
tickLine={false} axisLine={false}
/>
<YAxis
tick={{ fontSize: 10, fill: "oklch(0.65 0.04 257)" }}
tickLine={false} axisLine={false}
domain={[0, 100]} tickFormatter={(v) => `${v}%`}
/>
<Tooltip
contentStyle={{ backgroundColor: "oklch(0.16 0.04 265)", border: "1px solid oklch(1 0 0/9%)", borderRadius: "6px", fontSize: "12px" }}
formatter={(v, _name, props) => [
`${Number(v).toFixed(1)}% (${props.payload.kw.toFixed(2)} kW)`, "Power load"
]}
/>
<ReferenceLine y={75} stroke="oklch(0.72 0.18 84)" strokeDasharray="4 4" strokeWidth={1}
label={{ value: "Warn 75%", fontSize: 9, fill: "oklch(0.72 0.18 84)", position: "insideTopRight" }} />
<ReferenceLine y={90} stroke="oklch(0.55 0.22 25)" strokeDasharray="4 4" strokeWidth={1}
label={{ value: "Crit 90%", fontSize: 9, fill: "oklch(0.55 0.22 25)", position: "insideTopRight" }} />
<ReferenceLine y={forecastPct} stroke="oklch(0.62 0.17 212)" strokeDasharray="6 3" strokeWidth={1.5}
label={{ value: "90d forecast", fontSize: 9, fill: "oklch(0.62 0.17 212)", position: "insideTopLeft" }} />
<Bar dataKey="pct" radius={[3, 3, 0, 0]}>
{chartData.map((d) => (
<Cell
key={d.rack_id}
fill={
d.pct >= 90 ? "oklch(0.55 0.22 25)" :
d.pct >= 75 ? "oklch(0.65 0.20 45)" :
d.pct >= 50 ? "oklch(0.68 0.14 162)" :
"oklch(0.62 0.17 212)"
}
/>
))}
</Bar>
</BarChart>
</ResponsiveContainer>
)}
</CardContent>
</Card>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-semibold flex items-center gap-2">
<AlertTriangle className="w-4 h-4 text-amber-400" /> High Load Racks
</CardTitle>
</CardHeader>
<CardContent>
{highLoad.length === 0 ? (
<p className="text-sm text-green-400">All racks within normal limits</p>
) : (
<div className="space-y-2">
{highLoad.sort((a, b) => (b.power_pct ?? 0) - (a.power_pct ?? 0)).map((r) => (
<div key={r.rack_id} className="flex items-center justify-between text-xs">
<span className="font-medium">{r.rack_id.toUpperCase()}</span>
<div className="flex items-center gap-2">
<span className="text-muted-foreground">{r.power_kw?.toFixed(1)} kW</span>
<span className={cn(
"font-bold",
(r.power_pct ?? 0) >= 90 ? "text-destructive" : "text-amber-400"
)}>{r.power_pct?.toFixed(1)}%</span>
</div>
</div>
))}
</div>
)}
</CardContent>
</Card>
<Card>
<CardHeader className="pb-2">
<div className="flex items-center justify-between">
<CardTitle className="text-sm font-semibold flex items-center gap-2">
<TrendingDown className="w-4 h-4 text-muted-foreground" /> Stranded Capacity
</CardTitle>
{stranded.length > 0 && (
<span className="text-xs font-semibold text-amber-400">
{strandedKw.toFixed(1)} kW recoverable
</span>
)}
</div>
</CardHeader>
<CardContent>
{stranded.length === 0 ? (
<p className="text-sm text-muted-foreground">No underutilised racks detected</p>
) : (
<div className="space-y-2">
{stranded.sort((a, b) => (a.power_pct ?? 0) - (b.power_pct ?? 0)).map((r) => (
<div key={r.rack_id} className="flex items-center justify-between text-xs">
<span className="font-medium">{r.rack_id.toUpperCase()}</span>
<div className="flex items-center gap-2">
<span className="text-muted-foreground">{r.power_kw?.toFixed(1)} kW</span>
<span className="text-muted-foreground">{r.power_pct?.toFixed(1)}% utilised</span>
</div>
</div>
))}
<p className="text-[10px] text-muted-foreground pt-1">
{stranded.length} rack{stranded.length > 1 ? "s" : ""} below 20% consider consolidation
</p>
</div>
)}
</CardContent>
</Card>
</div>
</div>
);
}
// ── Page ──────────────────────────────────────────────────────────────────────
export default function CapacityPage() {
const [data, setData] = useState<CapacitySummary | null>(null);
const [loading, setLoading] = useState(true);
const [activeRoom, setActiveRoom] = useState("hall-a");
const load = useCallback(async () => {
try { setData(await fetchCapacitySummary(SITE_ID)); }
catch { toast.error("Failed to load capacity data"); }
finally { setLoading(false); }
}, []);
useEffect(() => {
load();
const id = setInterval(load, 30_000);
return () => clearInterval(id);
}, [load]);
const sitePower = data?.rooms.reduce((s, r) => s + r.power.used_kw, 0) ?? 0;
const siteCapacity = data?.rooms.reduce((s, r) => s + r.power.capacity_kw, 0) ?? 0;
return (
<PageShell className="p-6">
<div className="flex items-start justify-between">
<div>
<h1 className="text-xl font-semibold">Capacity Planning</h1>
<p className="text-sm text-muted-foreground">Singapore DC01 power, cooling &amp; space headroom</p>
</div>
<button
onClick={load}
className="flex items-center gap-1.5 text-xs text-muted-foreground hover:text-foreground transition-colors"
>
<RefreshCw className="w-3.5 h-3.5" /> Refresh
</button>
</div>
{loading ? (
<div className="space-y-4">
<Skeleton className="h-32 w-full" />
<div className="grid grid-cols-3 gap-4">
{Array.from({ length: 3 }).map((_, i) => <Skeleton key={i} className="h-56" />)}
</div>
<Skeleton className="h-64 w-full" />
</div>
) : !data ? (
<div className="flex items-center justify-center h-64 text-sm text-muted-foreground">
Unable to load capacity data.
</div>
) : (
<>
{/* Site summary banner */}
<div className="rounded-xl border border-border bg-muted/10 px-5 py-3 flex items-center gap-8 text-sm flex-wrap">
<div>
<span className="text-muted-foreground">Site IT load</span>
{" "}
<strong className="text-foreground text-base">{sitePower.toFixed(1)} kW</strong>
<span className="text-muted-foreground"> / {siteCapacity.toFixed(0)} kW rated</span>
</div>
<div>
<span className="text-muted-foreground">Site load</span>
{" "}
<strong className={cn(
"text-base",
(sitePower / siteCapacity * 100) >= 85 ? "text-destructive" :
(sitePower / siteCapacity * 100) >= 70 ? "text-amber-400" : "text-green-400"
)}>
{(sitePower / siteCapacity * 100).toFixed(1)}%
</strong>
</div>
<div className="ml-auto text-xs text-muted-foreground">
Capacity config: {data.config.rack_power_kw} kW/rack ·{" "}
{data.config.crac_cooling_kw} kW CRAC ·{" "}
{data.config.rack_u_total}U/rack
</div>
</div>
{/* Room comparison strip */}
<RoomSummaryStrip rooms={data.rooms} />
{/* Capacity runway + N+1 */}
<RunwayCard rooms={data.rooms} />
{/* Per-room detail tabs */}
<div>
<Tabs value={activeRoom} onValueChange={setActiveRoom}>
<TabsList>
{data.rooms.map((r) => (
<TabsTrigger key={r.room_id} value={r.room_id}>
{ROOM_LABELS[r.room_id] ?? r.room_id}
{r.power.pct >= 85 && (
<AlertTriangle className="w-3 h-3 ml-1.5 text-destructive" />
)}
</TabsTrigger>
))}
</TabsList>
</Tabs>
<div className="mt-6">
{data.rooms
.filter((r) => r.room_id === activeRoom)
.map((room) => (
<RoomCapacityPanel
key={room.room_id}
room={room}
racks={data.racks}
config={data.config}
/>
))}
</div>
</div>
</>
)}
</PageShell>
);
}

View file

@ -0,0 +1,610 @@
"use client";
import { useEffect, useState, useCallback } from "react";
import { toast } from "sonner";
import { fetchCracStatus, fetchChillerStatus, type CracStatus, type ChillerStatus } from "@/lib/api";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Skeleton } from "@/components/ui/skeleton";
import { CracDetailSheet } from "@/components/dashboard/crac-detail-sheet";
import {
Wind, AlertTriangle, CheckCircle2, Zap, ChevronRight, ArrowRight, Waves, Filter,
ChevronUp, ChevronDown,
} from "lucide-react";
import { cn } from "@/lib/utils";
const SITE_ID = "sg-01";
const roomLabels: Record<string, string> = { "hall-a": "Hall A", "hall-b": "Hall B" };
function fmt(v: number | null | undefined, dec = 1, unit = "") {
if (v == null) return "—";
return `${v.toFixed(dec)}${unit}`;
}
function FillBar({
value, max, color, warn, crit, height = "h-2",
}: {
value: number | null; max: number; color: string;
warn?: number; crit?: number; height?: string;
}) {
const pct = value != null ? Math.min(100, (value / max) * 100) : 0;
const barColor =
crit && value != null && value >= crit ? "#ef4444" :
warn && value != null && value >= warn ? "#f59e0b" :
color;
return (
<div className={cn("rounded-full bg-muted overflow-hidden w-full", height)}>
<div
className="h-full rounded-full transition-all duration-500"
style={{ width: `${pct}%`, backgroundColor: barColor }}
/>
</div>
);
}
function KpiTile({ label, value, sub, warn }: {
label: string; value: string; sub?: string; warn?: boolean;
}) {
return (
<div className="bg-muted/30 rounded-lg px-4 py-3 flex-1 min-w-0">
<p className="text-[10px] text-muted-foreground uppercase tracking-wider mb-1">{label}</p>
<p className={cn("text-xl font-bold tabular-nums", warn && "text-amber-400")}>{value}</p>
{sub && <p className="text-[10px] text-muted-foreground mt-0.5">{sub}</p>}
</div>
);
}
function CracCard({ crac, onOpen }: { crac: CracStatus; onOpen: () => void }) {
const [showCompressor, setShowCompressor] = useState(false);
const online = crac.state === "online";
const deltaWarn = (crac.delta ?? 0) > 11;
const deltaCrit = (crac.delta ?? 0) > 14;
const capWarn = (crac.cooling_capacity_pct ?? 0) > 75;
const capCrit = (crac.cooling_capacity_pct ?? 0) > 90;
const copWarn = (crac.cop ?? 99) < 1.5;
const filterWarn = (crac.filter_dp_pa ?? 0) > 80;
const filterCrit = (crac.filter_dp_pa ?? 0) > 120;
const compWarn = (crac.compressor_load_pct ?? 0) > 95;
const hiPWarn = (crac.high_pressure_bar ?? 0) > 22;
const loPWarn = (crac.low_pressure_bar ?? 99) < 3;
return (
<Card
className={cn(
"border cursor-pointer hover:border-primary/50 transition-colors",
!online && "border-destructive/40",
)}
onClick={onOpen}
>
{/* ── Header ───────────────────────────────────────────────── */}
<CardHeader className="pb-3">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Wind className={cn("w-4 h-4", online ? "text-primary" : "text-destructive")} />
<div>
<CardTitle className="text-base font-semibold leading-none">
{crac.crac_id.toUpperCase()}
</CardTitle>
{crac.room_id && (
<p className="text-xs text-muted-foreground mt-0.5">
{roomLabels[crac.room_id] ?? crac.room_id}
</p>
)}
</div>
</div>
<div className="flex items-center gap-2">
{online && (
<span className={cn(
"text-[10px] font-semibold px-2 py-0.5 rounded-full uppercase tracking-wide",
deltaCrit || capCrit ? "bg-destructive/10 text-destructive" :
deltaWarn || capWarn || filterWarn || copWarn ? "bg-amber-500/10 text-amber-400" :
"bg-green-500/10 text-green-400",
)}>
{deltaCrit || capCrit ? "Critical" : deltaWarn || capWarn || filterWarn || copWarn ? "Warning" : "Normal"}
</span>
)}
<span className={cn(
"flex items-center gap-1 text-[10px] font-semibold px-2.5 py-1 rounded-full uppercase tracking-wide",
online ? "bg-green-500/10 text-green-400" : "bg-destructive/10 text-destructive",
)}>
{online
? <><CheckCircle2 className="w-3 h-3" /> Online</>
: <><AlertTriangle className="w-3 h-3" /> Fault</>}
</span>
<ChevronRight className="w-4 h-4 text-muted-foreground" />
</div>
</div>
</CardHeader>
<CardContent className="space-y-4">
{!online ? (
<div className="rounded-md px-3 py-2 text-xs bg-destructive/10 text-destructive">
Unit offline cooling capacity in this room is degraded.
</div>
) : (
<>
{/* ── Thermal hero ─────────────────────────────────────── */}
<div className="rounded-lg bg-muted/20 px-4 py-3">
<div className="flex items-center justify-between">
<div className="text-center">
<p className="text-[10px] text-muted-foreground uppercase tracking-wide mb-0.5">Supply</p>
<p className="text-3xl font-bold tabular-nums text-blue-400">
{fmt(crac.supply_temp, 1)}°C
</p>
</div>
<div className="flex-1 flex flex-col items-center gap-1 px-4">
<p className={cn(
"text-base font-bold tabular-nums",
deltaCrit ? "text-destructive" : deltaWarn ? "text-amber-400" : "text-muted-foreground",
)}>
ΔT {fmt(crac.delta, 1)}°C
</p>
<div className="flex items-center gap-1 w-full">
<div className="flex-1 h-px bg-muted-foreground/30" />
<ArrowRight className="w-3 h-3 text-muted-foreground/50 shrink-0" />
<div className="flex-1 h-px bg-muted-foreground/30" />
</div>
</div>
<div className="text-center">
<p className="text-[10px] text-muted-foreground uppercase tracking-wide mb-0.5">Return</p>
<p className={cn(
"text-3xl font-bold tabular-nums",
deltaCrit ? "text-destructive" : deltaWarn ? "text-amber-400" : "text-orange-400",
)}>
{fmt(crac.return_temp, 1)}°C
</p>
</div>
</div>
</div>
{/* ── Cooling capacity ─────────────────────────────────── */}
<div>
<div className="flex justify-between items-baseline mb-1.5">
<span className="text-[10px] text-muted-foreground uppercase tracking-wide">Cooling Capacity</span>
<span className="text-xs font-mono">
<span className={cn(capCrit ? "text-destructive" : capWarn ? "text-amber-400" : "text-foreground")}>
{fmt(crac.cooling_capacity_kw, 1)} / {crac.rated_capacity_kw} kW
</span>
<span className="text-muted-foreground mx-1.5">·</span>
<span className={cn(copWarn ? "text-amber-400" : "text-foreground")}>
COP {fmt(crac.cop, 2)}
</span>
</span>
</div>
<FillBar value={crac.cooling_capacity_pct} max={100} color="#34d399" warn={75} crit={90} />
<p className={cn(
"text-[10px] mt-1 text-right",
capCrit ? "text-destructive" : capWarn ? "text-amber-400" : "text-muted-foreground",
)}>
{fmt(crac.cooling_capacity_pct, 1)}% utilised
</p>
</div>
{/* ── Fan + Filter ─────────────────────────────────────── */}
<div className="space-y-2.5">
<div>
<div className="flex justify-between text-[10px] mb-1">
<span className="text-muted-foreground">Fan</span>
<span className="font-mono text-foreground">
{fmt(crac.fan_pct, 1)}%
{crac.fan_rpm != null ? ` · ${Math.round(crac.fan_rpm).toLocaleString()} rpm` : ""}
</span>
</div>
<FillBar value={crac.fan_pct} max={100} color="#60a5fa" height="h-1.5" />
</div>
<div>
<div className="flex justify-between text-[10px] mb-1">
<span className="text-muted-foreground">Filter ΔP</span>
<span className={cn(
"font-mono",
filterCrit ? "text-destructive" : filterWarn ? "text-amber-400" : "text-foreground",
)}>
{fmt(crac.filter_dp_pa, 0)} Pa
{!filterWarn && <span className="text-green-400 ml-1.5"></span>}
</span>
</div>
<FillBar value={crac.filter_dp_pa} max={150} color="#94a3b8" warn={80} crit={120} height="h-1.5" />
</div>
</div>
{/* ── Compressor (collapsible) ─────────────────────────── */}
<div className="rounded-md bg-muted/20 px-3 py-2.5 space-y-2">
<button
onClick={(e) => { e.stopPropagation(); setShowCompressor(!showCompressor); }}
className="flex items-center justify-between w-full text-[10px] text-muted-foreground hover:text-foreground transition-colors"
>
<span className="uppercase tracking-wide font-semibold">Compressor</span>
<span className="flex items-center gap-2">
<span className="font-mono">{fmt(crac.compressor_load_pct, 1)}% · {fmt(crac.compressor_power_kw, 2)} kW</span>
{showCompressor ? <ChevronUp className="w-3 h-3" /> : <ChevronDown className="w-3 h-3" />}
</span>
</button>
{showCompressor && (
<div className="pt-2 space-y-2">
<FillBar value={crac.compressor_load_pct} max={100} color="#e879f9" warn={80} crit={95} height="h-1.5" />
<div className="flex justify-between text-[10px] text-muted-foreground pt-0.5">
<span className={cn(hiPWarn ? "text-destructive" : "")}>
Hi {fmt(crac.high_pressure_bar, 1)} bar
</span>
<span className={cn(loPWarn ? "text-destructive" : "")}>
Lo {fmt(crac.low_pressure_bar, 2)} bar
</span>
<span>SH {fmt(crac.discharge_superheat_c, 1)}°C</span>
<span>SC {fmt(crac.liquid_subcooling_c, 1)}°C</span>
</div>
</div>
)}
</div>
{/* ── Electrical (one line) ────────────────────────────── */}
<div className="flex items-center gap-1.5 text-xs text-muted-foreground border-t border-border/30 pt-3">
<Zap className="w-3 h-3 shrink-0" />
<span className="font-mono font-medium text-foreground">{fmt(crac.total_unit_power_kw, 2)} kW</span>
<span>·</span>
<span className="font-mono">{fmt(crac.input_voltage_v, 0)} V</span>
<span>·</span>
<span className="font-mono">{fmt(crac.input_current_a, 1)} A</span>
<span>·</span>
<span className="font-mono">PF {fmt(crac.power_factor, 3)}</span>
</div>
{/* ── Status banner ────────────────────────────────────── */}
<div className={cn(
"rounded-md px-3 py-2 text-xs",
deltaCrit || capCrit
? "bg-destructive/10 text-destructive"
: deltaWarn || capWarn || filterWarn || copWarn
? "bg-amber-500/10 text-amber-400"
: "bg-green-500/10 text-green-400",
)}>
{deltaCrit || capCrit
? "Heat load is high — check airflow or redistribute rack density."
: deltaWarn || capWarn
? "Heat load is elevated — monitor for further rises."
: filterWarn
? "Filter requires attention — airflow may be restricted."
: copWarn
? "Running inefficiently — check refrigerant charge."
: "Operating efficiently within normal parameters."}
</div>
</>
)}
</CardContent>
</Card>
);
}
// ── Filter replacement estimate ────────────────────────────────────
// Assumes ~1.2 Pa/day rate of rise — replace at 120 Pa threshold
const FILTER_REPLACE_PA = 120;
const FILTER_RATE_PA_DAY = 1.2;
function FilterEstimate({ cracs }: { cracs: CracStatus[] }) {
const units = cracs
.filter((c) => c.state === "online" && c.filter_dp_pa != null)
.map((c) => {
const dp = c.filter_dp_pa!;
const days = Math.max(0, Math.round((FILTER_REPLACE_PA - dp) / FILTER_RATE_PA_DAY));
const urgent = dp >= 120;
const warn = dp >= 80;
return { crac_id: c.crac_id, dp, days, urgent, warn };
})
.sort((a, b) => a.days - b.days);
if (units.length === 0) return null;
const anyUrgent = units.some((u) => u.urgent);
const anyWarn = units.some((u) => u.warn);
return (
<Card>
<CardHeader className="pb-2">
<div className="flex items-center justify-between">
<CardTitle className="text-sm font-semibold flex items-center gap-2">
<Filter className="w-4 h-4 text-muted-foreground" />
Predictive Filter Replacement
</CardTitle>
<span className={cn(
"text-[10px] font-semibold px-2 py-0.5 rounded-full uppercase",
anyUrgent ? "bg-destructive/10 text-destructive" :
anyWarn ? "bg-amber-500/10 text-amber-400" :
"bg-green-500/10 text-green-400",
)}>
{anyUrgent ? "Overdue" : anyWarn ? "Attention needed" : "All filters OK"}
</span>
</div>
</CardHeader>
<CardContent>
<div className="space-y-3">
{units.map((u) => (
<div key={u.crac_id}>
<div className="flex items-center justify-between text-xs mb-1">
<span className="font-medium">{u.crac_id.toUpperCase()}</span>
<div className="flex items-center gap-3">
<span className={cn(
"font-mono",
u.urgent ? "text-destructive" : u.warn ? "text-amber-400" : "text-muted-foreground",
)}>
{u.dp} Pa
</span>
<span className={cn(
"text-[10px] px-2 py-0.5 rounded-full font-semibold",
u.urgent ? "bg-destructive/10 text-destructive" :
u.warn ? "bg-amber-500/10 text-amber-400" :
"bg-green-500/10 text-green-400",
)}>
{u.urgent ? "Replace now" : `~${u.days}d`}
</span>
</div>
</div>
<div className="rounded-full bg-muted overflow-hidden h-1.5">
<div
className="h-full rounded-full transition-all duration-500"
style={{
width: `${Math.min(100, (u.dp / FILTER_REPLACE_PA) * 100)}%`,
backgroundColor: u.urgent ? "#ef4444" : u.warn ? "#f59e0b" : "#94a3b8",
}}
/>
</div>
</div>
))}
<p className="text-[10px] text-muted-foreground pt-1">
Estimated at {FILTER_RATE_PA_DAY} Pa/day increase · replace at {FILTER_REPLACE_PA} Pa threshold
</p>
</div>
</CardContent>
</Card>
);
}
// ── Chiller card ──────────────────────────────────────────────────
function ChillerCard({ chiller }: { chiller: ChillerStatus }) {
const online = chiller.state === "online";
const loadWarn = (chiller.cooling_load_pct ?? 0) > 80;
return (
<Card className={cn("border", !online && "border-destructive/40")}>
<CardHeader className="pb-2">
<div className="flex items-center justify-between">
<CardTitle className="text-sm font-semibold flex items-center gap-2">
<Waves className="w-4 h-4 text-blue-400" />
{chiller.chiller_id.toUpperCase()} Chiller Plant
</CardTitle>
<span className={cn("text-[10px] font-semibold px-2.5 py-1 rounded-full uppercase tracking-wide",
online ? "bg-green-500/10 text-green-400" : "bg-destructive/10 text-destructive",
)}>
{online ? <><CheckCircle2 className="w-3 h-3 inline mr-0.5" /> Online</> : <><AlertTriangle className="w-3 h-3 inline mr-0.5" /> Fault</>}
</span>
</div>
</CardHeader>
<CardContent className="space-y-3">
{!online ? (
<div className="rounded-md px-3 py-2 text-xs bg-destructive/10 text-destructive">
Chiller fault CHW supply lost. CRAC/CRAH units relying on local refrigerant circuits only.
</div>
) : (
<>
{/* CHW temps */}
<div className="rounded-lg bg-muted/20 px-4 py-3">
<div className="flex items-center justify-between">
<div className="text-center">
<p className="text-[10px] text-muted-foreground uppercase tracking-wide mb-0.5">CHW Supply</p>
<p className="text-2xl font-bold tabular-nums text-blue-400">{fmt(chiller.chw_supply_c, 1)}°C</p>
</div>
<div className="flex-1 flex flex-col items-center gap-1 px-4">
<p className="text-sm font-bold tabular-nums text-muted-foreground">ΔT {fmt(chiller.chw_delta_c, 1)}°C</p>
<div className="flex items-center gap-1 w-full">
<div className="flex-1 h-px bg-muted-foreground/30" />
<ArrowRight className="w-3 h-3 text-muted-foreground/50 shrink-0" />
<div className="flex-1 h-px bg-muted-foreground/30" />
</div>
</div>
<div className="text-center">
<p className="text-[10px] text-muted-foreground uppercase tracking-wide mb-0.5">CHW Return</p>
<p className="text-2xl font-bold tabular-nums text-orange-400">{fmt(chiller.chw_return_c, 1)}°C</p>
</div>
</div>
</div>
{/* Load */}
<div>
<div className="flex justify-between items-baseline mb-1.5">
<span className="text-[10px] text-muted-foreground uppercase tracking-wide">Cooling Load</span>
<span className="text-xs font-mono">
<span className={cn(loadWarn ? "text-amber-400" : "")}>{fmt(chiller.cooling_load_kw, 1)} kW</span>
<span className="text-muted-foreground mx-1.5">·</span>
<span>COP {fmt(chiller.cop, 2)}</span>
</span>
</div>
<FillBar value={chiller.cooling_load_pct} max={100} color="#34d399" warn={80} crit={95} />
<p className="text-[10px] mt-1 text-right text-muted-foreground">{fmt(chiller.cooling_load_pct, 1)}% load</p>
</div>
{/* Details */}
<div className="grid grid-cols-2 gap-x-4 gap-y-1.5 text-[11px]">
<div className="flex justify-between"><span className="text-muted-foreground">Flow rate</span><span className="font-mono">{fmt(chiller.flow_gpm, 0)} GPM</span></div>
<div className="flex justify-between"><span className="text-muted-foreground">Comp load</span><span className="font-mono">{fmt(chiller.compressor_load_pct, 1)}%</span></div>
<div className="flex justify-between"><span className="text-muted-foreground">Cond press</span><span className="font-mono">{fmt(chiller.condenser_pressure_bar, 2)} bar</span></div>
<div className="flex justify-between"><span className="text-muted-foreground">Evap press</span><span className="font-mono">{fmt(chiller.evaporator_pressure_bar, 2)} bar</span></div>
<div className="flex justify-between"><span className="text-muted-foreground">CW supply</span><span className="font-mono">{fmt(chiller.cw_supply_c, 1)}°C</span></div>
<div className="flex justify-between"><span className="text-muted-foreground">CW return</span><span className="font-mono">{fmt(chiller.cw_return_c, 1)}°C</span></div>
</div>
<div className="text-[10px] text-muted-foreground border-t border-border/30 pt-2">
Run hours: <strong className="text-foreground">{chiller.run_hours != null ? chiller.run_hours.toFixed(0) : "—"} h</strong>
</div>
</>
)}
</CardContent>
</Card>
);
}
// ── Page ──────────────────────────────────────────────────────────────────────
export default function CoolingPage() {
const [cracs, setCracs] = useState<CracStatus[]>([]);
const [chillers, setChillers] = useState<ChillerStatus[]>([]);
const [loading, setLoading] = useState(true);
const [selectedCrac, setSelected] = useState<string | null>(null);
const load = useCallback(async () => {
try {
const [c, ch] = await Promise.all([
fetchCracStatus(SITE_ID),
fetchChillerStatus(SITE_ID).catch(() => []),
]);
setCracs(c);
setChillers(ch);
}
catch { toast.error("Failed to load cooling data"); }
finally { setLoading(false); }
}, []);
useEffect(() => {
load();
const id = setInterval(load, 30_000);
return () => clearInterval(id);
}, [load]);
const online = cracs.filter(c => c.state === "online");
const anyFaulted = cracs.some(c => c.state === "fault");
const totalCoolingKw = online.reduce((s, c) => s + (c.cooling_capacity_kw ?? 0), 0);
const totalRatedKw = cracs.reduce((s, c) => s + (c.rated_capacity_kw ?? 0), 0);
const copUnits = online.filter(c => c.cop != null);
const avgCop = copUnits.length > 0
? copUnits.reduce((s, c) => s + (c.cop ?? 0), 0) / copUnits.length
: null;
const totalUnitPower = online.reduce((s, c) => s + (c.total_unit_power_kw ?? 0), 0);
const totalAirflowCfm = online.reduce((s, c) => s + (c.airflow_cfm ?? 0), 0);
return (
<div className="p-6 space-y-6">
{/* ── Page header ───────────────────────────────────────────── */}
<div className="flex items-start justify-between">
<div>
<h1 className="text-xl font-semibold">Cooling Systems</h1>
<p className="text-sm text-muted-foreground">
Singapore DC01 · click a unit to drill down · refreshes every 30s
</p>
</div>
{!loading && (
<span className={cn(
"flex items-center gap-1.5 text-xs font-semibold px-3 py-1.5 rounded-full",
anyFaulted ? "bg-destructive/10 text-destructive" : "bg-green-500/10 text-green-400",
)}>
{anyFaulted
? <><AlertTriangle className="w-3.5 h-3.5" /> Cooling fault detected</>
: <><CheckCircle2 className="w-3.5 h-3.5" /> All {cracs.length} units operational</>}
</span>
)}
</div>
{/* ── Filter alert banner ───────────────────────────────────── */}
{!loading && (() => {
const urgent = cracs
.filter(c => c.state === "online" && c.filter_dp_pa != null)
.map(c => ({ id: c.crac_id, days: Math.max(0, Math.round((120 - c.filter_dp_pa!) / 1.2)) }))
.filter(c => c.days < 14)
.sort((a, b) => a.days - b.days);
if (urgent.length === 0) return null;
return (
<div className="flex items-center gap-3 rounded-lg border border-amber-500/30 bg-amber-500/5 px-4 py-3 text-sm">
<Filter className="w-4 h-4 text-amber-400 shrink-0" />
<span className="text-amber-400">
<strong>Filter replacement due:</strong>{" "}
{urgent.map(u => `${u.id.toUpperCase()} in ${u.days === 0 ? "now" : `~${u.days}d`}`).join(", ")}
</span>
</div>
);
})()}
{/* ── Fleet summary KPI cards ───────────────────────────────── */}
{loading && (
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-5 gap-4">
{Array.from({ length: 5 }).map((_, i) => <Skeleton key={i} className="h-20" />)}
</div>
)}
{!loading && cracs.length > 0 && (
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-5 gap-4">
<Card>
<CardContent className="p-4">
<p className="text-[10px] text-muted-foreground uppercase tracking-wider mb-1">Cooling Load</p>
<p className="text-2xl font-bold tabular-nums">{totalCoolingKw.toFixed(1)} kW</p>
<p className="text-[10px] text-muted-foreground mt-0.5">of {totalRatedKw} kW rated</p>
</CardContent>
</Card>
<Card>
<CardContent className="p-4">
<p className="text-[10px] text-muted-foreground uppercase tracking-wider mb-1">Avg COP</p>
<p className={cn("text-2xl font-bold tabular-nums", avgCop != null && avgCop < 1.5 && "text-amber-400")}>
{avgCop != null ? avgCop.toFixed(2) : "—"}
</p>
</CardContent>
</Card>
<Card>
<CardContent className="p-4">
<p className="text-[10px] text-muted-foreground uppercase tracking-wider mb-1">Unit Power Draw</p>
<p className="text-2xl font-bold tabular-nums">{totalUnitPower.toFixed(1)} kW</p>
<p className="text-[10px] text-muted-foreground mt-0.5">total electrical input</p>
</CardContent>
</Card>
<Card>
<CardContent className="p-4">
<p className="text-[10px] text-muted-foreground uppercase tracking-wider mb-1">Units Online</p>
<p className={cn("text-2xl font-bold tabular-nums", anyFaulted && "text-amber-400")}>
{online.length} / {cracs.length}
</p>
</CardContent>
</Card>
{totalAirflowCfm > 0 && (
<Card>
<CardContent className="p-4">
<p className="text-[10px] text-muted-foreground uppercase tracking-wider mb-1">Total Airflow</p>
<p className="text-2xl font-bold tabular-nums">{Math.round(totalAirflowCfm).toLocaleString()}</p>
<p className="text-[10px] text-muted-foreground mt-0.5">CFM combined output</p>
</CardContent>
</Card>
)}
</div>
)}
{/* ── Chiller plant ─────────────────────────────────────────── */}
{(loading || chillers.length > 0) && (
<>
<h2 className="text-xs font-semibold uppercase tracking-widest text-muted-foreground">Chiller Plant</h2>
{loading ? (
<Skeleton className="h-56" />
) : (
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{chillers.map(ch => <ChillerCard key={ch.chiller_id} chiller={ch} />)}
</div>
)}
</>
)}
{/* ── Filter health (moved before CRAC cards) ───────────────── */}
{!loading && <FilterEstimate cracs={cracs} />}
{/* ── CRAC cards ────────────────────────────────────────────── */}
<h2 className="text-xs font-semibold uppercase tracking-widest text-muted-foreground">CRAC / CRAH Units</h2>
{loading ? (
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
<Skeleton className="h-72" />
<Skeleton className="h-72" />
</div>
) : (
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{cracs.map(crac => (
<CracCard key={crac.crac_id} crac={crac} onOpen={() => setSelected(crac.crac_id)} />
))}
</div>
)}
<CracDetailSheet
siteId={SITE_ID}
cracId={selectedCrac}
onClose={() => setSelected(null)}
/>
</div>
);
}

View file

@ -0,0 +1,246 @@
"use client";
import { useEffect, useState, useCallback } from "react";
import { useRouter } from "next/navigation";
import { Zap, Thermometer, Wind, AlertTriangle, Wifi, WifiOff, Fuel, Droplets } from "lucide-react";
import { KpiCard } from "@/components/dashboard/kpi-card";
import { PowerTrendChart } from "@/components/dashboard/power-trend-chart";
import { TemperatureTrendChart } from "@/components/dashboard/temperature-trend-chart";
import { AlarmFeed } from "@/components/dashboard/alarm-feed";
import { MiniFloorMap } from "@/components/dashboard/mini-floor-map";
import { RackDetailSheet } from "@/components/dashboard/rack-detail-sheet";
import {
fetchKpis, fetchPowerHistory, fetchTempHistory,
fetchAlarms, fetchGeneratorStatus, fetchLeakStatus,
fetchCapacitySummary, fetchFloorLayout,
type KpiData, type PowerBucket, type TempBucket,
type Alarm, type GeneratorStatus, type LeakSensorStatus,
type RackCapacity,
} from "@/lib/api";
import { TimeRangePicker } from "@/components/ui/time-range-picker";
import Link from "next/link";
import { PageShell } from "@/components/layout/page-shell";
const SITE_ID = "sg-01";
const KPI_INTERVAL = 15_000;
const CHART_INTERVAL = 30_000;
// Fallback static data shown when the API is unreachable
const FALLBACK_KPIS: KpiData = {
total_power_kw: 0, pue: 0, avg_temperature: 0, active_alarms: 0,
};
export default function DashboardPage() {
const router = useRouter();
const [kpis, setKpis] = useState<KpiData>(FALLBACK_KPIS);
const [prevKpis, setPrevKpis] = useState<KpiData | null>(null);
const [powerHistory, setPowerHistory] = useState<PowerBucket[]>([]);
const [tempHistory, setTempHistory] = useState<TempBucket[]>([]);
const [alarms, setAlarms] = useState<Alarm[]>([]);
const [generators, setGenerators] = useState<GeneratorStatus[]>([]);
const [leakSensors, setLeakSensors] = useState<LeakSensorStatus[]>([]);
const [mapRacks, setMapRacks] = useState<RackCapacity[]>([]);
const [mapLayout, setMapLayout] = useState<Record<string, { label: string; crac_id: string; rows: { label: string; racks: string[] }[] }> | null>(null);
const [chartHours, setChartHours] = useState(1);
const [loading, setLoading] = useState(true);
const [liveError, setLiveError] = useState(false);
const [lastUpdated, setLastUpdated] = useState<Date | null>(null);
const [selectedRack, setSelectedRack] = useState<string | null>(null);
const refreshKpis = useCallback(async () => {
try {
const [k, a, g, l, cap] = await Promise.all([
fetchKpis(SITE_ID),
fetchAlarms(SITE_ID),
fetchGeneratorStatus(SITE_ID).catch(() => []),
fetchLeakStatus(SITE_ID).catch(() => []),
fetchCapacitySummary(SITE_ID).catch(() => null),
]);
setKpis((current) => {
if (current !== FALLBACK_KPIS) setPrevKpis(current);
return k;
});
setAlarms(a);
setGenerators(g);
setLeakSensors(l);
if (cap) setMapRacks(cap.racks);
setLiveError(false);
setLastUpdated(new Date());
} catch {
setLiveError(true);
}
}, []);
const refreshCharts = useCallback(async () => {
try {
const [p, t] = await Promise.all([
fetchPowerHistory(SITE_ID, chartHours),
fetchTempHistory(SITE_ID, chartHours),
]);
setPowerHistory(p);
setTempHistory(t);
} catch {
// keep previous chart data on failure
}
}, []);
// Initial load
useEffect(() => {
Promise.all([refreshKpis(), refreshCharts()]).finally(() => setLoading(false));
fetchFloorLayout(SITE_ID)
.then(l => setMapLayout(l as typeof mapLayout))
.catch(() => {});
}, [refreshKpis, refreshCharts]);
// Re-fetch charts when time range changes
useEffect(() => { refreshCharts(); }, [chartHours, refreshCharts]);
// Polling
useEffect(() => {
const kpiTimer = setInterval(refreshKpis, KPI_INTERVAL);
const chartTimer = setInterval(refreshCharts, CHART_INTERVAL);
return () => { clearInterval(kpiTimer); clearInterval(chartTimer); };
}, [refreshKpis, refreshCharts]);
function handleAlarmClick(alarm: Alarm) {
if (alarm.rack_id) {
setSelectedRack(alarm.rack_id);
} else if (alarm.room_id) {
router.push("/environmental");
} else {
router.push("/alarms");
}
}
// Derived KPI display values
const alarmStatus = kpis.active_alarms === 0 ? "ok"
: kpis.active_alarms <= 2 ? "warning" : "critical";
const tempStatus = kpis.avg_temperature === 0 ? "ok"
: kpis.avg_temperature >= 28 ? "critical"
: kpis.avg_temperature >= 25 ? "warning" : "ok";
// Trends vs previous poll
const powerTrend = prevKpis ? Math.round((kpis.total_power_kw - prevKpis.total_power_kw) * 10) / 10 : null;
const tempTrend = prevKpis ? Math.round((kpis.avg_temperature - prevKpis.avg_temperature) * 10) / 10 : null;
const alarmTrend = prevKpis ? kpis.active_alarms - prevKpis.active_alarms : null;
// Generator derived
const gen = generators[0] ?? null;
const genFuel = gen?.fuel_pct ?? null;
const genState = gen?.state ?? "unknown";
const genStatus: "ok" | "warning" | "critical" =
genState === "fault" ? "critical" :
genState === "running" ? "warning" :
genFuel !== null && genFuel < 25 ? "warning" : "ok";
// Leak derived
const activeLeaks = leakSensors.filter(s => s.state === "detected").length;
const leakStatus: "ok" | "warning" | "critical" = activeLeaks > 0 ? "critical" : "ok";
return (
<PageShell>
<RackDetailSheet siteId="sg-01" rackId={selectedRack} onClose={() => setSelectedRack(null)} />
{/* Live status bar */}
<div className="flex items-center justify-between">
<div className="flex items-center gap-2 text-xs text-muted-foreground">
{liveError ? (
<><WifiOff className="w-3 h-3 text-destructive" /> Live data unavailable</>
) : (
<><Wifi className="w-3 h-3 text-green-400" /> Live · updates every 15s</>
)}
</div>
{lastUpdated && (
<span className="text-xs text-muted-foreground">
Last updated {lastUpdated.toLocaleTimeString()}
</span>
)}
</div>
{/* Unified KPI grid — 3×2 on desktop */}
<div className="grid grid-cols-2 gap-4 sm:grid-cols-3 lg:grid-cols-3">
<KpiCard
title="Total Power"
value={loading ? "—" : `${kpis.total_power_kw} kW`}
icon={Zap}
iconColor="text-amber-400"
status="ok"
loading={loading}
trend={powerTrend}
trendLabel={powerTrend !== null ? `${powerTrend > 0 ? "+" : ""}${powerTrend} kW` : undefined}
href="/power"
/>
<KpiCard
title="PUE"
value={loading ? "—" : kpis.pue.toFixed(2)}
hint="Lower is better"
icon={Wind}
iconColor="text-primary"
status="ok"
loading={loading}
href="/capacity"
/>
<KpiCard
title="Avg Temperature"
value={loading ? "—" : `${kpis.avg_temperature}°C`}
icon={Thermometer}
iconColor="text-green-400"
status={loading ? "ok" : tempStatus}
loading={loading}
trend={tempTrend}
trendLabel={tempTrend !== null ? `${tempTrend > 0 ? "+" : ""}${tempTrend}°C` : undefined}
trendInvert
href="/environmental"
/>
<KpiCard
title="Active Alarms"
value={loading ? "—" : String(kpis.active_alarms)}
icon={AlertTriangle}
iconColor="text-destructive"
status={loading ? "ok" : alarmStatus}
loading={loading}
trend={alarmTrend}
trendLabel={alarmTrend !== null ? `${alarmTrend > 0 ? "+" : ""}${alarmTrend}` : undefined}
trendInvert
href="/alarms"
/>
<KpiCard
title="Generator"
value={loading ? "—" : genFuel !== null ? `${genFuel.toFixed(1)}% fuel` : "—"}
hint={genState === "standby" ? "Standby — ready" : genState === "running" ? "Running under load" : genState === "test" ? "Test run" : genState === "fault" ? "FAULT — check generator" : "—"}
icon={Fuel}
iconColor={genStatus === "critical" ? "text-destructive" : genStatus === "warning" ? "text-amber-400" : "text-green-400"}
status={loading ? "ok" : genStatus}
loading={loading}
href="/power"
/>
<KpiCard
title="Leak Detection"
value={loading ? "—" : activeLeaks > 0 ? `${activeLeaks} active` : "All clear"}
hint={activeLeaks > 0 ? "Water detected — investigate immediately" : `${leakSensors.length} sensors monitoring`}
icon={Droplets}
iconColor={leakStatus === "critical" ? "text-destructive" : "text-blue-400"}
status={loading ? "ok" : leakStatus}
loading={loading}
href="/environmental"
/>
</div>
{/* Charts row */}
<div className="flex items-center justify-between">
<p className="text-xs text-muted-foreground font-medium uppercase tracking-wider">Trends</p>
<TimeRangePicker value={chartHours} onChange={setChartHours} />
</div>
<div className="grid grid-cols-1 gap-4 lg:grid-cols-2">
<PowerTrendChart data={powerHistory} loading={loading} />
<TemperatureTrendChart data={tempHistory} loading={loading} />
</div>
{/* Bottom row — 50/50 */}
<div className="grid grid-cols-1 gap-4 lg:grid-cols-2">
<MiniFloorMap layout={mapLayout} racks={mapRacks} loading={loading} />
<AlarmFeed alarms={alarms} loading={loading} onAcknowledge={refreshKpis} onAlarmClick={handleAlarmClick} />
</div>
</PageShell>
);
}

View file

@ -0,0 +1,330 @@
"use client";
import { useEffect, useState, useCallback } from "react";
import { toast } from "sonner";
import {
fetchEnergyReport, fetchUtilityPower,
type EnergyReport, type UtilityPower,
} from "@/lib/api";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Skeleton } from "@/components/ui/skeleton";
import {
AreaChart, Area, LineChart, Line,
XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, ReferenceLine,
} from "recharts";
import { Zap, Leaf, RefreshCw, TrendingDown, DollarSign, Activity } from "lucide-react";
import { cn } from "@/lib/utils";
const SITE_ID = "sg-01";
// Singapore grid emission factor (kgCO2e/kWh) — Energy Market Authority 2023
const GRID_EF_KG_CO2_KWH = 0.4168;
// Approximate WUE for air-cooled DC in Singapore climate
const WUE_EST = 1.4;
function KpiTile({
label, value, sub, icon: Icon, iconClass, warn,
}: {
label: string; value: string; sub?: string;
icon?: React.ElementType; iconClass?: string; warn?: boolean;
}) {
return (
<div className="rounded-xl border border-border bg-muted/10 px-4 py-4 space-y-1">
<div className="flex items-center gap-2">
{Icon && <Icon className={cn("w-4 h-4 shrink-0", iconClass ?? "text-primary")} />}
<p className="text-[10px] text-muted-foreground uppercase tracking-wider">{label}</p>
</div>
<p className={cn("text-2xl font-bold tabular-nums leading-none", warn && "text-amber-400")}>{value}</p>
{sub && <p className="text-[10px] text-muted-foreground">{sub}</p>}
</div>
);
}
function SectionHeader({ children }: { children: React.ReactNode }) {
return (
<h2 className="text-xs font-semibold uppercase tracking-widest text-muted-foreground">{children}</h2>
);
}
export default function EnergyPage() {
const [energy, setEnergy] = useState<EnergyReport | null>(null);
const [utility, setUtility] = useState<UtilityPower | null>(null);
const [loading, setLoading] = useState(true);
const load = useCallback(async () => {
try {
const [e, u] = await Promise.all([
fetchEnergyReport(SITE_ID, 30),
fetchUtilityPower(SITE_ID).catch(() => null),
]);
setEnergy(e);
setUtility(u);
} catch { toast.error("Failed to load energy data"); }
finally { setLoading(false); }
}, []);
useEffect(() => {
load();
const id = setInterval(load, 60_000);
return () => clearInterval(id);
}, [load]);
const co2e_kg = energy ? Math.round(energy.kwh_total * GRID_EF_KG_CO2_KWH) : null;
const co2e_t = co2e_kg ? (co2e_kg / 1000).toFixed(2) : null;
const wue_water = energy ? (energy.kwh_total * (WUE_EST - 1)).toFixed(0) : null;
const itKwChart = (energy?.pue_trend ?? []).map((d) => ({
day: new Date(d.day).toLocaleDateString("en-GB", { month: "short", day: "numeric" }),
kw: d.avg_it_kw,
pue: d.pue_est,
}));
const avgPue30 = energy?.pue_estimated ?? null;
const pueWarn = avgPue30 != null && avgPue30 > 1.5;
return (
<div className="p-6 space-y-6">
{/* Header */}
<div className="flex items-start justify-between gap-4 flex-wrap">
<div>
<h1 className="text-xl font-semibold">Energy &amp; Sustainability</h1>
<p className="text-sm text-muted-foreground">Singapore DC01 30-day energy analysis · refreshes every 60s</p>
</div>
<div className="flex items-center gap-3">
{!loading && (
<div className="flex items-center gap-1.5 text-xs px-3 py-1.5 rounded-full bg-green-500/10 text-green-400 font-semibold">
<Leaf className="w-3.5 h-3.5" /> {co2e_t ? `${co2e_t} tCO₂e this month` : "—"}
</div>
)}
<button
onClick={load}
className="flex items-center gap-1.5 text-xs text-muted-foreground hover:text-foreground transition-colors"
>
<RefreshCw className="w-3.5 h-3.5" /> Refresh
</button>
</div>
</div>
{/* Site energy banner */}
{!loading && utility && (
<div className="rounded-xl border border-border bg-muted/10 px-5 py-3 flex items-center gap-8 text-sm flex-wrap">
<div>
<span className="text-muted-foreground">Current IT load: </span>
<strong>{utility.total_kw.toFixed(1)} kW</strong>
</div>
<div>
<span className="text-muted-foreground">Tariff: </span>
<strong>SGD {utility.tariff_sgd_kwh.toFixed(3)}/kWh</strong>
</div>
<div>
<span className="text-muted-foreground">Month-to-date: </span>
<strong>{utility.kwh_month_to_date.toFixed(0)} kWh</strong>
<span className="text-muted-foreground ml-1">
(SGD {utility.cost_sgd_mtd.toFixed(0)})
</span>
</div>
<div className="ml-auto text-xs text-muted-foreground">
Singapore · SP Group grid
</div>
</div>
)}
{/* 30-day KPIs */}
<SectionHeader>30-Day Energy Summary</SectionHeader>
{loading ? (
<div className="grid grid-cols-2 lg:grid-cols-4 gap-3">
{Array.from({ length: 4 }).map((_, i) => <Skeleton key={i} className="h-24" />)}
</div>
) : (
<div className="grid grid-cols-2 lg:grid-cols-4 gap-3">
<KpiTile
label="Total Consumption"
value={energy ? `${energy.kwh_total.toFixed(0)} kWh` : "—"}
sub="last 30 days"
icon={Zap}
iconClass="text-amber-400"
/>
<KpiTile
label="Energy Cost"
value={energy ? `SGD ${energy.cost_sgd.toFixed(0)}` : "—"}
sub={`@ SGD ${energy?.tariff_sgd_kwh.toFixed(3) ?? "—"}/kWh`}
icon={DollarSign}
iconClass="text-green-400"
/>
<KpiTile
label="Avg PUE"
value={avgPue30 != null ? avgPue30.toFixed(3) : "—"}
sub={avgPue30 != null && avgPue30 < 1.4 ? "Excellent" : avgPue30 != null && avgPue30 < 1.6 ? "Good" : "Room to improve"}
icon={Activity}
iconClass={pueWarn ? "text-amber-400" : "text-primary"}
warn={pueWarn}
/>
<KpiTile
label="Annual Estimate"
value={utility ? `SGD ${(utility.cost_sgd_annual_est).toFixed(0)}` : "—"}
sub={utility ? `${utility.kwh_annual_est.toFixed(0)} kWh/yr` : undefined}
icon={TrendingDown}
iconClass="text-muted-foreground"
/>
</div>
)}
{/* IT Load trend */}
{(loading || itKwChart.length > 0) && (
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-semibold flex items-center gap-2">
<Zap className="w-4 h-4 text-amber-400" />
Daily IT Load 30 Days
</CardTitle>
</CardHeader>
<CardContent>
{loading ? (
<Skeleton className="h-48" />
) : (
<ResponsiveContainer width="100%" height={200}>
<AreaChart data={itKwChart} margin={{ top: 4, right: 16, left: -10, bottom: 0 }}>
<defs>
<linearGradient id="itKwGrad" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor="oklch(0.78 0.17 84)" stopOpacity={0.3} />
<stop offset="95%" stopColor="oklch(0.78 0.17 84)" stopOpacity={0} />
</linearGradient>
</defs>
<CartesianGrid strokeDasharray="3 3" stroke="oklch(1 0 0 / 8%)" vertical={false} />
<XAxis
dataKey="day"
tick={{ fontSize: 9, fill: "oklch(0.65 0.04 257)" }}
tickLine={false} axisLine={false}
interval={4}
/>
<YAxis
tick={{ fontSize: 9, fill: "oklch(0.65 0.04 257)" }}
tickLine={false} axisLine={false}
tickFormatter={(v) => `${v} kW`}
domain={["auto", "auto"]}
/>
<Tooltip
contentStyle={{ backgroundColor: "oklch(0.16 0.04 265)", border: "1px solid oklch(1 0 0/9%)", borderRadius: "6px", fontSize: "12px" }}
formatter={(v) => [`${Number(v).toFixed(1)} kW`, "IT Load"]}
/>
<Area type="monotone" dataKey="kw" stroke="oklch(0.78 0.17 84)" fill="url(#itKwGrad)" strokeWidth={2} dot={false} />
</AreaChart>
</ResponsiveContainer>
)}
</CardContent>
</Card>
)}
{/* PUE trend */}
{(loading || itKwChart.length > 0) && (
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-semibold flex items-center gap-2">
<Activity className="w-4 h-4 text-primary" />
PUE Trend 30 Days
<span className="text-[10px] font-normal text-muted-foreground ml-1">(target: &lt; 1.4)</span>
</CardTitle>
</CardHeader>
<CardContent>
{loading ? (
<Skeleton className="h-48" />
) : (
<ResponsiveContainer width="100%" height={200}>
<LineChart data={itKwChart} margin={{ top: 4, right: 16, left: -10, bottom: 0 }}>
<CartesianGrid strokeDasharray="3 3" stroke="oklch(1 0 0 / 8%)" vertical={false} />
<XAxis
dataKey="day"
tick={{ fontSize: 9, fill: "oklch(0.65 0.04 257)" }}
tickLine={false} axisLine={false}
interval={4}
/>
<YAxis
tick={{ fontSize: 9, fill: "oklch(0.65 0.04 257)" }}
tickLine={false} axisLine={false}
domain={[1.0, "auto"]}
/>
<Tooltip
contentStyle={{ backgroundColor: "oklch(0.16 0.04 265)", border: "1px solid oklch(1 0 0/9%)", borderRadius: "6px", fontSize: "12px" }}
formatter={(v) => [Number(v).toFixed(3), "PUE"]}
/>
<ReferenceLine y={1.4} stroke="oklch(0.68 0.14 162)" strokeDasharray="4 4" strokeWidth={1}
label={{ value: "Target 1.4", fontSize: 9, fill: "oklch(0.68 0.14 162)", position: "insideTopRight" }} />
<ReferenceLine y={1.6} stroke="oklch(0.65 0.20 45)" strokeDasharray="4 4" strokeWidth={1}
label={{ value: "Warn 1.6", fontSize: 9, fill: "oklch(0.65 0.20 45)", position: "insideTopRight" }} />
<Line type="monotone" dataKey="pue" stroke="oklch(0.62 0.17 212)" strokeWidth={2} dot={false} activeDot={{ r: 3 }} />
</LineChart>
</ResponsiveContainer>
)}
</CardContent>
</Card>
)}
{/* Sustainability */}
<SectionHeader>Sustainability Metrics</SectionHeader>
{loading ? (
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3">
{Array.from({ length: 3 }).map((_, i) => <Skeleton key={i} className="h-24" />)}
</div>
) : (
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3">
<div className="rounded-xl border border-green-500/20 bg-green-500/5 px-4 py-4 space-y-2">
<div className="flex items-center gap-2">
<Leaf className="w-4 h-4 text-green-400" />
<p className="text-xs font-semibold text-green-400 uppercase tracking-wider">Carbon Footprint</p>
</div>
<p className="text-2xl font-bold tabular-nums">{co2e_t ?? "—"} tCOe</p>
<p className="text-[10px] text-muted-foreground">
30-day estimate · {energy?.kwh_total.toFixed(0) ?? "—"} kWh × {GRID_EF_KG_CO2_KWH} kgCOe/kWh
</p>
<p className="text-[10px] text-muted-foreground">
Singapore grid emission factor (EMA 2023)
</p>
</div>
<div className="rounded-xl border border-blue-500/20 bg-blue-500/5 px-4 py-4 space-y-2">
<div className="flex items-center gap-2">
<Activity className="w-4 h-4 text-blue-400" />
<p className="text-xs font-semibold text-blue-400 uppercase tracking-wider">Water Usage (WUE)</p>
</div>
<p className="text-2xl font-bold tabular-nums">{WUE_EST.toFixed(1)}</p>
<p className="text-[10px] text-muted-foreground">
Estimated WUE (L/kWh) · air-cooled DC
</p>
<p className="text-[10px] text-muted-foreground">
Est. {wue_water ? `${Number(wue_water).toLocaleString()} L` : "—"} consumed (30d)
</p>
</div>
<div className="rounded-xl border border-primary/20 bg-primary/5 px-4 py-4 space-y-2">
<div className="flex items-center gap-2">
<TrendingDown className="w-4 h-4 text-primary" />
<p className="text-xs font-semibold text-primary uppercase tracking-wider">Efficiency</p>
</div>
<p className={cn("text-2xl font-bold tabular-nums", pueWarn ? "text-amber-400" : "text-green-400")}>
{avgPue30?.toFixed(3) ?? "—"}
</p>
<p className="text-[10px] text-muted-foreground">
Avg PUE · {avgPue30 != null && avgPue30 < 1.4 ? "Excellent — Tier IV class" :
avgPue30 != null && avgPue30 < 1.6 ? "Good — industry average" :
"Above average — optimise cooling"}
</p>
<p className="text-[10px] text-muted-foreground">
IT energy efficiency: {avgPue30 != null ? `${(1 / avgPue30 * 100).toFixed(1)}%` : "—"} of total power to IT
</p>
</div>
</div>
)}
{/* Reference info */}
<div className="rounded-xl border border-border bg-muted/5 px-5 py-4 text-xs text-muted-foreground space-y-1.5">
<p className="font-semibold text-foreground/80">Singapore Energy Context</p>
<p>Grid emission factor: {GRID_EF_KG_CO2_KWH} kgCOe/kWh (EMA 2023, predominantly natural gas + growing solar)</p>
<p>Electricity tariff: SGD {utility?.tariff_sgd_kwh.toFixed(3) ?? "0.298"}/kWh (SP Group commercial rate)</p>
<p>BCA Green Mark: Targeting GoldPLUS certification · PUE target &lt; 1.4</p>
<p className="text-muted-foreground/50 text-[10px] pt-1">
COe and WUE estimates are indicative. Actual values depend on metered chilled water and cooling tower data.
</p>
</div>
</div>
);
}

View file

@ -0,0 +1,846 @@
"use client";
import { useEffect, useState, useCallback } from "react";
import { toast } from "sonner";
import {
fetchRackEnvReadings, fetchHumidityHistory, fetchTempHistory as fetchRoomTempHistory,
fetchCracStatus, fetchLeakStatus, fetchFireStatus, fetchParticleStatus,
type RoomEnvReadings, type HumidityBucket, type TempBucket, type CracStatus,
type LeakSensorStatus, type FireZoneStatus, type ParticleStatus,
} from "@/lib/api";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Skeleton } from "@/components/ui/skeleton";
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { useThresholds } from "@/lib/threshold-context";
import { TimeRangePicker } from "@/components/ui/time-range-picker";
import {
ComposedChart, Line, XAxis, YAxis, CartesianGrid, Tooltip,
ResponsiveContainer, ReferenceLine, ReferenceArea,
} from "recharts";
import { Thermometer, Droplets, WifiOff, CheckCircle2, AlertTriangle, Flame, Wind } from "lucide-react";
import { RackDetailSheet } from "@/components/dashboard/rack-detail-sheet";
import { cn } from "@/lib/utils";
import Link from "next/link";
const SITE_ID = "sg-01";
const ROOM_COLORS: Record<string, { temp: string; hum: string }> = {
"hall-a": { temp: "oklch(0.62 0.17 212)", hum: "oklch(0.55 0.18 270)" },
"hall-b": { temp: "oklch(0.7 0.15 162)", hum: "oklch(0.60 0.15 145)" },
};
const roomLabels: Record<string, string> = { "hall-a": "Hall A", "hall-b": "Hall B" };
function formatTime(iso: string) {
return new Date(iso).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
}
// ── Utility functions ─────────────────────────────────────────────────────────
/** Magnus formula dew point (°C) from temperature (°C) and relative humidity (%) */
function dewPoint(temp: number, rh: number): number {
const gamma = Math.log(rh / 100) + (17.625 * temp) / (243.04 + temp);
return Math.round((243.04 * gamma / (17.625 - gamma)) * 10) / 10;
}
function humidityColor(hum: number | null): string {
if (hum === null) return "oklch(0.25 0.02 265)";
if (hum > 80) return "oklch(0.55 0.22 25)"; // critical high
if (hum > 65) return "oklch(0.65 0.20 45)"; // warning high
if (hum > 50) return "oklch(0.72 0.18 84)"; // elevated
if (hum >= 30) return "oklch(0.68 0.14 162)"; // optimal
return "oklch(0.62 0.17 212)"; // low (static risk)
}
// ── Temperature heatmap ───────────────────────────────────────────────────────
function tempColor(temp: number | null, warn = 26, crit = 28): string {
if (temp === null) return "oklch(0.25 0.02 265)";
if (temp >= crit + 4) return "oklch(0.55 0.22 25)";
if (temp >= crit) return "oklch(0.65 0.20 45)";
if (temp >= warn) return "oklch(0.72 0.18 84)";
if (temp >= warn - 2) return "oklch(0.78 0.14 140)";
if (temp >= warn - 4) return "oklch(0.68 0.14 162)";
return "oklch(0.60 0.15 212)";
}
type HeatmapOverlay = "temp" | "humidity";
function TempHeatmap({
rooms, onRackClick, activeRoom, tempWarn = 26, tempCrit = 28, humWarn = 65, humCrit = 80,
}: {
rooms: RoomEnvReadings[];
onRackClick: (rackId: string) => void;
activeRoom: string;
tempWarn?: number;
tempCrit?: number;
humWarn?: number;
humCrit?: number;
}) {
const [overlay, setOverlay] = useState<HeatmapOverlay>("temp");
const room = rooms.find((r) => r.room_id === activeRoom);
return (
<Card>
<CardHeader className="pb-2">
<div className="flex items-center justify-between flex-wrap gap-2">
<CardTitle className="text-sm font-semibold flex items-center gap-2">
{overlay === "temp"
? <Thermometer className="w-4 h-4 text-primary" />
: <Droplets className="w-4 h-4 text-blue-400" />}
{overlay === "temp" ? "Temperature" : "Humidity"} Heatmap
</CardTitle>
<div className="flex items-center gap-2">
{/* Overlay toggle */}
<div className="flex items-center gap-0.5 rounded-md border border-border p-0.5">
<button
onClick={() => setOverlay("temp")}
aria-label="Temperature overlay"
aria-pressed={overlay === "temp"}
className={cn(
"flex items-center gap-1 px-2 py-0.5 rounded text-[10px] font-medium transition-colors",
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary",
overlay === "temp" ? "bg-primary text-primary-foreground" : "text-muted-foreground hover:text-foreground"
)}
>
<Thermometer className="w-3 h-3" /> Temp
</button>
<button
onClick={() => setOverlay("humidity")}
aria-label="Humidity overlay"
aria-pressed={overlay === "humidity"}
className={cn(
"flex items-center gap-1 px-2 py-0.5 rounded text-[10px] font-medium transition-colors",
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary",
overlay === "humidity" ? "bg-primary text-primary-foreground" : "text-muted-foreground hover:text-foreground"
)}
>
<Droplets className="w-3 h-3" /> Humidity
</button>
</div>
</div>
</div>
</CardHeader>
<CardContent className="space-y-4">
{/* Callout — hottest or most humid */}
{(() => {
if (overlay === "temp") {
const hottest = room?.racks.reduce((a, b) =>
(a.temperature ?? 0) > (b.temperature ?? 0) ? a : b
);
if (!hottest || hottest.temperature === null) return null;
const isHot = hottest.temperature >= tempWarn;
return (
<div className={cn(
"flex items-center gap-2 rounded-lg px-3 py-2 text-xs",
hottest.temperature >= tempCrit ? "bg-destructive/10 text-destructive" :
isHot ? "bg-amber-500/10 text-amber-400" : "bg-muted/40 text-muted-foreground"
)}>
<Thermometer className="w-3.5 h-3.5 shrink-0" />
<span>
Hottest: <strong>{hottest.rack_id.toUpperCase()}</strong> at <strong>{hottest.temperature}°C</strong>
{hottest.temperature >= tempCrit ? " — above critical threshold" : isHot ? " — above warning threshold" : " — within normal range"}
</span>
</div>
);
} else {
const humid = room?.racks.reduce((a, b) =>
(a.humidity ?? 0) > (b.humidity ?? 0) ? a : b
);
if (!humid || humid.humidity === null) return null;
const isHigh = humid.humidity > humWarn;
return (
<div className={cn(
"flex items-center gap-2 rounded-lg px-3 py-2 text-xs",
humid.humidity > humCrit ? "bg-destructive/10 text-destructive" :
isHigh ? "bg-amber-500/10 text-amber-400" : "bg-muted/40 text-muted-foreground"
)}>
<Droplets className="w-3.5 h-3.5 shrink-0" />
<span>
Highest humidity: <strong>{humid.rack_id.toUpperCase()}</strong> at <strong>{humid.humidity}%</strong>
{humid.humidity > humCrit ? " — above critical threshold" : isHigh ? " — above warning threshold" : " — within normal range"}
</span>
</div>
);
}
})()}
{/* Rack grid */}
<div className="grid grid-cols-5 gap-2">
{room?.racks.map((rack) => {
const offline = rack.temperature === null && rack.humidity === null;
const bg = overlay === "temp" ? tempColor(rack.temperature, tempWarn, tempCrit) : humidityColor(rack.humidity);
const mainVal = overlay === "temp"
? (rack.temperature !== null ? `${rack.temperature}°` : null)
: (rack.humidity !== null ? `${rack.humidity}%` : null);
const subVal = overlay === "temp"
? (rack.humidity !== null && rack.temperature !== null
? `DP ${dewPoint(rack.temperature, rack.humidity)}°` : null)
: (rack.temperature !== null ? `${rack.temperature}°C` : null);
return (
<div
key={rack.rack_id}
onClick={() => onRackClick(rack.rack_id)}
className={cn(
"relative rounded-lg p-3 flex flex-col items-center justify-center gap-0.5 min-h-[72px] transition-all cursor-pointer hover:ring-2 hover:ring-white/20",
offline ? "hover:opacity-70" : "hover:opacity-80"
)}
style={{
backgroundColor: offline ? "oklch(0.22 0.02 265)" : bg,
backgroundImage: offline
? "repeating-linear-gradient(45deg, transparent, transparent 4px, oklch(1 0 0 / 4%) 4px, oklch(1 0 0 / 4%) 8px)"
: undefined,
}}
>
<span className="text-[10px] font-semibold text-white/70">
{rack.rack_id.replace("rack-", "").toUpperCase()}
</span>
{offline ? (
<WifiOff className="w-3.5 h-3.5 text-white/40" />
) : (
<span className="text-base font-bold text-white">{mainVal ?? "—"}</span>
)}
{subVal && (
<span className="text-[10px] text-white/60">{subVal}</span>
)}
</div>
);
})}
</div>
{/* Legend */}
<div className="flex items-center gap-1 text-[10px] text-muted-foreground">
{overlay === "temp" ? (
<>
<span>Cool</span>
{(["oklch(0.60 0.15 212)", "oklch(0.68 0.14 162)", "oklch(0.78 0.14 140)", "oklch(0.72 0.18 84)", "oklch(0.65 0.20 45)", "oklch(0.55 0.22 25)"] as string[]).map((c, i) => (
<span key={i} className="w-6 h-3 rounded-sm inline-block" style={{ backgroundColor: c }} />
))}
<span>Hot</span>
<span className="ml-auto">Warn: {tempWarn}°C &nbsp;|&nbsp; Crit: {tempCrit}°C · Tiles show dew point (DP)</span>
</>
) : (
<>
<span>Dry</span>
{(["oklch(0.62 0.17 212)", "oklch(0.68 0.14 162)", "oklch(0.72 0.18 84)", "oklch(0.65 0.20 45)", "oklch(0.55 0.22 25)"] as string[]).map((c, i) => (
<span key={i} className="w-6 h-3 rounded-sm inline-block" style={{ backgroundColor: c }} />
))}
<span>Humid</span>
<span className="ml-auto">Optimal: 3065% &nbsp;|&nbsp; ASHRAE A1 max: 80%</span>
</>
)}
</div>
</CardContent>
</Card>
);
}
// ── Dual-axis trend chart ─────────────────────────────────────────────────────
function EnvTrendChart({
tempData, humData, hours, activeRoom, tempWarn = 26, tempCrit = 28, humWarn = 65,
}: {
tempData: TempBucket[];
humData: HumidityBucket[];
hours: number;
activeRoom: string;
tempWarn?: number;
tempCrit?: number;
humWarn?: number;
}) {
const roomIds = [...new Set(tempData.map((d) => d.room_id))].sort();
// Build combined rows for the active room
type ComboRow = { time: string; temp: number | null; hum: number | null };
const buckets = new Map<string, ComboRow>();
for (const d of tempData.filter((d) => d.room_id === activeRoom)) {
const time = formatTime(d.bucket);
if (!buckets.has(time)) buckets.set(time, { time, temp: null, hum: null });
buckets.get(time)!.temp = d.avg_temp;
}
for (const d of humData.filter((d) => d.room_id === activeRoom)) {
const time = formatTime(d.bucket);
if (!buckets.has(time)) buckets.set(time, { time, temp: null, hum: null });
buckets.get(time)!.hum = d.avg_humidity;
}
const chartData = Array.from(buckets.values());
const colors = ROOM_COLORS[activeRoom] ?? ROOM_COLORS["hall-a"];
const labelSuffix = hours <= 1 ? "1h" : hours <= 6 ? "6h" : hours <= 24 ? "24h" : "7d";
return (
<Card>
<CardHeader className="pb-2">
<div className="flex items-center justify-between flex-wrap gap-2">
<CardTitle className="text-sm font-semibold flex items-center gap-2">
<Thermometer className="w-4 h-4 text-primary" />
<Droplets className="w-4 h-4 text-blue-400" />
Temp &amp; Humidity last {labelSuffix}
</CardTitle>
</div>
{/* Legend */}
<div className="flex items-center gap-4 text-[10px] text-muted-foreground mt-1 flex-wrap">
<span className="flex items-center gap-1.5">
<span className="w-4 h-0.5 inline-block rounded" style={{ backgroundColor: colors.temp }} />
Temp (°C, left axis)
</span>
<span className="flex items-center gap-1.5">
<span className="w-4 h-0.5 inline-block rounded border-t-2 border-dashed" style={{ borderColor: colors.hum }} />
Humidity (%, right axis)
</span>
<span className="flex items-center gap-1.5 ml-auto">
<span className="w-4 h-3 inline-block rounded opacity-40" style={{ backgroundColor: "#22c55e" }} />
ASHRAE A1 safe zone
</span>
</div>
</CardHeader>
<CardContent>
{chartData.length === 0 ? (
<div className="h-[240px] flex items-center justify-center text-sm text-muted-foreground">
Waiting for data...
</div>
) : (
<ResponsiveContainer width="100%" height={240}>
<ComposedChart data={chartData} margin={{ top: 4, right: 30, left: -16, bottom: 0 }}>
{/* ASHRAE A1 safe zones */}
<ReferenceArea yAxisId="temp" y1={18} y2={27} fill="#22c55e" fillOpacity={0.07} />
<ReferenceArea yAxisId="hum" y1={20} y2={80} fill="#3b82f6" fillOpacity={0.05} />
<CartesianGrid strokeDasharray="3 3" stroke="oklch(1 0 0 / 8%)" />
<XAxis
dataKey="time"
tick={{ fontSize: 10, fill: "oklch(0.65 0.04 257)" }}
tickLine={false}
axisLine={false}
/>
{/* Left axis — temperature */}
<YAxis
yAxisId="temp"
domain={[16, 36]}
tick={{ fontSize: 10, fill: "oklch(0.65 0.04 257)" }}
tickLine={false}
axisLine={false}
tickFormatter={(v) => `${v}°`}
/>
{/* Right axis — humidity */}
<YAxis
yAxisId="hum"
orientation="right"
domain={[0, 100]}
tick={{ fontSize: 10, fill: "oklch(0.65 0.04 257)" }}
tickLine={false}
axisLine={false}
tickFormatter={(v) => `${v}%`}
/>
<Tooltip
contentStyle={{ backgroundColor: "oklch(0.16 0.04 265)", border: "1px solid oklch(1 0 0 / 9%)", borderRadius: "6px", fontSize: "12px" }}
formatter={(v, name) =>
name === "temp" ? [`${Number(v).toFixed(1)}°C`, "Temperature"] :
[`${Number(v).toFixed(0)}%`, "Humidity"]
}
/>
{/* Temp reference lines */}
<ReferenceLine yAxisId="temp" y={tempWarn} stroke="oklch(0.72 0.18 84)" strokeDasharray="4 4" strokeWidth={1}
label={{ value: `Warn ${tempWarn}°`, fontSize: 9, fill: "oklch(0.72 0.18 84)", position: "right" }} />
<ReferenceLine yAxisId="temp" y={tempCrit} stroke="oklch(0.55 0.22 25)" strokeDasharray="4 4" strokeWidth={1}
label={{ value: `Crit ${tempCrit}°`, fontSize: 9, fill: "oklch(0.55 0.22 25)", position: "right" }} />
{/* Humidity reference line */}
<ReferenceLine yAxisId="hum" y={humWarn} stroke="oklch(0.62 0.17 212)" strokeDasharray="4 4" strokeWidth={1} />
{/* Lines */}
<Line
yAxisId="temp"
type="monotone"
dataKey="temp"
stroke={colors.temp}
strokeWidth={2}
dot={false}
activeDot={{ r: 4 }}
connectNulls
/>
<Line
yAxisId="hum"
type="monotone"
dataKey="hum"
stroke={colors.hum}
strokeWidth={2}
strokeDasharray="6 3"
dot={false}
activeDot={{ r: 4 }}
connectNulls
/>
</ComposedChart>
</ResponsiveContainer>
)}
<p className="text-[10px] text-muted-foreground mt-2">
Green shaded band = ASHRAE A1 thermal envelope (1827°C / 2080% RH)
</p>
</CardContent>
</Card>
);
}
// ── ASHRAE A1 Compliance Table ────────────────────────────────────────────────
// ASHRAE A1: 1532°C, 2080% RH
function AshraeTable({ rooms }: { rooms: RoomEnvReadings[] }) {
const allRacks = rooms.flatMap(r =>
r.racks.map(rack => ({ ...rack, room_id: r.room_id }))
).filter(r => r.temperature !== null || r.humidity !== null);
type Issue = { type: string; detail: string };
const rows = allRacks.map(rack => {
const issues: Issue[] = [];
if (rack.temperature !== null) {
if (rack.temperature < 15) issues.push({ type: "Temp", detail: `${rack.temperature}°C — below 15°C min` });
if (rack.temperature > 32) issues.push({ type: "Temp", detail: `${rack.temperature}°C — above 32°C max` });
}
if (rack.humidity !== null) {
if (rack.humidity < 20) issues.push({ type: "RH", detail: `${rack.humidity}% — below 20% min` });
if (rack.humidity > 80) issues.push({ type: "RH", detail: `${rack.humidity}% — above 80% max` });
}
const dp = rack.temperature !== null && rack.humidity !== null
? dewPoint(rack.temperature, rack.humidity) : null;
return { rack, issues, dp };
});
const violations = rows.filter(r => r.issues.length > 0);
const compliant = rows.filter(r => r.issues.length === 0);
return (
<Card>
<CardHeader className="pb-2">
<div className="flex items-center justify-between">
<CardTitle className="text-sm font-semibold flex items-center gap-2">
<CheckCircle2 className="w-4 h-4 text-primary" /> ASHRAE A1 Compliance
</CardTitle>
<span className={cn(
"text-[10px] font-semibold px-2 py-0.5 rounded-full",
violations.length > 0 ? "bg-destructive/10 text-destructive" : "bg-green-500/10 text-green-400"
)}>
{violations.length === 0 ? `All ${compliant.length} racks compliant` : `${violations.length} violation${violations.length > 1 ? "s" : ""}`}
</span>
</div>
</CardHeader>
<CardContent>
{violations.length === 0 ? (
<p className="text-sm text-green-400 flex items-center gap-2">
<CheckCircle2 className="w-4 h-4" />
All racks within ASHRAE A1 envelope (1532°C, 2080% RH)
</p>
) : (
<div className="space-y-2">
{violations.map(({ rack, issues, dp }) => (
<div key={rack.rack_id} className="flex items-start gap-3 rounded-lg bg-destructive/5 border border-destructive/20 px-3 py-2 text-xs">
<AlertTriangle className="w-3.5 h-3.5 text-destructive mt-0.5 shrink-0" />
<div className="flex-1">
<span className="font-semibold">{rack.rack_id.toUpperCase()}</span>
<span className="text-muted-foreground ml-2">{roomLabels[rack.room_id] ?? rack.room_id}</span>
<div className="flex flex-wrap gap-x-4 gap-y-0.5 mt-0.5 text-destructive">
{issues.map((iss, i) => <span key={i}>{iss.type}: {iss.detail}</span>)}
{dp !== null && <span className="text-muted-foreground">DP: {dp}°C</span>}
</div>
</div>
</div>
))}
<p className="text-[10px] text-muted-foreground pt-1">
ASHRAE A1 envelope: 1532°C dry bulb, 2080% relative humidity
</p>
</div>
)}
</CardContent>
</Card>
);
}
// ── Dew Point Panel ───────────────────────────────────────────────────────────
function DewPointPanel({
rooms, cracs, activeRoom,
}: {
rooms: RoomEnvReadings[];
cracs: CracStatus[];
activeRoom: string;
}) {
const room = rooms.find(r => r.room_id === activeRoom);
const crac = cracs.find(c => c.room_id === activeRoom);
const supplyTemp = crac?.supply_temp ?? null;
const rackDps = (room?.racks ?? [])
.filter(r => r.temperature !== null && r.humidity !== null)
.map(r => ({
rack_id: r.rack_id,
dp: dewPoint(r.temperature!, r.humidity!),
temp: r.temperature!,
hum: r.humidity!,
}))
.sort((a, b) => b.dp - a.dp);
return (
<Card>
<CardHeader className="pb-2">
<div className="flex items-center justify-between flex-wrap gap-2">
<CardTitle className="text-sm font-semibold flex items-center gap-2">
<Droplets className="w-4 h-4 text-blue-400" /> Dew Point by Rack
</CardTitle>
{supplyTemp !== null && (
<span className="text-[10px] text-muted-foreground">
CRAC supply: <strong className="text-blue-400">{supplyTemp}°C</strong>
{rackDps.some(r => r.dp >= supplyTemp - 1) && (
<span className="text-destructive ml-1"> condensation risk!</span>
)}
</span>
)}
</div>
</CardHeader>
<CardContent>
{rackDps.length === 0 ? (
<p className="text-sm text-muted-foreground">No data available</p>
) : (
<div className="space-y-1.5">
{rackDps.map(({ rack_id, dp, temp, hum }) => {
const nearCondensation = supplyTemp !== null && dp >= supplyTemp - 1;
const dpColor = nearCondensation ? "text-destructive"
: dp > 15 ? "text-amber-400" : "text-foreground";
return (
<div key={rack_id} className="flex items-center gap-3 text-xs">
<span className="font-mono w-16 shrink-0 text-muted-foreground">
{rack_id.replace("rack-", "").toUpperCase()}
</span>
<div className="flex-1 h-1.5 rounded-full bg-muted overflow-hidden">
<div
className={cn("h-full rounded-full transition-all", nearCondensation ? "bg-destructive" : dp > 15 ? "bg-amber-500" : "bg-blue-500")}
style={{ width: `${Math.min(100, Math.max(0, (dp / 30) * 100))}%` }}
/>
</div>
<span className={cn("font-mono font-semibold w-16 text-right", dpColor)}>
{dp}°C DP
</span>
<span className="text-muted-foreground w-20 text-right hidden sm:block">
{temp}° / {hum}%
</span>
</div>
);
})}
<p className="text-[10px] text-muted-foreground pt-1">
Dew point approaching CRAC supply temp = condensation risk on cold surfaces
</p>
</div>
)}
</CardContent>
</Card>
);
}
// ── Leak sensor panel ─────────────────────────────────────────────────────────
function LeakPanel({ sensors }: { sensors: LeakSensorStatus[] }) {
const detected = sensors.filter(s => s.state === "detected");
const anyDetected = detected.length > 0;
return (
<Card className={cn("border", anyDetected && "border-destructive/50")}>
<CardHeader className="pb-2">
<div className="flex items-center justify-between">
<CardTitle className="text-sm font-semibold flex items-center gap-2">
<Droplets className="w-4 h-4 text-blue-400" /> Water / Leak Detection
</CardTitle>
<div className="flex items-center gap-2">
<Link href="/leak" className="text-[10px] text-primary hover:underline">View full page </Link>
<span className={cn("text-[10px] font-semibold px-2 py-0.5 rounded-full uppercase tracking-wide",
anyDetected ? "bg-destructive/10 text-destructive animate-pulse" : "bg-green-500/10 text-green-400",
)}>
{anyDetected ? `${detected.length} leak detected` : "All clear"}
</span>
</div>
</div>
</CardHeader>
<CardContent className="space-y-2">
{sensors.map(s => {
const detected = s.state === "detected";
return (
<div key={s.sensor_id} className={cn(
"flex items-start justify-between rounded-lg px-3 py-2 text-xs",
detected ? "bg-destructive/10" : "bg-muted/30",
)}>
<div>
<p className={cn("font-semibold", detected ? "text-destructive" : "text-foreground")}>
{detected ? <AlertTriangle className="w-3 h-3 inline mr-1" /> : <CheckCircle2 className="w-3 h-3 inline mr-1 text-green-400" />}
{s.sensor_id}
</p>
<p className="text-muted-foreground mt-0.5">
Zone: {s.floor_zone}
{s.under_floor ? " · under-floor" : ""}
{s.near_crac ? " · near CRAC" : ""}
{s.room_id ? ` · ${s.room_id}` : ""}
</p>
</div>
<span className={cn("font-semibold shrink-0 ml-2", detected ? "text-destructive" : "text-green-400")}>
{s.state === "detected" ? "DETECTED" : s.state === "clear" ? "Clear" : "Unknown"}
</span>
</div>
);
})}
{sensors.length === 0 && (
<p className="text-sm text-muted-foreground text-center py-2">No sensors configured</p>
)}
</CardContent>
</Card>
);
}
// ── VESDA / Fire panel ────────────────────────────────────────────────────────
const VESDA_LEVEL_CONFIG: Record<string, { label: string; color: string; bg: string }> = {
normal: { label: "Normal", color: "text-green-400", bg: "bg-green-500/10" },
alert: { label: "Alert", color: "text-amber-400", bg: "bg-amber-500/10" },
action: { label: "Action", color: "text-orange-400", bg: "bg-orange-500/10" },
fire: { label: "FIRE", color: "text-destructive", bg: "bg-destructive/10" },
};
function FirePanel({ zones }: { zones: FireZoneStatus[] }) {
const elevated = zones.filter(z => z.level !== "normal");
return (
<Card className={cn("border", elevated.length > 0 && elevated.some(z => z.level === "fire") && "border-destructive/50")}>
<CardHeader className="pb-2">
<div className="flex items-center justify-between">
<CardTitle className="text-sm font-semibold flex items-center gap-2">
<Flame className="w-4 h-4 text-orange-400" /> VESDA / Smoke Detection
</CardTitle>
<div className="flex items-center gap-2">
<Link href="/fire" className="text-[10px] text-primary hover:underline">View full page </Link>
<span className={cn("text-[10px] font-semibold px-2 py-0.5 rounded-full uppercase tracking-wide",
elevated.length === 0 ? "bg-green-500/10 text-green-400" :
zones.some(z => z.level === "fire") ? "bg-destructive/10 text-destructive animate-pulse" :
"bg-amber-500/10 text-amber-400",
)}>
{elevated.length === 0 ? "All normal" : `${elevated.length} zone${elevated.length !== 1 ? "s" : ""} elevated`}
</span>
</div>
</div>
</CardHeader>
<CardContent className="space-y-2">
{zones.map(zone => {
const cfg = VESDA_LEVEL_CONFIG[zone.level] ?? VESDA_LEVEL_CONFIG.normal;
return (
<div key={zone.zone_id} className={cn("rounded-lg px-3 py-2 text-xs", cfg.bg)}>
<div className="flex items-center justify-between mb-1.5">
<div>
<p className="font-semibold">{zone.zone_id}</p>
{zone.room_id && <p className="text-muted-foreground">{zone.room_id}</p>}
</div>
<span className={cn("font-bold text-sm uppercase", cfg.color)}>{cfg.label}</span>
</div>
<div className="flex items-center justify-between">
<span className="text-muted-foreground">
Obscuration: <strong className={cn(zone.level !== "normal" ? cfg.color : "")}>{zone.obscuration_pct_m != null ? `${zone.obscuration_pct_m.toFixed(3)} %/m` : "—"}</strong>
</span>
<div className="flex gap-2 text-[10px]">
{!zone.detector_1_ok && <span className="text-destructive">Det1 fault</span>}
{!zone.detector_2_ok && <span className="text-destructive">Det2 fault</span>}
{!zone.power_ok && <span className="text-destructive">Power fault</span>}
{!zone.flow_ok && <span className="text-destructive">Flow fault</span>}
{zone.detector_1_ok && zone.detector_2_ok && zone.power_ok && zone.flow_ok && (
<span className="text-green-400">Systems OK</span>
)}
</div>
</div>
</div>
);
})}
{zones.length === 0 && (
<p className="text-sm text-muted-foreground text-center py-2">No VESDA zones configured</p>
)}
</CardContent>
</Card>
);
}
// ── Particle count panel (ISO 14644) ──────────────────────────────────────────
const ISO_LABELS: Record<number, { label: string; color: string }> = {
5: { label: "ISO 5", color: "text-green-400" },
6: { label: "ISO 6", color: "text-green-400" },
7: { label: "ISO 7", color: "text-green-400" },
8: { label: "ISO 8", color: "text-amber-400" },
9: { label: "ISO 9", color: "text-destructive" },
};
const ISO8_0_5UM = 3_520_000;
const ISO8_5UM = 29_300;
function ParticlePanel({ rooms }: { rooms: ParticleStatus[] }) {
if (rooms.length === 0) return null;
return (
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-semibold flex items-center gap-2">
<Wind className="w-4 h-4 text-primary" />
Air Quality ISO 14644
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
{rooms.map(r => {
const cls = r.iso_class ? ISO_LABELS[r.iso_class] : null;
const p05pct = r.particles_0_5um !== null ? Math.min(100, (r.particles_0_5um / ISO8_0_5UM) * 100) : null;
const p5pct = r.particles_5um !== null ? Math.min(100, (r.particles_5um / ISO8_5UM) * 100) : null;
return (
<div key={r.room_id} className="space-y-2">
<div className="flex items-center justify-between">
<span className="text-sm font-medium">{r.room_id === "hall-a" ? "Hall A" : r.room_id === "hall-b" ? "Hall B" : r.room_id}</span>
{cls ? (
<span className={cn("text-xs font-semibold px-2 py-0.5 rounded-full bg-muted/40", cls.color)}>
{cls.label}
</span>
) : (
<span className="text-xs text-muted-foreground">No data</span>
)}
</div>
<div className="space-y-1.5 text-xs text-muted-foreground">
<div className="flex items-center gap-2">
<span className="w-28 shrink-0">0.5 µm</span>
<div className="flex-1 h-2 rounded-full bg-muted overflow-hidden">
{p05pct !== null && (
<div
className={cn("h-full rounded-full transition-all", p05pct >= 100 ? "bg-destructive" : p05pct >= 70 ? "bg-amber-500" : "bg-green-500")}
style={{ width: `${p05pct}%` }}
/>
)}
</div>
<span className="w-32 text-right font-mono">
{r.particles_0_5um !== null ? r.particles_0_5um.toLocaleString() : "—"} /m³
</span>
</div>
<div className="flex items-center gap-2">
<span className="w-28 shrink-0">5 µm</span>
<div className="flex-1 h-2 rounded-full bg-muted overflow-hidden">
{p5pct !== null && (
<div
className={cn("h-full rounded-full transition-all", p5pct >= 100 ? "bg-destructive" : p5pct >= 70 ? "bg-amber-500" : "bg-green-500")}
style={{ width: `${p5pct}%` }}
/>
)}
</div>
<span className="w-32 text-right font-mono">
{r.particles_5um !== null ? r.particles_5um.toLocaleString() : "—"} /m³
</span>
</div>
</div>
</div>
);
})}
<p className="text-[10px] text-muted-foreground pt-1">
DC target: ISO 8 (3,520,000 particles 0.5 µm/m³ · 29,300 5 µm/m³)
</p>
</CardContent>
</Card>
);
}
// ── Page ──────────────────────────────────────────────────────────────────────
export default function EnvironmentalPage() {
const { thresholds } = useThresholds();
const [rooms, setRooms] = useState<RoomEnvReadings[]>([]);
const [tempHist, setTempHist] = useState<TempBucket[]>([]);
const [humHist, setHumHist] = useState<HumidityBucket[]>([]);
const [cracs, setCracs] = useState<CracStatus[]>([]);
const [leakSensors, setLeak] = useState<LeakSensorStatus[]>([]);
const [fireZones, setFire] = useState<FireZoneStatus[]>([]);
const [particles, setParticles] = useState<ParticleStatus[]>([]);
const [hours, setHours] = useState(6);
const [loading, setLoading] = useState(true);
const [selectedRack, setSelectedRack] = useState<string | null>(null);
const [activeRoom, setActiveRoom] = useState("hall-a");
const load = useCallback(async () => {
try {
const [r, t, h, c, l, f, p] = await Promise.all([
fetchRackEnvReadings(SITE_ID),
fetchRoomTempHistory(SITE_ID, hours),
fetchHumidityHistory(SITE_ID, hours),
fetchCracStatus(SITE_ID),
fetchLeakStatus(SITE_ID).catch(() => []),
fetchFireStatus(SITE_ID).catch(() => []),
fetchParticleStatus(SITE_ID).catch(() => []),
]);
setRooms(r);
setTempHist(t);
setHumHist(h);
setCracs(c);
setLeak(l);
setFire(f);
setParticles(p);
} catch {
toast.error("Failed to load environmental data");
} finally {
setLoading(false);
}
}, [hours]);
useEffect(() => {
load();
const id = setInterval(load, 30_000);
return () => clearInterval(id);
}, [load]);
return (
<div className="p-6 space-y-6">
<div className="flex items-start justify-between">
<div>
<h1 className="text-xl font-semibold">Environmental Monitoring</h1>
<p className="text-sm text-muted-foreground">Singapore DC01 refreshes every 30s</p>
</div>
<TimeRangePicker value={hours} onChange={setHours} />
</div>
{loading ? (
<div className="space-y-4">
<Skeleton className="h-64 w-full" />
<Skeleton className="h-48 w-full" />
</div>
) : (
<>
<RackDetailSheet siteId={SITE_ID} rackId={selectedRack} onClose={() => setSelectedRack(null)} />
{/* Page-level room tab selector */}
{rooms.length > 0 && (
<Tabs value={activeRoom} onValueChange={setActiveRoom}>
<TabsList>
{rooms.map(r => (
<TabsTrigger key={r.room_id} value={r.room_id} className="px-6">
{roomLabels[r.room_id] ?? r.room_id}
</TabsTrigger>
))}
</TabsList>
</Tabs>
)}
{rooms.length > 0 && (
<TempHeatmap rooms={rooms} onRackClick={setSelectedRack} activeRoom={activeRoom}
tempWarn={thresholds.temp.warn} tempCrit={thresholds.temp.critical}
humWarn={thresholds.humidity.warn} humCrit={thresholds.humidity.critical} />
)}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
{rooms.length > 0 && <AshraeTable rooms={rooms} />}
{rooms.length > 0 && (
<DewPointPanel rooms={rooms} cracs={cracs} activeRoom={activeRoom} />
)}
</div>
{/* Leak + VESDA panels */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
<LeakPanel sensors={leakSensors} />
<FirePanel zones={fireZones} />
</div>
<EnvTrendChart tempData={tempHist} humData={humHist} hours={hours} activeRoom={activeRoom}
tempWarn={thresholds.temp.warn} tempCrit={thresholds.temp.critical}
humWarn={thresholds.humidity.warn} />
<ParticlePanel rooms={particles} />
</>
)}
</div>
);
}

View file

@ -0,0 +1,285 @@
"use client";
import { useEffect, useState, useCallback } from "react";
import { toast } from "sonner";
import { fetchFireStatus, type FireZoneStatus } from "@/lib/api";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Skeleton } from "@/components/ui/skeleton";
import { Flame, RefreshCw, CheckCircle2, AlertTriangle, Zap, Wind, Activity } from "lucide-react";
import { cn } from "@/lib/utils";
const SITE_ID = "sg-01";
const LEVEL_CONFIG: Record<string, {
label: string; bg: string; border: string; text: string; icon: React.ElementType; pulsing: boolean;
}> = {
normal: {
label: "Normal",
bg: "bg-green-500/10", border: "border-green-500/20", text: "text-green-400",
icon: CheckCircle2, pulsing: false,
},
alert: {
label: "Alert",
bg: "bg-amber-500/10", border: "border-amber-500/40", text: "text-amber-400",
icon: AlertTriangle, pulsing: false,
},
action: {
label: "Action",
bg: "bg-orange-500/10", border: "border-orange-500/40", text: "text-orange-400",
icon: AlertTriangle, pulsing: true,
},
fire: {
label: "FIRE",
bg: "bg-destructive/10", border: "border-destructive/60", text: "text-destructive",
icon: Flame, pulsing: true,
},
};
function ObscurationBar({ value }: { value: number | null }) {
if (value == null) return null;
const pct = Math.min(100, value * 20); // 05 %/m mapped to 0100%
const color = value > 3 ? "#ef4444" : value > 1.5 ? "#f59e0b" : "#94a3b8";
return (
<div>
<div className="flex justify-between text-[10px] mb-1">
<span className="text-muted-foreground">Obscuration</span>
<span className="font-mono font-semibold text-xs" style={{ color }}>{value.toFixed(2)} %/m</span>
</div>
<div className="rounded-full bg-muted overflow-hidden h-1.5">
<div className="h-full rounded-full transition-all duration-500" style={{ width: `${pct}%`, backgroundColor: color }} />
</div>
</div>
);
}
function StatusIndicator({ label, ok, icon: Icon }: {
label: string; ok: boolean; icon: React.ElementType;
}) {
return (
<div className={cn(
"rounded-lg px-2.5 py-2 flex items-center gap-2 text-xs",
ok ? "bg-green-500/10" : "bg-destructive/10",
)}>
<Icon className={cn("w-3.5 h-3.5 shrink-0", ok ? "text-green-400" : "text-destructive")} />
<div>
<p className="text-muted-foreground text-[10px]">{label}</p>
<p className={cn("font-semibold", ok ? "text-green-400" : "text-destructive")}>
{ok ? "OK" : "Fault"}
</p>
</div>
</div>
);
}
function VesdaCard({ zone }: { zone: FireZoneStatus }) {
const level = zone.level;
const cfg = LEVEL_CONFIG[level] ?? LEVEL_CONFIG.normal;
const Icon = cfg.icon;
const isAlarm = level !== "normal";
return (
<Card className={cn(isAlarm ? "border-2" : "border", cfg.border, isAlarm && cfg.bg, level === "fire" && "bg-red-950/30")}>
<CardHeader className="pb-2">
<div className="flex items-center justify-between gap-2">
<CardTitle className="text-sm font-semibold flex items-center gap-2">
<Icon className={cn("w-4 h-4", cfg.text, cfg.pulsing && "animate-pulse")} />
{zone.zone_id.toUpperCase()}
</CardTitle>
<span className={cn(
"flex items-center gap-1 text-[10px] font-bold px-2.5 py-0.5 rounded-full uppercase tracking-wide border",
cfg.bg, cfg.border, cfg.text,
)}>
<Icon className={cn("w-3 h-3", cfg.pulsing && "animate-pulse")} />
{cfg.label}
</span>
</div>
</CardHeader>
<CardContent className="space-y-4">
{level === "fire" && (
<div className="rounded-lg border border-destructive/60 bg-destructive/15 px-3 py-3 text-xs text-destructive font-semibold animate-pulse">
FIRE ALARM Initiate evacuation and contact emergency services immediately
</div>
)}
{level === "action" && (
<div className="rounded-lg border border-orange-500/40 bg-orange-500/10 px-3 py-2.5 text-xs text-orange-400 font-medium">
Action threshold reached investigate smoke source immediately
</div>
)}
{level === "alert" && (
<div className="rounded-lg border border-amber-500/30 bg-amber-500/10 px-3 py-2.5 text-xs text-amber-400">
Alert level elevated smoke particles detected, monitor closely
</div>
)}
<ObscurationBar value={zone.obscuration_pct_m} />
{/* Detector status */}
<div className="space-y-1.5">
{[
{ label: "Detector 1", ok: zone.detector_1_ok },
{ label: "Detector 2", ok: zone.detector_2_ok },
].map(({ label, ok }) => (
<div key={label} className="flex items-center justify-between text-xs">
<span className="flex items-center gap-1.5 text-muted-foreground">
{ok ? <CheckCircle2 className="w-3.5 h-3.5 text-green-400" /> : <AlertTriangle className="w-3.5 h-3.5 text-destructive" />}
{label}
</span>
<span className={cn("font-semibold", ok ? "text-green-400" : "text-destructive")}>
{ok ? "Online" : "Fault"}
</span>
</div>
))}
</div>
{/* System status */}
<div className="grid grid-cols-2 gap-2">
<StatusIndicator label="Power supply" ok={zone.power_ok} icon={Zap} />
<StatusIndicator label="Airflow" ok={zone.flow_ok} icon={Wind} />
</div>
</CardContent>
</Card>
);
}
export default function FireSafetyPage() {
const [zones, setZones] = useState<FireZoneStatus[]>([]);
const [loading, setLoading] = useState(true);
const load = useCallback(async () => {
try {
setZones(await fetchFireStatus(SITE_ID));
} catch { toast.error("Failed to load fire safety data"); }
finally { setLoading(false); }
}, []);
useEffect(() => {
load();
const id = setInterval(load, 10_000);
return () => clearInterval(id);
}, [load]);
const fireZones = zones.filter((z) => z.level === "fire");
const actionZones = zones.filter((z) => z.level === "action");
const alertZones = zones.filter((z) => z.level === "alert");
const normalZones = zones.filter((z) => z.level === "normal");
const anyAlarm = fireZones.length + actionZones.length + alertZones.length > 0;
const worstLevel =
fireZones.length > 0 ? "fire" :
actionZones.length > 0 ? "action" :
alertZones.length > 0 ? "alert" : "normal";
const worstCfg = LEVEL_CONFIG[worstLevel];
const WIcon = worstCfg.icon;
return (
<div className="p-6 space-y-6">
{/* Header */}
<div className="flex items-start justify-between gap-4 flex-wrap">
<div>
<h1 className="text-xl font-semibold">Fire &amp; Life Safety</h1>
<p className="text-sm text-muted-foreground">Singapore DC01 VESDA aspirating detector network · refreshes every 10s</p>
</div>
<div className="flex items-center gap-3">
{!loading && (
<span className={cn(
"flex items-center gap-1.5 text-xs font-semibold px-3 py-1.5 rounded-full border",
worstCfg.bg, worstCfg.border, worstCfg.text,
anyAlarm && "animate-pulse",
)}>
<WIcon className="w-3.5 h-3.5" />
{anyAlarm
? `${fireZones.length + actionZones.length + alertZones.length} zone${fireZones.length + actionZones.length + alertZones.length > 1 ? "s" : ""} in alarm`
: `All ${zones.length} zones normal`}
</span>
)}
<button
onClick={load}
className="flex items-center gap-1.5 text-xs text-muted-foreground hover:text-foreground transition-colors"
>
<RefreshCw className="w-3.5 h-3.5" /> Refresh
</button>
</div>
</div>
{/* System summary bar */}
{!loading && (
<div className="rounded-xl border border-border bg-muted/10 px-5 py-3 flex items-center gap-8 text-sm flex-wrap">
<div className="flex items-center gap-2">
<Activity className="w-4 h-4 text-primary" />
<span className="text-muted-foreground">VESDA zones monitored:</span>
<strong>{zones.length}</strong>
</div>
{[
{ label: "Fire", count: fireZones.length, cls: "text-destructive" },
{ label: "Action", count: actionZones.length, cls: "text-orange-400" },
{ label: "Alert", count: alertZones.length, cls: "text-amber-400" },
{ label: "Normal", count: normalZones.length, cls: "text-green-400" },
].map(({ label, count, cls }) => (
<div key={label} className="flex items-center gap-1.5">
<span className="text-muted-foreground">{label}:</span>
<strong className={cls}>{count}</strong>
</div>
))}
<div className="ml-auto text-xs text-muted-foreground">
All detectors use VESDA aspirating smoke detection technology
</div>
</div>
)}
{/* Fire alarm banner */}
{!loading && fireZones.length > 0 && (
<div className="rounded-xl border-2 border-destructive bg-destructive/10 px-5 py-4 animate-pulse">
<div className="flex items-center gap-3 mb-2">
<Flame className="w-6 h-6 text-destructive shrink-0" />
<p className="text-base font-bold text-destructive">
FIRE ALARM ACTIVE {fireZones.length} zone{fireZones.length > 1 ? "s" : ""}
</p>
</div>
<p className="text-sm text-destructive/80">
Initiate building evacuation. Contact SCDF (995). Do not re-enter until cleared by fire services.
</p>
</div>
)}
{/* Zone cards — alarms first */}
{loading ? (
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{Array.from({ length: 4 }).map((_, i) => <Skeleton key={i} className="h-64" />)}
</div>
) : zones.length === 0 ? (
<div className="text-sm text-muted-foreground">No VESDA zone data available</div>
) : (
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{[...fireZones, ...actionZones, ...alertZones, ...normalZones].map((zone) => (
<VesdaCard key={zone.zone_id} zone={zone} />
))}
</div>
)}
{/* Legend */}
<div className="rounded-xl border border-border bg-muted/5 px-5 py-4">
<p className="text-xs font-semibold text-muted-foreground uppercase tracking-widest mb-3">VESDA Alert Levels</p>
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3 text-xs">
{Object.entries(LEVEL_CONFIG).map(([key, cfg]) => {
const Icon = cfg.icon;
return (
<div key={key} className={cn("rounded-lg border px-3 py-2.5", cfg.bg, cfg.border)}>
<div className="flex items-center gap-1.5 mb-1">
<Icon className={cn("w-3.5 h-3.5", cfg.text)} />
<span className={cn("font-bold uppercase", cfg.text)}>{cfg.label}</span>
</div>
<p className="text-muted-foreground text-[10px]">
{key === "normal" ? "No smoke detected, system clear" :
key === "alert" ? "Trace smoke particles, monitor" :
key === "action" ? "Significant smoke, investigate now" :
"Confirmed fire, evacuate immediately"}
</p>
</div>
);
})}
</div>
</div>
</div>
);
}

View file

@ -0,0 +1,963 @@
"use client";
import { useEffect, useState, useCallback, useRef } from "react";
import { TransformWrapper, TransformComponent } from "react-zoom-pan-pinch";
import {
fetchCapacitySummary, fetchCracStatus, fetchAlarms, fetchLeakStatus,
fetchFloorLayout, saveFloorLayout,
type CapacitySummary, type CracStatus, type Alarm, type LeakSensorStatus,
} from "@/lib/api";
import { RackDetailSheet } from "@/components/dashboard/rack-detail-sheet";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Skeleton } from "@/components/ui/skeleton";
import { Sheet, SheetContent, SheetTrigger } from "@/components/ui/sheet";
import { Button } from "@/components/ui/button";
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
import {
Wind, WifiOff, Thermometer, Zap, CheckCircle2, AlertTriangle,
Droplets, Cable, Flame, Snowflake, Settings2, Plus, Trash2,
GripVertical, ChevronDown, ChevronUp,
} from "lucide-react";
import { cn } from "@/lib/utils";
import { useThresholds } from "@/lib/threshold-context";
const SITE_ID = "sg-01";
// ── Layout type ───────────────────────────────────────────────────────────────
type RowLayout = { label: string; racks: string[] };
type RoomLayout = { label: string; crac_id: string; rows: RowLayout[] };
type FloorLayout = Record<string, RoomLayout>;
const DEFAULT_LAYOUT: FloorLayout = {
"hall-a": {
label: "Hall A",
crac_id: "crac-01",
rows: [
{ label: "Row 1", racks: Array.from({ length: 20 }, (_, i) => `SG1A01.${String(i + 1).padStart(2, "0")}`) },
{ label: "Row 2", racks: Array.from({ length: 20 }, (_, i) => `SG1A02.${String(i + 1).padStart(2, "0")}`) },
],
},
"hall-b": {
label: "Hall B",
crac_id: "crac-02",
rows: [
{ label: "Row 1", racks: Array.from({ length: 20 }, (_, i) => `SG1B01.${String(i + 1).padStart(2, "0")}`) },
{ label: "Row 2", racks: Array.from({ length: 20 }, (_, i) => `SG1B02.${String(i + 1).padStart(2, "0")}`) },
],
},
};
// Derive feed from row index: even index → "A", odd → "B"
function getFeed(layout: FloorLayout, rackId: string): "A" | "B" | undefined {
for (const room of Object.values(layout)) {
for (let i = 0; i < room.rows.length; i++) {
if (room.rows[i].racks.includes(rackId)) return i % 2 === 0 ? "A" : "B";
}
}
return undefined;
}
// ── Colour helpers ────────────────────────────────────────────────────────────
function tempBg(temp: number | null, warn = 26, crit = 28) {
if (temp === null) return "oklch(0.22 0.02 265)";
if (temp >= crit + 4) return "oklch(0.55 0.22 25)";
if (temp >= crit) return "oklch(0.65 0.20 45)";
if (temp >= warn) return "oklch(0.72 0.18 84)";
if (temp >= warn - 2) return "oklch(0.78 0.14 140)";
if (temp >= warn - 4) return "oklch(0.68 0.14 162)";
return "oklch(0.60 0.15 212)";
}
function powerBg(pct: number | null) {
if (pct === null) return "oklch(0.22 0.02 265)";
if (pct >= 90) return "oklch(0.55 0.22 25)";
if (pct >= 75) return "oklch(0.65 0.20 45)";
if (pct >= 55) return "oklch(0.72 0.18 84)";
if (pct >= 35) return "oklch(0.68 0.14 162)";
return "oklch(0.60 0.15 212)";
}
type Overlay = "temp" | "power" | "alarms" | "feed" | "crac";
function alarmBg(count: number): string {
if (count === 0) return "oklch(0.22 0.02 265)";
if (count >= 3) return "oklch(0.55 0.22 25)";
if (count >= 1) return "oklch(0.65 0.20 45)";
return "oklch(0.68 0.14 162)";
}
function feedBg(feed: "A" | "B" | undefined): string {
if (feed === "A") return "oklch(0.55 0.18 255)";
if (feed === "B") return "oklch(0.60 0.18 40)";
return "oklch(0.22 0.02 265)";
}
const CRAC_ZONE_COLORS = [
"oklch(0.55 0.18 255)", // blue — zone 1
"oklch(0.60 0.18 40)", // amber — zone 2
"oklch(0.60 0.16 145)", // teal — zone 3
"oklch(0.58 0.18 310)", // purple — zone 4
];
// ── Rack tile ─────────────────────────────────────────────────────────────────
function RackTile({
rackId, temp, powerPct, alarmCount, overlay, feed, cracColor, onClick, tempWarn = 26, tempCrit = 28,
}: {
rackId: string; temp: number | null; powerPct: number | null;
alarmCount: number; overlay: Overlay; feed?: "A" | "B"; cracColor?: string; onClick: () => void;
tempWarn?: number; tempCrit?: number;
}) {
const offline = temp === null && powerPct === null;
const bg = offline ? "oklch(0.22 0.02 265)"
: overlay === "temp" ? tempBg(temp, tempWarn, tempCrit)
: overlay === "power" ? powerBg(powerPct)
: overlay === "feed" ? feedBg(feed)
: overlay === "crac" ? (cracColor ?? "oklch(0.22 0.02 265)")
: alarmBg(alarmCount);
const shortId = rackId.replace("rack-", "").toUpperCase();
const mainVal = overlay === "temp" ? (temp !== null ? `${temp}°` : null)
: overlay === "power" ? (powerPct !== null ? `${Math.round(powerPct)}%` : null)
: overlay === "feed" ? (feed ?? null)
: (alarmCount > 0 ? String(alarmCount) : null);
const subVal = overlay === "temp" ? (powerPct !== null ? `${Math.round(powerPct)}%` : null)
: overlay === "power" ? (temp !== null ? `${temp}°C` : null)
: overlay === "feed" ? (temp !== null ? `${temp}°C` : null)
: (temp !== null ? `${temp}°C` : null);
return (
<button
onClick={onClick}
title={`${rackId}${temp ?? "—"}°C · ${powerPct != null ? Math.round(powerPct) : "—"}% load · ${alarmCount} alarm${alarmCount !== 1 ? "s" : ""}`}
aria-label={`${rackId}${temp ?? "—"}°C · ${powerPct != null ? Math.round(powerPct) : "—"}% load · ${alarmCount} alarm${alarmCount !== 1 ? "s" : ""}`}
className="group relative flex flex-col items-center justify-center gap-0.5 rounded-lg cursor-pointer select-none transition-all duration-200 hover:ring-2 hover:ring-white/40 hover:scale-105 active:scale-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary"
style={{
backgroundColor: bg,
width: 76, height: 92,
backgroundImage: offline
? "repeating-linear-gradient(45deg,transparent,transparent 5px,oklch(1 0 0/5%) 5px,oklch(1 0 0/5%) 10px)"
: undefined,
}}
>
<span className="text-[9px] font-bold text-white/60 tracking-widest">{shortId}</span>
{offline ? (
<WifiOff className="w-4 h-4 text-white/30" />
) : overlay === "alarms" && alarmCount === 0 ? (
<CheckCircle2 className="w-4 h-4 text-white/40" />
) : (
<span className="text-[17px] font-bold text-white leading-none">{mainVal ?? "—"}</span>
)}
{subVal && (
<span className="text-[9px] text-white/55 opacity-0 group-hover:opacity-100 transition-opacity">{subVal}</span>
)}
{overlay === "temp" && powerPct !== null && (
<div className="absolute bottom-1.5 left-2 right-2 h-[3px] rounded-full bg-white/15 overflow-hidden">
<div className="h-full rounded-full bg-white/50" style={{ width: `${powerPct}%` }} />
</div>
)}
{overlay !== "alarms" && alarmCount > 0 && (
<div className="absolute top-1 right-1 w-3.5 h-3.5 rounded-full bg-destructive flex items-center justify-center">
<span className="text-[8px] font-bold text-white leading-none">{alarmCount}</span>
</div>
)}
</button>
);
}
// ── CRAC strip ────────────────────────────────────────────────────────────────
function CracStrip({ crac }: { crac: CracStatus | undefined }) {
const online = crac?.state === "online";
return (
<div className={cn(
"flex items-center gap-4 rounded-lg px-4 py-2.5 border text-sm",
online ? "bg-primary/5 border-primary/20" : "bg-destructive/5 border-destructive/20"
)}>
<Wind className={cn("w-4 h-4 shrink-0", online ? "text-primary" : "text-destructive")} />
<div className="flex items-center gap-2">
<span className="font-semibold text-xs">{crac?.crac_id.toUpperCase() ?? "CRAC"}</span>
<span className={cn(
"flex items-center gap-1 text-[10px] font-semibold px-2 py-0.5 rounded-full uppercase",
online ? "bg-green-500/10 text-green-400" : "bg-destructive/10 text-destructive"
)}>
{online ? <CheckCircle2 className="w-3 h-3" /> : <AlertTriangle className="w-3 h-3" />}
{online ? "Online" : "Fault"}
</span>
</div>
{crac && online && (
<div className="flex items-center gap-4 ml-2 text-xs text-muted-foreground">
<span>
<Thermometer className="w-3 h-3 inline mr-0.5 text-primary" />
Supply <strong className="text-foreground">{crac.supply_temp ?? "—"}°C</strong>
</span>
<span>
<Thermometer className="w-3 h-3 inline mr-0.5 text-orange-400" />
Return <strong className="text-foreground">{crac.return_temp ?? "—"}°C</strong>
</span>
{crac.delta !== null && (
<span>ΔT <strong className={cn(
crac.delta > 14 ? "text-destructive" : crac.delta > 11 ? "text-amber-400" : "text-green-400"
)}>+{crac.delta}°C</strong></span>
)}
{crac.fan_pct !== null && (
<span>Fan <strong className="text-foreground">{crac.fan_pct}%</strong></span>
)}
{crac.cooling_capacity_pct !== null && (
<span>Cap <strong className={cn(
(crac.cooling_capacity_pct ?? 0) >= 90 ? "text-destructive" :
(crac.cooling_capacity_pct ?? 0) >= 75 ? "text-amber-400" : "text-foreground"
)}>{crac.cooling_capacity_pct?.toFixed(0)}%</strong></span>
)}
</div>
)}
</div>
);
}
// ── Room plan ─────────────────────────────────────────────────────────────────
function RoomPlan({
roomId, layout, data, cracs, overlay, alarmsByRack, onRackClick, tempWarn = 26, tempCrit = 28,
}: {
roomId: string;
layout: FloorLayout;
data: CapacitySummary;
cracs: CracStatus[];
overlay: Overlay;
alarmsByRack: Map<string, number>;
onRackClick: (id: string) => void;
tempWarn?: number;
tempCrit?: number;
}) {
const roomLayout = layout[roomId];
if (!roomLayout) return null;
const rackMap = new Map(data.racks.map((r) => [r.rack_id, r]));
const crac = cracs.find((c) => c.crac_id === roomLayout.crac_id);
const roomRacks = data.racks.filter((r) => r.room_id === roomId);
const offlineCount = roomRacks.filter((r) => r.temp === null && r.power_kw === null).length;
const avgTemp = (() => {
const temps = roomRacks.map((r) => r.temp).filter((t): t is number => t !== null);
return temps.length ? Math.round((temps.reduce((a, b) => a + b, 0) / temps.length) * 10) / 10 : null;
})();
const totalPower = (() => {
const powers = roomRacks.map((r) => r.power_kw).filter((p): p is number => p !== null);
return powers.length ? Math.round(powers.reduce((a, b) => a + b, 0) * 10) / 10 : null;
})();
return (
<div className="space-y-4">
<div className="flex items-center gap-6 text-xs text-muted-foreground">
<span>{roomRacks.length} racks</span>
{avgTemp !== null && (
<span className="flex items-center gap-1">
<Thermometer className="w-3 h-3" />
Avg <strong className="text-foreground">{avgTemp}°C</strong>
</span>
)}
{totalPower !== null && (
<span className="flex items-center gap-1">
<Zap className="w-3 h-3" />
<strong className="text-foreground">{totalPower} kW</strong> IT load
</span>
)}
{offlineCount > 0 && (
<span className="flex items-center gap-1 text-muted-foreground/60">
<WifiOff className="w-3 h-3" />
{offlineCount} offline
</span>
)}
</div>
<TransformWrapper initialScale={1} minScale={0.4} maxScale={3} limitToBounds={false} wheel={{ disabled: true }}>
{({ zoomIn, zoomOut, resetTransform }) => (
<>
<div className="flex items-center gap-1 mb-2">
<button
onClick={() => zoomIn()}
className="px-2 py-1 rounded border border-border text-xs text-muted-foreground hover:bg-muted/50 transition-colors"
title="Zoom in"
>+</button>
<button
onClick={() => resetTransform()}
className="px-2 py-1 rounded border border-border text-xs text-muted-foreground hover:bg-muted/50 transition-colors"
title="Reset zoom"
></button>
<button
onClick={() => zoomOut()}
className="px-2 py-1 rounded border border-border text-xs text-muted-foreground hover:bg-muted/50 transition-colors"
title="Zoom out"
></button>
<span className="text-[10px] text-muted-foreground/50 ml-1">Drag to pan</span>
</div>
<TransformComponent wrapperStyle={{ width: "100%", overflow: "hidden", borderRadius: "0.75rem" }}>
<div className="rounded-xl border border-border bg-muted/10 p-5 space-y-3" style={{ minWidth: "100%" }}>
<CracStrip crac={crac} />
<div
className="flex items-center gap-2 text-[11px] font-semibold uppercase tracking-widest px-2 rounded-md py-2"
style={{ backgroundColor: "oklch(0.55 0.22 25 / 6%)", color: "oklch(0.65 0.20 45)" }}
>
<div className="flex-1 h-px" style={{ backgroundColor: "oklch(0.65 0.20 45 / 30%)" }} />
<Flame className="w-3 h-3 shrink-0" style={{ color: "oklch(0.65 0.20 45)" }} />
HOT AISLE
<Flame className="w-3 h-3 shrink-0" style={{ color: "oklch(0.65 0.20 45)" }} />
<div className="flex-1 h-px" style={{ backgroundColor: "oklch(0.65 0.20 45 / 30%)" }} />
</div>
{roomLayout.rows.map((row, rowIdx) => {
const rowCracColor = CRAC_ZONE_COLORS[rowIdx % CRAC_ZONE_COLORS.length];
return (
<div key={rowIdx}>
<div className="flex items-center gap-2">
<span className="text-[9px] text-muted-foreground/40 uppercase tracking-widest w-10 shrink-0 text-right">
{row.label}
</span>
<div className="flex gap-2 flex-wrap">
{row.racks.map((rackId) => {
const rack = rackMap.get(rackId);
return (
<RackTile
key={rackId}
rackId={rackId}
temp={rack?.temp ?? null}
powerPct={rack?.power_pct ?? null}
alarmCount={alarmsByRack.get(rackId) ?? 0}
overlay={overlay}
feed={getFeed(layout, rackId)}
cracColor={rowCracColor}
onClick={() => onRackClick(rackId)}
tempWarn={tempWarn}
tempCrit={tempCrit}
/>
);
})}
</div>
</div>
{rowIdx < roomLayout.rows.length - 1 && (
<div
className="flex items-center gap-2 text-[11px] font-semibold uppercase tracking-widest px-2 mt-2 mb-1 rounded-md py-2"
style={{ backgroundColor: "oklch(0.62 0.17 212 / 7%)", color: "oklch(0.62 0.17 212)" }}
>
<div className="flex-1 h-px border-t border-dashed" style={{ borderColor: "oklch(0.62 0.17 212 / 30%)" }} />
<Snowflake className="w-3 h-3 shrink-0" style={{ color: "oklch(0.62 0.17 212)" }} />
COLD AISLE
<Snowflake className="w-3 h-3 shrink-0" style={{ color: "oklch(0.62 0.17 212)" }} />
<div className="flex-1 h-px border-t border-dashed" style={{ borderColor: "oklch(0.62 0.17 212 / 30%)" }} />
</div>
)}
</div>
);
})}
<div
className="flex items-center gap-2 text-[11px] font-semibold uppercase tracking-widest px-2 rounded-md py-2"
style={{ backgroundColor: "oklch(0.55 0.22 25 / 6%)", color: "oklch(0.65 0.20 45)" }}
>
<div className="flex-1 h-px" style={{ backgroundColor: "oklch(0.65 0.20 45 / 30%)" }} />
<Flame className="w-3 h-3 shrink-0" style={{ color: "oklch(0.65 0.20 45)" }} />
HOT AISLE
<Flame className="w-3 h-3 shrink-0" style={{ color: "oklch(0.65 0.20 45)" }} />
<div className="flex-1 h-px" style={{ backgroundColor: "oklch(0.65 0.20 45 / 30%)" }} />
</div>
</div>
</TransformComponent>
</>
)}
</TransformWrapper>
</div>
);
}
// ── Leak sensor panel ─────────────────────────────────────────────────────────
function LeakSensorPanel({ sensors }: { sensors: LeakSensorStatus[] }) {
if (sensors.length === 0) return null;
const active = sensors.filter((s) => s.state === "detected");
const byZone = sensors.reduce<Record<string, LeakSensorStatus[]>>((acc, s) => {
const zone = s.floor_zone ?? "unknown";
(acc[zone] ??= []).push(s);
return acc;
}, {});
return (
<Card className={cn("border", active.length > 0 && "border-destructive/50")}>
<CardHeader className="pb-2">
<div className="flex items-center justify-between">
<CardTitle className="text-sm font-semibold flex items-center gap-2">
<Droplets className={cn("w-4 h-4", active.length > 0 ? "text-destructive" : "text-blue-400")} />
Leak Sensor Status
</CardTitle>
<span className={cn(
"text-[10px] font-semibold px-2.5 py-0.5 rounded-full uppercase",
active.length > 0 ? "bg-destructive/10 text-destructive" : "bg-green-500/10 text-green-400",
)}>
{active.length > 0 ? `${active.length} leak${active.length > 1 ? "s" : ""} detected` : "All clear"}
</span>
</div>
</CardHeader>
<CardContent>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3">
{Object.entries(byZone).map(([zone, zoneSensors]) => (
<div key={zone} className="rounded-lg border border-border/50 bg-muted/10 p-3 space-y-2">
<p className="text-[10px] font-semibold uppercase tracking-wider text-muted-foreground">{zone}</p>
{zoneSensors.map((s) => {
const detected = s.state === "detected";
return (
<div key={s.sensor_id} className="flex items-start justify-between gap-2">
<div className="min-w-0">
<div className="flex items-center gap-1.5">
<div className={cn(
"w-2 h-2 rounded-full shrink-0",
detected ? "bg-destructive animate-pulse" :
s.state === "unknown" ? "bg-muted-foreground/30" : "bg-green-500",
)} />
<span className="text-xs font-medium truncate">{s.sensor_id}</span>
</div>
<p className="text-[9px] text-muted-foreground/60 mt-0.5 truncate">
{[
s.under_floor ? "Under floor" : "Surface mount",
s.near_crac ? "near CRAC" : null,
s.room_id ?? null,
].filter(Boolean).join(" · ")}
</p>
</div>
<span className={cn(
"text-[10px] font-semibold shrink-0",
detected ? "text-destructive" :
s.state === "unknown" ? "text-muted-foreground/50" : "text-green-400",
)}>
{detected ? "LEAK" : s.state === "unknown" ? "unknown" : "clear"}
</span>
</div>
);
})}
</div>
))}
</div>
</CardContent>
</Card>
);
}
// ── Layout editor ─────────────────────────────────────────────────────────────
function LayoutEditor({
layout, onSave, saving,
}: {
layout: FloorLayout;
onSave: (l: FloorLayout) => void;
saving?: boolean;
}) {
const [draft, setDraft] = useState<FloorLayout>(() => JSON.parse(JSON.stringify(layout)));
const [newRoomId, setNewRoomId] = useState("");
const [newRoomLabel, setNewRoomLabel] = useState("");
const [newRoomCrac, setNewRoomCrac] = useState("");
const [expandedRoom, setExpandedRoom] = useState<string | null>(Object.keys(draft)[0] ?? null);
function updateRoom(roomId: string, patch: Partial<RoomLayout>) {
setDraft(d => ({ ...d, [roomId]: { ...d[roomId], ...patch } }));
}
function deleteRoom(roomId: string) {
setDraft(d => { const n = { ...d }; delete n[roomId]; return n; });
if (expandedRoom === roomId) setExpandedRoom(null);
}
function addRoom() {
const id = newRoomId.trim().toLowerCase().replace(/\s+/g, "-");
if (!id || !newRoomLabel.trim() || draft[id]) return;
setDraft(d => ({
...d,
[id]: { label: newRoomLabel.trim(), crac_id: newRoomCrac.trim(), rows: [] },
}));
setNewRoomId(""); setNewRoomLabel(""); setNewRoomCrac("");
setExpandedRoom(id);
}
function addRow(roomId: string) {
const room = draft[roomId];
const label = `Row ${room.rows.length + 1}`;
updateRoom(roomId, { rows: [...room.rows, { label, racks: [] }] });
}
function deleteRow(roomId: string, rowIdx: number) {
const rows = draft[roomId].rows.filter((_, i) => i !== rowIdx);
updateRoom(roomId, { rows });
}
function updateRowLabel(roomId: string, rowIdx: number, label: string) {
const rows = draft[roomId].rows.map((r, i) => i === rowIdx ? { ...r, label } : r);
updateRoom(roomId, { rows });
}
function addRack(roomId: string, rowIdx: number, rackId: string) {
const id = rackId.trim();
if (!id) return;
const rows = draft[roomId].rows.map((r, i) =>
i === rowIdx ? { ...r, racks: [...r.racks, id] } : r
);
updateRoom(roomId, { rows });
}
function removeRack(roomId: string, rowIdx: number, rackIdx: number) {
const rows = draft[roomId].rows.map((r, i) =>
i === rowIdx ? { ...r, racks: r.racks.filter((_, j) => j !== rackIdx) } : r
);
updateRoom(roomId, { rows });
}
function moveRow(roomId: string, rowIdx: number, dir: -1 | 1) {
const rows = [...draft[roomId].rows];
const target = rowIdx + dir;
if (target < 0 || target >= rows.length) return;
[rows[rowIdx], rows[target]] = [rows[target], rows[rowIdx]];
updateRoom(roomId, { rows });
}
return (
<div className="flex flex-col h-full">
<div className="px-6 pt-6 pb-4 border-b border-border shrink-0">
<h2 className="text-base font-semibold flex items-center gap-2">
<Settings2 className="w-4 h-4 text-primary" /> Floor Layout Editor
</h2>
<p className="text-xs text-muted-foreground mt-1">
Configure rooms, rows, and rack positions. Changes are saved for all users.
</p>
</div>
<div className="flex-1 overflow-y-auto px-6 py-4 space-y-4">
{Object.entries(draft).map(([roomId, room]) => (
<div key={roomId} className="rounded-xl border border-border bg-muted/10 overflow-hidden">
{/* Room header */}
<div className="flex items-center gap-2 px-3 py-2.5 bg-muted/20 border-b border-border/60">
<button
onClick={() => setExpandedRoom(expandedRoom === roomId ? null : roomId)}
className="flex items-center gap-2 flex-1 min-w-0 text-left focus-visible:outline-none"
>
{expandedRoom === roomId
? <ChevronUp className="w-3.5 h-3.5 text-muted-foreground shrink-0" />
: <ChevronDown className="w-3.5 h-3.5 text-muted-foreground shrink-0" />}
<span className="text-sm font-semibold truncate">{room.label}</span>
<span className="text-[10px] text-muted-foreground font-mono">{roomId}</span>
<span className="text-[10px] text-muted-foreground ml-1">
· {room.rows.reduce((s, r) => s + r.racks.length, 0)} racks
</span>
</button>
<button
onClick={() => deleteRoom(roomId)}
className="text-muted-foreground hover:text-destructive transition-colors shrink-0 p-1"
aria-label={`Delete ${room.label}`}
>
<Trash2 className="w-3.5 h-3.5" />
</button>
</div>
{expandedRoom === roomId && (
<div className="p-3 space-y-3">
{/* Room fields */}
<div className="grid grid-cols-2 gap-2">
<div className="space-y-1">
<label className="text-[10px] text-muted-foreground font-medium">Room Label</label>
<input
value={room.label}
onChange={e => updateRoom(roomId, { label: e.target.value })}
className="w-full h-7 rounded border border-border bg-background px-2 text-xs focus:outline-none focus:ring-1 focus:ring-primary"
/>
</div>
<div className="space-y-1">
<label className="text-[10px] text-muted-foreground font-medium">CRAC ID</label>
<input
value={room.crac_id}
onChange={e => updateRoom(roomId, { crac_id: e.target.value })}
placeholder="e.g. crac-01"
className="w-full h-7 rounded border border-border bg-background px-2 text-xs focus:outline-none focus:ring-1 focus:ring-primary"
/>
</div>
</div>
{/* Rows */}
<div className="space-y-2">
<div className="flex items-center justify-between">
<p className="text-[10px] font-semibold text-muted-foreground uppercase tracking-wider">Rows</p>
<button
onClick={() => addRow(roomId)}
className="flex items-center gap-1 text-[10px] text-primary hover:text-primary/80 transition-colors"
>
<Plus className="w-3 h-3" /> Add Row
</button>
</div>
{room.rows.length === 0 && (
<p className="text-[10px] text-muted-foreground/50 py-2 text-center">No rows click Add Row</p>
)}
{room.rows.map((row, rowIdx) => (
<div key={rowIdx} className="rounded-lg border border-border/60 bg-background/50 p-2.5 space-y-2">
<div className="flex items-center gap-1.5">
<GripVertical className="w-3 h-3 text-muted-foreground/30 shrink-0" />
<input
value={row.label}
onChange={e => updateRowLabel(roomId, rowIdx, e.target.value)}
className="flex-1 h-6 rounded border border-border bg-background px-2 text-xs focus:outline-none focus:ring-1 focus:ring-primary"
/>
<button onClick={() => moveRow(roomId, rowIdx, -1)} disabled={rowIdx === 0}
className="p-0.5 text-muted-foreground hover:text-foreground disabled:opacity-20 transition-colors">
<ChevronUp className="w-3 h-3" />
</button>
<button onClick={() => moveRow(roomId, rowIdx, 1)} disabled={rowIdx === room.rows.length - 1}
className="p-0.5 text-muted-foreground hover:text-foreground disabled:opacity-20 transition-colors">
<ChevronDown className="w-3 h-3" />
</button>
<button onClick={() => deleteRow(roomId, rowIdx)}
className="p-0.5 text-muted-foreground hover:text-destructive transition-colors">
<Trash2 className="w-3 h-3" />
</button>
</div>
{/* Racks in this row */}
<div className="flex flex-wrap gap-1">
{row.racks.map((rackId, rackIdx) => (
<span key={rackIdx} className="inline-flex items-center gap-0.5 bg-muted rounded px-1.5 py-0.5 text-[10px] font-mono">
{rackId}
<button
onClick={() => removeRack(roomId, rowIdx, rackIdx)}
className="text-muted-foreground hover:text-destructive transition-colors ml-0.5"
aria-label={`Remove ${rackId}`}
>
×
</button>
</span>
))}
<RackAdder onAdd={(id) => addRack(roomId, rowIdx, id)} />
</div>
<p className="text-[9px] text-muted-foreground/40">
Feed: {rowIdx % 2 === 0 ? "A (even rows)" : "B (odd rows)"} · {row.racks.length} rack{row.racks.length !== 1 ? "s" : ""}
</p>
</div>
))}
</div>
</div>
)}
</div>
))}
{/* Add new room */}
<div className="rounded-xl border border-dashed border-border p-3 space-y-2">
<p className="text-[10px] font-semibold text-muted-foreground uppercase tracking-wider">Add New Room</p>
<div className="grid grid-cols-3 gap-2">
<input
value={newRoomId}
onChange={e => setNewRoomId(e.target.value)}
placeholder="room-id (e.g. hall-c)"
className="h-7 rounded border border-border bg-background px-2 text-xs focus:outline-none focus:ring-1 focus:ring-primary"
/>
<input
value={newRoomLabel}
onChange={e => setNewRoomLabel(e.target.value)}
placeholder="Label (e.g. Hall C)"
className="h-7 rounded border border-border bg-background px-2 text-xs focus:outline-none focus:ring-1 focus:ring-primary"
/>
<input
value={newRoomCrac}
onChange={e => setNewRoomCrac(e.target.value)}
placeholder="CRAC ID (e.g. crac-03)"
className="h-7 rounded border border-border bg-background px-2 text-xs focus:outline-none focus:ring-1 focus:ring-primary"
/>
</div>
<button
onClick={addRoom}
disabled={!newRoomId.trim() || !newRoomLabel.trim()}
className="flex items-center gap-1.5 text-xs text-primary hover:text-primary/80 disabled:opacity-40 disabled:cursor-not-allowed transition-colors"
>
<Plus className="w-3.5 h-3.5" /> Add Room
</button>
</div>
</div>
{/* Footer actions */}
<div className="px-6 py-4 border-t border-border shrink-0 flex items-center justify-between gap-3">
<button
onClick={() => { setDraft(JSON.parse(JSON.stringify(DEFAULT_LAYOUT))); setExpandedRoom(Object.keys(DEFAULT_LAYOUT)[0]); }}
className="text-xs text-muted-foreground hover:text-foreground transition-colors"
>
Reset to default
</button>
<Button size="sm" onClick={() => onSave(draft)} disabled={saving}>
{saving ? "Saving…" : "Save Layout"}
</Button>
</div>
</div>
);
}
// Small inline input to add a rack ID to a row
function RackAdder({ onAdd }: { onAdd: (id: string) => void }) {
const [val, setVal] = useState("");
function submit() {
if (val.trim()) { onAdd(val.trim()); setVal(""); }
}
return (
<span className="inline-flex items-center gap-0.5">
<input
value={val}
onChange={e => setVal(e.target.value)}
onKeyDown={e => e.key === "Enter" && submit()}
placeholder="rack-id"
className="h-5 w-20 rounded border border-dashed border-border bg-background px-1.5 text-[10px] font-mono focus:outline-none focus:ring-1 focus:ring-primary"
/>
<button
onClick={submit}
className="text-primary hover:text-primary/70 transition-colors"
aria-label="Add rack"
>
<Plus className="w-3 h-3" />
</button>
</span>
);
}
// ── Page ──────────────────────────────────────────────────────────────────────
export default function FloorMapPage() {
const { thresholds } = useThresholds();
const [layout, setLayout] = useState<FloorLayout>(DEFAULT_LAYOUT);
const [data, setData] = useState<CapacitySummary | null>(null);
const [cracs, setCracs] = useState<CracStatus[]>([]);
const [alarms, setAlarms] = useState<Alarm[]>([]);
const [leakSensors, setLeakSensors] = useState<LeakSensorStatus[]>([]);
const [loading, setLoading] = useState(true);
const [overlay, setOverlay] = useState<Overlay>("temp");
const [activeRoom, setActiveRoom] = useState<string>("");
const [selectedRack, setSelectedRack] = useState<string | null>(null);
const [editorOpen, setEditorOpen] = useState(false);
const [layoutSaving, setLayoutSaving] = useState(false);
// Load layout from backend on mount
useEffect(() => {
fetchFloorLayout(SITE_ID)
.then((remote) => {
const parsed = remote as FloorLayout;
setLayout(parsed);
setActiveRoom(Object.keys(parsed)[0] ?? "");
})
.catch(() => {
// No saved layout yet — use default
setActiveRoom(Object.keys(DEFAULT_LAYOUT)[0] ?? "");
});
}, []);
const alarmsByRack = new Map<string, number>();
for (const a of alarms) {
if (a.rack_id) alarmsByRack.set(a.rack_id, (alarmsByRack.get(a.rack_id) ?? 0) + 1);
}
const load = useCallback(async () => {
try {
const [d, c, a, ls] = await Promise.all([
fetchCapacitySummary(SITE_ID),
fetchCracStatus(SITE_ID),
fetchAlarms(SITE_ID, "active", 200),
fetchLeakStatus(SITE_ID).catch(() => [] as LeakSensorStatus[]),
]);
setData(d);
setCracs(c);
setAlarms(a);
setLeakSensors(ls);
} catch { /* keep stale */ }
finally { setLoading(false); }
}, []);
useEffect(() => {
load();
const id = setInterval(load, 30_000);
return () => clearInterval(id);
}, [load]);
async function handleSaveLayout(newLayout: FloorLayout) {
setLayoutSaving(true);
try {
await saveFloorLayout(SITE_ID, newLayout as unknown as Record<string, unknown>);
setLayout(newLayout);
if (!newLayout[activeRoom]) setActiveRoom(Object.keys(newLayout)[0] ?? "");
setEditorOpen(false);
} catch {
// save failed — keep editor open so user can retry
} finally {
setLayoutSaving(false);
}
}
const roomIds = Object.keys(layout);
return (
<div className="p-6 space-y-6">
<div className="flex items-start justify-between gap-4 flex-wrap">
<div>
<h1 className="text-xl font-semibold">Floor Map</h1>
<p className="text-sm text-muted-foreground">Singapore DC01 live rack layout · refreshes every 30s</p>
</div>
<div className="flex items-center gap-2">
{/* Overlay selector */}
<div className="flex items-center gap-0.5 rounded-lg bg-muted p-1">
{([
{ val: "temp" as Overlay, icon: Thermometer, label: "Temperature" },
{ val: "power" as Overlay, icon: Zap, label: "Power %" },
{ val: "alarms" as Overlay, icon: AlertTriangle, label: "Alarms" },
{ val: "feed" as Overlay, icon: Cable, label: "Power Feed" },
{ val: "crac" as Overlay, icon: Wind, label: "CRAC Coverage" },
]).map(({ val, icon: Icon, label }) => (
<button
key={val}
onClick={() => setOverlay(val)}
aria-label={label}
aria-pressed={overlay === val}
className={cn(
"flex items-center gap-1.5 px-3 py-1.5 rounded-md text-xs font-medium transition-colors",
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary",
overlay === val ? "bg-primary text-primary-foreground" : "text-muted-foreground hover:text-foreground"
)}
>
<Icon className="w-3.5 h-3.5" /> {label}
</button>
))}
</div>
{/* Layout editor trigger */}
<Sheet open={editorOpen} onOpenChange={setEditorOpen}>
<SheetTrigger asChild>
<Button variant="outline" size="sm" className="flex items-center gap-1.5">
<Settings2 className="w-3.5 h-3.5" /> Edit Layout
</Button>
</SheetTrigger>
<SheetContent side="right" className="p-0 w-[420px] sm:w-[480px] flex flex-col">
<LayoutEditor layout={layout} onSave={handleSaveLayout} saving={layoutSaving} />
</SheetContent>
</Sheet>
</div>
</div>
{loading ? (
<Skeleton className="h-96 w-full" />
) : !data ? (
<div className="flex items-center justify-center h-64 text-sm text-muted-foreground">
Unable to load floor map data.
</div>
) : (
<>
<RackDetailSheet siteId={SITE_ID} rackId={selectedRack} onClose={() => setSelectedRack(null)} />
<Card>
<CardHeader className="pb-3">
<div className="flex items-center justify-between">
<CardTitle className="text-sm font-semibold">Room View</CardTitle>
{roomIds.length > 0 && (
<Tabs value={activeRoom} onValueChange={setActiveRoom}>
<TabsList className="h-7">
{roomIds.map((id) => (
<TabsTrigger key={id} value={id} className="text-xs px-3 py-0.5">
{layout[id].label}
</TabsTrigger>
))}
</TabsList>
</Tabs>
)}
</div>
</CardHeader>
<CardContent>
{activeRoom && layout[activeRoom] ? (
<RoomPlan
roomId={activeRoom}
layout={layout}
data={data}
cracs={cracs}
overlay={overlay}
alarmsByRack={alarmsByRack}
onRackClick={setSelectedRack}
tempWarn={thresholds.temp.warn}
tempCrit={thresholds.temp.critical}
/>
) : (
<div className="flex flex-col items-center justify-center h-48 gap-3 text-muted-foreground">
<Settings2 className="w-8 h-8 opacity-30" />
<p className="text-sm">No rooms configured</p>
<p className="text-xs">Use Edit Layout to add rooms and racks</p>
</div>
)}
</CardContent>
</Card>
<LeakSensorPanel sensors={leakSensors} />
{/* Legend */}
<div className="flex items-center gap-3 text-[10px] text-muted-foreground flex-wrap">
{overlay === "alarms" ? (
<>
<span className="font-medium">Alarm count:</span>
<div className="flex items-center gap-1">
{([
{ c: "oklch(0.22 0.02 265)", l: "0" },
{ c: "oklch(0.65 0.20 45)", l: "12" },
{ c: "oklch(0.55 0.22 25)", l: "3+" },
]).map(({ c, l }) => (
<span key={l} className="flex items-center gap-0.5">
<span className="w-6 h-4 rounded-sm inline-block" style={{ backgroundColor: c }} />
<span>{l}</span>
</span>
))}
</div>
</>
) : overlay === "feed" ? (
<>
<span className="font-medium">Power feed:</span>
<div className="flex items-center gap-2">
<span className="flex items-center gap-1">
<span className="w-6 h-4 rounded-sm inline-block" style={{ backgroundColor: "oklch(0.55 0.18 255)" }} />
<span>Feed A (even rows)</span>
</span>
<span className="flex items-center gap-1">
<span className="w-6 h-4 rounded-sm inline-block" style={{ backgroundColor: "oklch(0.60 0.18 40)" }} />
<span>Feed B (odd rows)</span>
</span>
</div>
</>
) : overlay === "crac" ? (
<>
<span className="font-medium">CRAC thermal zones:</span>
<div className="flex items-center gap-2 flex-wrap">
{CRAC_ZONE_COLORS.slice(0, layout[activeRoom]?.rows.length ?? 2).map((c, i) => (
<span key={i} className="flex items-center gap-1">
<span className="w-6 h-4 rounded-sm inline-block" style={{ backgroundColor: c }} />
<span>Zone {i + 1}</span>
</span>
))}
</div>
</>
) : (
<>
<span className="font-medium">{overlay === "temp" ? "Temperature:" : "Power utilisation:"}</span>
<div className="flex items-center gap-1">
{overlay === "temp"
? (["oklch(0.60 0.15 212)","oklch(0.68 0.14 162)","oklch(0.78 0.14 140)","oklch(0.72 0.18 84)","oklch(0.65 0.20 45)","oklch(0.55 0.22 25)"] as string[]).map((c, i) => (
<span key={i} className="w-7 h-4 rounded-sm inline-block" style={{ backgroundColor: c }} />
))
: (["oklch(0.60 0.15 212)","oklch(0.68 0.14 162)","oklch(0.72 0.18 84)","oklch(0.65 0.20 45)","oklch(0.55 0.22 25)"] as string[]).map((c, i) => (
<span key={i} className="w-7 h-4 rounded-sm inline-block" style={{ backgroundColor: c }} />
))}
<span className="ml-1">{overlay === "temp" ? "Cool → Hot" : "Low → High"}</span>
</div>
{overlay === "temp" && <span className="ml-auto">Warn: {thresholds.temp.warn}°C &nbsp;|&nbsp; Critical: {thresholds.temp.critical}°C</span>}
{overlay === "power" && <span className="ml-auto">Warn: 75% &nbsp;|&nbsp; Critical: 90%</span>}
</>
)}
<span className="text-muted-foreground/50">Click any rack to drill down</span>
</div>
</>
)}
</div>
);
}

View file

@ -0,0 +1,412 @@
"use client";
import { useEffect, useState, useCallback } from "react";
import { toast } from "sonner";
import {
fetchGeneratorStatus, fetchAtsStatus, fetchPhaseBreakdown,
type GeneratorStatus, type AtsStatus, type RoomPhase,
} from "@/lib/api";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Skeleton } from "@/components/ui/skeleton";
import {
Fuel, Zap, Activity, RefreshCw, CheckCircle2, AlertTriangle,
ArrowLeftRight, Gauge, Thermometer, Battery,
} from "lucide-react";
import { cn } from "@/lib/utils";
import { GeneratorDetailSheet } from "@/components/dashboard/generator-detail-sheet";
const SITE_ID = "sg-01";
const STATE_COLOR: Record<string, string> = {
running: "bg-green-500/10 text-green-400",
standby: "bg-blue-500/10 text-blue-400",
test: "bg-amber-500/10 text-amber-400",
fault: "bg-destructive/10 text-destructive",
unknown: "bg-muted/30 text-muted-foreground",
};
const ATS_FEED_COLOR: Record<string, string> = {
"utility-a": "bg-blue-500/10 text-blue-400",
"utility-b": "bg-sky-500/10 text-sky-400",
"generator": "bg-amber-500/10 text-amber-400",
};
function FillBar({
value, max, color = "#22c55e", warn, crit,
}: {
value: number | null; max: number; color?: string; warn?: number; crit?: number;
}) {
const pct = value != null ? Math.min(100, (value / max) * 100) : 0;
const bg = crit && value != null && value >= crit ? "#ef4444"
: warn && value != null && value >= warn ? "#f59e0b"
: color;
return (
<div className="rounded-full bg-muted overflow-hidden h-2 w-full">
<div className="h-full rounded-full transition-all duration-500" style={{ width: `${pct}%`, backgroundColor: bg }} />
</div>
);
}
function StatRow({ label, value, warn }: { label: string; value: string; warn?: boolean }) {
return (
<div className="flex justify-between items-baseline text-xs">
<span className="text-muted-foreground">{label}</span>
<span className={cn("font-mono font-medium", warn && "text-amber-400")}>{value}</span>
</div>
);
}
function GeneratorCard({ gen, onClick }: { gen: GeneratorStatus; onClick: () => void }) {
const fuelLow = (gen.fuel_pct ?? 100) < 25;
const fuelCrit = (gen.fuel_pct ?? 100) < 10;
const isFault = gen.state === "fault";
const isRun = gen.state === "running" || gen.state === "test";
return (
<Card
className={cn("border cursor-pointer hover:border-primary/40 transition-colors", isFault && "border-destructive/50")}
onClick={onClick}
>
<CardHeader className="pb-3">
<div className="flex items-center justify-between">
<CardTitle className="text-base font-semibold flex items-center gap-2">
<Activity className={cn("w-4 h-4", isRun ? "text-green-400" : "text-muted-foreground")} />
{gen.gen_id.toUpperCase()}
</CardTitle>
<div className="flex items-center gap-2">
<span className={cn(
"text-[10px] font-semibold px-2.5 py-0.5 rounded-full uppercase tracking-wide",
STATE_COLOR[gen.state] ?? STATE_COLOR.unknown,
)}>
{gen.state}
</span>
</div>
</div>
</CardHeader>
<CardContent className="space-y-4">
{/* Fuel level */}
<div>
<div className="flex items-center justify-between mb-1.5">
<span className="flex items-center gap-1.5 text-[10px] text-muted-foreground uppercase tracking-wide">
<Fuel className="w-3 h-3" /> Fuel Level
</span>
<span className={cn(
"text-sm font-bold tabular-nums",
fuelCrit ? "text-destructive" : fuelLow ? "text-amber-400" : "text-green-400",
)}>
{gen.fuel_pct != null ? `${gen.fuel_pct.toFixed(1)}%` : "—"}
</span>
</div>
<FillBar
value={gen.fuel_pct}
max={100}
color="#22c55e"
warn={25}
crit={10}
/>
{gen.fuel_litres != null && (
<p className="text-[10px] text-muted-foreground text-right mt-1">
{gen.fuel_litres.toFixed(0)} L remaining
</p>
)}
{gen.fuel_litres != null && gen.load_kw != null && gen.load_kw > 0 && (() => {
const runtimeH = gen.fuel_litres / (gen.load_kw * 0.27);
const hours = Math.floor(runtimeH);
const mins = Math.round((runtimeH - hours) * 60);
const cls = runtimeH < 4 ? "text-destructive" : runtimeH < 12 ? "text-amber-400" : "text-green-400";
return (
<p className={cn("text-[10px] text-right mt-0.5", cls)}>
Est. runtime: <strong>{hours}h {mins}m</strong>
</p>
);
})()}
</div>
{/* Load */}
{gen.load_kw != null && (
<div>
<div className="flex items-center justify-between mb-1.5">
<span className="flex items-center gap-1.5 text-[10px] text-muted-foreground uppercase tracking-wide">
<Zap className="w-3 h-3" /> Load
</span>
<span className="text-sm font-bold tabular-nums text-foreground">
{gen.load_kw.toFixed(1)} kW
{gen.load_pct != null && (
<span className="text-muted-foreground font-normal ml-1">({gen.load_pct.toFixed(0)}%)</span>
)}
</span>
</div>
<FillBar value={gen.load_pct} max={100} color="#60a5fa" warn={75} crit={90} />
</div>
)}
{/* Engine stats */}
<div className="rounded-lg bg-muted/20 px-3 py-3 space-y-2">
<p className="text-[10px] text-muted-foreground uppercase tracking-widest font-semibold mb-1">Engine</p>
{gen.voltage_v != null && <StatRow label="Output voltage" value={`${gen.voltage_v.toFixed(0)} V`} />}
{gen.frequency_hz != null && <StatRow label="Frequency" value={`${gen.frequency_hz.toFixed(1)} Hz`} warn={Math.abs(gen.frequency_hz - 50) > 0.5} />}
{gen.run_hours != null && <StatRow label="Run hours" value={`${gen.run_hours.toFixed(0)} h`} />}
{gen.oil_pressure_bar != null && <StatRow label="Oil pressure" value={`${gen.oil_pressure_bar.toFixed(1)} bar`} warn={gen.oil_pressure_bar < 2.0} />}
{gen.coolant_temp_c != null && (
<div className="flex justify-between items-baseline text-xs">
<span className="text-muted-foreground flex items-center gap-1">
<Thermometer className="w-3 h-3 inline" /> Coolant temp
</span>
<span className={cn("font-mono font-medium", gen.coolant_temp_c > 95 ? "text-destructive" : gen.coolant_temp_c > 85 ? "text-amber-400" : "")}>
{gen.coolant_temp_c.toFixed(1)}°C
</span>
</div>
)}
{gen.battery_v != null && (
<div className="flex justify-between items-baseline text-xs">
<span className="text-muted-foreground flex items-center gap-1">
<Battery className="w-3 h-3 inline" /> Battery
</span>
<span className={cn("font-mono font-medium", gen.battery_v < 11.5 ? "text-destructive" : gen.battery_v < 12.0 ? "text-amber-400" : "text-green-400")}>
{gen.battery_v.toFixed(1)} V
</span>
</div>
)}
</div>
</CardContent>
</Card>
);
}
function AtsCard({ ats }: { ats: AtsStatus }) {
const feedColor = ATS_FEED_COLOR[ats.active_feed] ?? "bg-muted/30 text-muted-foreground";
const isGen = ats.active_feed === "generator";
return (
<Card className={cn("border", isGen && "border-amber-500/40")}>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-semibold flex items-center gap-2">
<ArrowLeftRight className="w-4 h-4 text-primary" />
{ats.ats_id.toUpperCase()} ATS Transfer Switch
</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
<div className="flex items-center gap-3">
<span className="text-xs text-muted-foreground">Active feed</span>
<span className={cn("text-xs font-bold px-2.5 py-1 rounded-full uppercase tracking-wide", feedColor)}>
{ats.active_feed}
</span>
{isGen && <span className="text-[10px] text-amber-400">Running on generator power</span>}
</div>
<div className="grid grid-cols-3 gap-3 text-xs">
{[
{ label: "Utility A", v: ats.utility_a_v },
{ label: "Utility B", v: ats.utility_b_v },
{ label: "Generator", v: ats.generator_v },
].map(({ label, v }) => (
<div key={label} className="rounded-lg bg-muted/20 px-2 py-2 text-center">
<p className="text-[10px] text-muted-foreground mb-0.5">{label}</p>
<p className="font-bold text-foreground tabular-nums">{v != null ? `${v.toFixed(0)} V` : "—"}</p>
</div>
))}
</div>
<div className="grid grid-cols-2 gap-3 text-xs">
{ats.transfer_count != null && (
<div className="rounded-lg bg-muted/20 px-3 py-2">
<p className="text-[10px] text-muted-foreground mb-0.5">Transfers (total)</p>
<p className="font-bold">{ats.transfer_count}</p>
</div>
)}
{ats.last_transfer_ms != null && (
<div className="rounded-lg bg-muted/20 px-3 py-2">
<p className="text-[10px] text-muted-foreground mb-0.5">Last transfer time</p>
<p className="font-bold">{ats.last_transfer_ms} ms</p>
</div>
)}
</div>
</CardContent>
</Card>
);
}
function PhaseImbalancePanel({ rooms }: { rooms: RoomPhase[] }) {
const allRacks = rooms.flatMap((r) => r.racks);
const flagged = allRacks
.filter((r) => (r.imbalance_pct ?? 0) >= 5)
.sort((a, b) => (b.imbalance_pct ?? 0) - (a.imbalance_pct ?? 0));
if (flagged.length === 0) return (
<div className="rounded-lg border border-border bg-muted/10 px-4 py-3 flex items-center gap-2 text-sm">
<CheckCircle2 className="w-4 h-4 text-green-400" />
<span className="text-green-400">No PDU phase imbalance detected across all racks</span>
</div>
);
return (
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-semibold flex items-center gap-2">
<AlertTriangle className="w-4 h-4 text-amber-400" />
PDU Phase Imbalance
</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-2">
{flagged.map((rack) => {
const crit = (rack.imbalance_pct ?? 0) >= 15;
return (
<div key={rack.rack_id} className={cn(
"rounded-lg px-3 py-2 grid grid-cols-5 gap-2 text-xs items-center",
crit ? "bg-destructive/10" : "bg-amber-500/10",
)}>
<span className="font-medium col-span-1">{rack.rack_id.toUpperCase()}</span>
<span className={cn("text-center", crit ? "text-destructive" : "text-amber-400")}>
{rack.imbalance_pct?.toFixed(1)}% imbalance
</span>
<span className="text-muted-foreground text-center">A: {rack.phase_a_kw?.toFixed(2) ?? "—"} kW</span>
<span className="text-muted-foreground text-center">B: {rack.phase_b_kw?.toFixed(2) ?? "—"} kW</span>
<span className="text-muted-foreground text-center">C: {rack.phase_c_kw?.toFixed(2) ?? "—"} kW</span>
</div>
);
})}
</div>
</CardContent>
</Card>
);
}
export default function GeneratorPage() {
const [generators, setGenerators] = useState<GeneratorStatus[]>([]);
const [atsUnits, setAtsUnits] = useState<AtsStatus[]>([]);
const [phases, setPhases] = useState<RoomPhase[]>([]);
const [loading, setLoading] = useState(true);
const [selectedGen, setSelectedGen] = useState<string | null>(null);
const load = useCallback(async () => {
try {
const [g, a, p] = await Promise.all([
fetchGeneratorStatus(SITE_ID),
fetchAtsStatus(SITE_ID).catch(() => [] as AtsStatus[]),
fetchPhaseBreakdown(SITE_ID).catch(() => [] as RoomPhase[]),
]);
setGenerators(g);
setAtsUnits(a);
setPhases(p);
} catch { toast.error("Failed to load generator data"); }
finally { setLoading(false); }
}, []);
useEffect(() => {
load();
const id = setInterval(load, 15_000);
return () => clearInterval(id);
}, [load]);
const anyFault = generators.some((g) => g.state === "fault");
const anyRun = generators.some((g) => g.state === "running" || g.state === "test");
const onGen = atsUnits.some((a) => a.active_feed === "generator");
return (
<div className="p-6 space-y-6">
{/* Header */}
<div className="flex items-start justify-between gap-4 flex-wrap">
<div>
<h1 className="text-xl font-semibold">Generator &amp; Power Path</h1>
<p className="text-sm text-muted-foreground">Singapore DC01 backup power systems · refreshes every 15s</p>
</div>
<div className="flex items-center gap-3">
{!loading && (
<span className={cn(
"flex items-center gap-1.5 text-xs font-semibold px-3 py-1.5 rounded-full",
anyFault ? "bg-destructive/10 text-destructive" :
onGen ? "bg-amber-500/10 text-amber-400" :
anyRun ? "bg-green-500/10 text-green-400" :
"bg-blue-500/10 text-blue-400",
)}>
{anyFault ? <><AlertTriangle className="w-3.5 h-3.5" /> Generator fault</> :
onGen ? <><AlertTriangle className="w-3.5 h-3.5" /> Running on generator</> :
anyRun ? <><CheckCircle2 className="w-3.5 h-3.5" /> Generator running (test)</> :
<><CheckCircle2 className="w-3.5 h-3.5" /> Utility power all standby</>}
</span>
)}
<button
onClick={load}
className="flex items-center gap-1.5 text-xs text-muted-foreground hover:text-foreground transition-colors"
>
<RefreshCw className="w-3.5 h-3.5" /> Refresh
</button>
</div>
</div>
{/* Site power status bar */}
{!loading && atsUnits.length > 0 && (
<div className={cn(
"rounded-xl border px-5 py-3 flex items-center gap-6 text-sm flex-wrap",
onGen ? "border-amber-500/30 bg-amber-500/5" : "border-border bg-muted/10",
)}>
<Gauge className={cn("w-5 h-5 shrink-0", onGen ? "text-amber-400" : "text-primary")} />
<div>
<span className="text-muted-foreground">Power path: </span>
<strong className="text-foreground capitalize">{onGen ? "Generator (utility lost)" : "Utility mains"}</strong>
</div>
<div>
<span className="text-muted-foreground">Generators: </span>
<strong>{generators.length} total</strong>
<span className="text-muted-foreground ml-1">
({generators.filter((g) => g.state === "standby").length} standby,{" "}
{generators.filter((g) => g.state === "running").length} running,{" "}
{generators.filter((g) => g.state === "fault").length} fault)
</span>
</div>
</div>
)}
{/* Generators */}
<h2 className="text-xs font-semibold uppercase tracking-widest text-muted-foreground">
Diesel Generators
</h2>
{loading ? (
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
<Skeleton className="h-80" />
<Skeleton className="h-80" />
</div>
) : generators.length === 0 ? (
<div className="text-sm text-muted-foreground">No generator data available</div>
) : (
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{generators.map((g) => (
<GeneratorCard key={g.gen_id} gen={g} onClick={() => setSelectedGen(g.gen_id)} />
))}
</div>
)}
{/* ATS */}
<h2 className="text-xs font-semibold uppercase tracking-widest text-muted-foreground">
Automatic Transfer Switches
</h2>
{loading ? (
<Skeleton className="h-40" />
) : atsUnits.length === 0 ? (
<div className="text-sm text-muted-foreground">No ATS data available</div>
) : (
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{atsUnits.map((a) => <AtsCard key={a.ats_id} ats={a} />)}
</div>
)}
<GeneratorDetailSheet
siteId={SITE_ID}
genId={selectedGen}
onClose={() => setSelectedGen(null)}
/>
{/* Phase imbalance */}
<h2 className="text-xs font-semibold uppercase tracking-widest text-muted-foreground">
PDU Phase Balance
</h2>
{loading ? (
<Skeleton className="h-32" />
) : (
<PhaseImbalancePanel rooms={phases} />
)}
</div>
);
}

View file

@ -0,0 +1,33 @@
import { Sidebar } from "@/components/layout/sidebar";
import { Topbar } from "@/components/layout/topbar";
import { AlarmProvider } from "@/lib/alarm-context";
import { ThresholdProvider } from "@/lib/threshold-context";
import { ErrorBoundary } from "@/components/error-boundary";
import { Toaster } from "sonner";
export default function DashboardLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<ThresholdProvider>
<AlarmProvider>
<div className="flex h-screen overflow-hidden bg-background">
<div className="hidden md:flex">
<Sidebar />
</div>
<div className="flex flex-col flex-1 min-w-0 overflow-hidden">
<Topbar />
<main className="flex-1 overflow-y-auto p-6">
<ErrorBoundary>
{children}
</ErrorBoundary>
</main>
</div>
<Toaster position="bottom-right" theme="dark" richColors />
</div>
</AlarmProvider>
</ThresholdProvider>
);
}

View file

@ -0,0 +1,244 @@
"use client";
import { useEffect, useState, useCallback } from "react";
import { toast } from "sonner";
import { fetchLeakStatus, type LeakSensorStatus } from "@/lib/api";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Skeleton } from "@/components/ui/skeleton";
import { Droplets, RefreshCw, CheckCircle2, AlertTriangle, MapPin, Wind, Clock } from "lucide-react";
import { cn } from "@/lib/utils";
const SITE_ID = "sg-01";
function SensorBadge({ state }: { state: string }) {
const cfg = {
detected: { cls: "bg-destructive/10 text-destructive border-destructive/30", label: "LEAK DETECTED" },
clear: { cls: "bg-green-500/10 text-green-400 border-green-500/20", label: "Clear" },
unknown: { cls: "bg-muted/30 text-muted-foreground border-border", label: "Unknown" },
}[state] ?? { cls: "bg-muted/30 text-muted-foreground border-border", label: state };
return (
<span className={cn(
"text-[10px] font-semibold px-2 py-0.5 rounded-full uppercase tracking-wide border",
cfg.cls,
)}>
{cfg.label}
</span>
);
}
function SensorCard({ sensor }: { sensor: LeakSensorStatus }) {
const detected = sensor.state === "detected";
const sensorAny = sensor as LeakSensorStatus & { last_triggered_at?: string | null; trigger_count_30d?: number };
const triggerCount30d = sensorAny.trigger_count_30d ?? 0;
const lastTriggeredAt = sensorAny.last_triggered_at ?? null;
let lastTriggeredText: string;
if (lastTriggeredAt) {
const daysAgo = Math.floor((Date.now() - new Date(lastTriggeredAt).getTime()) / (1000 * 60 * 60 * 24));
lastTriggeredText = daysAgo === 0 ? "Today" : `${daysAgo}d ago`;
} else if (detected) {
lastTriggeredText = "Currently active";
} else {
lastTriggeredText = "No recent events";
}
return (
<div className={cn(
"rounded-xl border p-4 space-y-3 transition-colors",
detected ? "border-destructive/50 bg-destructive/5" : "border-border bg-muted/5",
)}>
<div className="flex items-start justify-between gap-2">
<div className="flex items-center gap-2">
<div className={cn(
"w-2.5 h-2.5 rounded-full shrink-0 mt-0.5",
detected ? "bg-destructive animate-pulse" :
sensor.state === "unknown" ? "bg-muted-foreground/30" : "bg-green-500",
)} />
<div>
<p className="text-sm font-semibold leading-none">{sensor.sensor_id}</p>
{sensor.floor_zone && (
<p className="text-[10px] text-muted-foreground mt-0.5 flex items-center gap-1">
<MapPin className="w-3 h-3" /> {sensor.floor_zone}
</p>
)}
</div>
</div>
<div className="flex items-center gap-2">
<span className="text-[10px] text-muted-foreground">{triggerCount30d} events (30d)</span>
<SensorBadge state={sensor.state} />
</div>
</div>
<div className="grid grid-cols-2 gap-2 text-xs">
{sensor.room_id && (
<div className="flex items-center gap-1.5 text-muted-foreground">
<span>Room:</span>
<span className="font-medium text-foreground capitalize">{sensor.room_id}</span>
</div>
)}
{sensor.near_crac && (
<div className="flex items-center gap-1.5 text-muted-foreground">
<Wind className="w-3 h-3 shrink-0" />
<span className="font-medium text-foreground">Near CRAC</span>
</div>
)}
<div className="col-span-2 flex items-center gap-1.5 text-muted-foreground">
<span>{sensor.under_floor ? "Under raised floor" : "Above floor level"}</span>
</div>
<div className="col-span-2 flex items-center gap-1.5 text-[10px] text-muted-foreground mt-0.5">
<Clock className="w-3 h-3 shrink-0" />
<span>{lastTriggeredText}</span>
</div>
</div>
{detected && (
<div className="rounded-lg bg-destructive/10 border border-destructive/30 px-3 py-2 text-xs text-destructive font-medium">
Water detected inspect immediately and isolate if necessary
</div>
)}
</div>
);
}
export default function LeakDetectionPage() {
const [sensors, setSensors] = useState<LeakSensorStatus[]>([]);
const [loading, setLoading] = useState(true);
const load = useCallback(async () => {
try {
setSensors(await fetchLeakStatus(SITE_ID));
} catch { toast.error("Failed to load leak sensor data"); }
finally { setLoading(false); }
}, []);
useEffect(() => {
load();
const id = setInterval(load, 15_000);
return () => clearInterval(id);
}, [load]);
const active = sensors.filter((s) => s.state === "detected");
const offline = sensors.filter((s) => s.state === "unknown");
const dry = sensors.filter((s) => s.state === "clear");
// Group by floor_zone
const byZone = sensors.reduce<Record<string, LeakSensorStatus[]>>((acc, s) => {
const zone = s.floor_zone ?? "Unassigned";
(acc[zone] ??= []).push(s);
return acc;
}, {});
const zoneEntries = Object.entries(byZone).sort(([a], [b]) => a.localeCompare(b));
return (
<div className="p-6 space-y-6">
{/* Header */}
<div className="flex items-start justify-between gap-4 flex-wrap">
<div>
<h1 className="text-xl font-semibold">Leak Detection</h1>
<p className="text-sm text-muted-foreground">Singapore DC01 water sensor site map · refreshes every 15s</p>
</div>
<div className="flex items-center gap-3">
{!loading && (
<span className={cn(
"flex items-center gap-1.5 text-xs font-semibold px-3 py-1.5 rounded-full",
active.length > 0
? "bg-destructive/10 text-destructive"
: "bg-green-500/10 text-green-400",
)}>
{active.length > 0
? <><AlertTriangle className="w-3.5 h-3.5" /> {active.length} leak{active.length > 1 ? "s" : ""} detected</>
: <><CheckCircle2 className="w-3.5 h-3.5" /> No leaks detected</>}
</span>
)}
<button
onClick={load}
className="flex items-center gap-1.5 text-xs text-muted-foreground hover:text-foreground transition-colors"
>
<RefreshCw className="w-3.5 h-3.5" /> Refresh
</button>
</div>
</div>
{/* KPI bar */}
{!loading && (
<div className="grid grid-cols-3 gap-3">
{[
{
label: "Active Leaks",
value: active.length,
sub: "require immediate action",
cls: active.length > 0 ? "border-destructive/40 bg-destructive/5 text-destructive" : "border-border bg-muted/10 text-green-400",
},
{
label: "Sensors Clear",
value: dry.length,
sub: `of ${sensors.length} total sensors`,
cls: "border-border bg-muted/10 text-foreground",
},
{
label: "Offline",
value: offline.length,
sub: "no signal",
cls: offline.length > 0 ? "border-amber-500/30 bg-amber-500/5 text-amber-400" : "border-border bg-muted/10 text-muted-foreground",
},
].map(({ label, value, sub, cls }) => (
<div key={label} className={cn("rounded-xl border px-4 py-3", cls)}>
<p className="text-[10px] uppercase tracking-wider mb-1 opacity-70">{label}</p>
<p className="text-2xl font-bold tabular-nums leading-none">{value}</p>
<p className="text-[10px] opacity-60 mt-1">{sub}</p>
</div>
))}
</div>
)}
{/* Active leak alert */}
{!loading && active.length > 0 && (
<div className="rounded-xl border border-destructive/50 bg-destructive/10 px-5 py-4">
<div className="flex items-center gap-3 mb-3">
<Droplets className="w-5 h-5 text-destructive shrink-0" />
<p className="text-sm font-semibold text-destructive">
{active.length} water leak{active.length > 1 ? "s" : ""} detected immediate action required
</p>
</div>
<div className="space-y-1">
{active.map((s) => (
<p key={s.sensor_id} className="text-xs text-destructive/80">
<strong>{s.sensor_id}</strong>
{s.floor_zone ? `${s.floor_zone}` : ""}
{s.near_crac ? ` (near ${s.near_crac})` : ""}
{s.under_floor ? " — under raised floor" : ""}
</p>
))}
</div>
</div>
)}
{/* Zone panels */}
{loading ? (
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{Array.from({ length: 4 }).map((_, i) => <Skeleton key={i} className="h-48" />)}
</div>
) : (
zoneEntries.map(([zone, zoneSensors]) => {
const zoneActive = zoneSensors.filter((s) => s.state === "detected");
return (
<div key={zone}>
<div className="flex items-center gap-2 mb-3">
<h2 className="text-xs font-semibold uppercase tracking-widest text-muted-foreground">{zone}</h2>
{zoneActive.length > 0 && (
<span className="text-[10px] font-semibold text-destructive bg-destructive/10 px-2 py-0.5 rounded-full">
{zoneActive.length} LEAK
</span>
)}
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3">
{zoneSensors.map((s) => <SensorCard key={s.sensor_id} sensor={s} />)}
</div>
</div>
);
})
)}
</div>
);
}

View file

@ -0,0 +1,440 @@
"use client";
import { useEffect, useState, useCallback } from "react";
import { toast } from "sonner";
import {
fetchMaintenanceWindows, createMaintenanceWindow, deleteMaintenanceWindow,
type MaintenanceWindow,
} from "@/lib/api";
import { PageShell } from "@/components/layout/page-shell";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Skeleton } from "@/components/ui/skeleton";
import { Button } from "@/components/ui/button";
import {
CalendarClock, Plus, Trash2, CheckCircle2, Clock, AlertTriangle,
BellOff, RefreshCw, X,
} from "lucide-react";
import { cn } from "@/lib/utils";
const SITE_ID = "sg-01";
const TARGET_GROUPS = [
{
label: "Site",
targets: [{ value: "all", label: "Entire Site" }],
},
{
label: "Halls",
targets: [
{ value: "hall-a", label: "Hall A" },
{ value: "hall-b", label: "Hall B" },
],
},
{
label: "Racks — Hall A",
targets: [
{ value: "rack-A01", label: "Rack A01" },
{ value: "rack-A02", label: "Rack A02" },
{ value: "rack-A03", label: "Rack A03" },
{ value: "rack-A04", label: "Rack A04" },
{ value: "rack-A05", label: "Rack A05" },
],
},
{
label: "Racks — Hall B",
targets: [
{ value: "rack-B01", label: "Rack B01" },
{ value: "rack-B02", label: "Rack B02" },
{ value: "rack-B03", label: "Rack B03" },
{ value: "rack-B04", label: "Rack B04" },
{ value: "rack-B05", label: "Rack B05" },
],
},
{
label: "CRAC Units",
targets: [
{ value: "crac-01", label: "CRAC-01" },
{ value: "crac-02", label: "CRAC-02" },
],
},
{
label: "UPS",
targets: [
{ value: "ups-01", label: "UPS-01" },
{ value: "ups-02", label: "UPS-02" },
],
},
{
label: "Generator",
targets: [
{ value: "gen-01", label: "Generator GEN-01" },
],
},
];
// Flat list for looking up labels
const TARGETS_FLAT = TARGET_GROUPS.flatMap(g => g.targets);
const statusCfg = {
active: { label: "Active", cls: "bg-green-500/10 text-green-400 border-green-500/20", icon: CheckCircle2 },
scheduled: { label: "Scheduled", cls: "bg-blue-500/10 text-blue-400 border-blue-500/20", icon: Clock },
expired: { label: "Expired", cls: "bg-muted/50 text-muted-foreground border-border", icon: AlertTriangle },
};
function StatusChip({ status }: { status: MaintenanceWindow["status"] }) {
const cfg = statusCfg[status];
const Icon = cfg.icon;
return (
<span className={cn("inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-[10px] font-semibold border uppercase tracking-wide", cfg.cls)}>
<Icon className="w-3 h-3" /> {cfg.label}
</span>
);
}
function formatDt(iso: string): string {
return new Date(iso).toLocaleString([], { dateStyle: "short", timeStyle: "short" });
}
// 7-day timeline strip
const DAY_LABELS = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
function TimelineStrip({ windows }: { windows: MaintenanceWindow[] }) {
const relevant = windows.filter(w => w.status === "active" || w.status === "scheduled");
if (relevant.length === 0) return null;
const today = new Date();
today.setHours(0, 0, 0, 0);
const totalMs = 7 * 24 * 3600_000;
// Day labels
const days = Array.from({ length: 7 }, (_, i) => {
const d = new Date(today.getTime() + i * 24 * 3600_000);
return DAY_LABELS[d.getDay()];
});
return (
<div className="rounded-lg border border-border bg-muted/10 p-4 space-y-3">
<p className="text-xs font-semibold uppercase tracking-widest text-muted-foreground">
7-Day Maintenance Timeline
</p>
{/* Day column labels */}
<div className="relative">
<div className="flex text-[10px] text-muted-foreground mb-1">
{days.map((day, i) => (
<div key={i} className="flex-1 text-center">{day}</div>
))}
</div>
{/* Grid lines */}
<div className="relative h-auto">
<div className="absolute inset-0 flex pointer-events-none">
{Array.from({ length: 8 }, (_, i) => (
<div key={i} className="flex-1 border-l border-border/30 last:border-r" />
))}
</div>
{/* Window bars */}
<div className="space-y-1.5 pt-1 pb-1">
{relevant.map(w => {
const startMs = Math.max(0, new Date(w.start_dt).getTime() - today.getTime());
const endMs = Math.min(totalMs, new Date(w.end_dt).getTime() - today.getTime());
if (endMs <= 0 || startMs >= totalMs) return null;
const leftPct = (startMs / totalMs) * 100;
const widthPct = ((endMs - startMs) / totalMs) * 100;
const barCls = w.status === "active"
? "bg-green-500/30 border-green-500/50 text-green-300"
: "bg-blue-500/20 border-blue-500/40 text-blue-300";
return (
<div key={w.id} className="relative h-6">
<div
className={cn("absolute h-full rounded border text-[10px] font-medium flex items-center px-1.5 overflow-hidden", barCls)}
style={{ left: `${leftPct}%`, width: `${widthPct}%`, minWidth: "4px" }}
>
<span className="truncate">{w.title}</span>
</div>
</div>
);
})}
</div>
</div>
</div>
</div>
);
}
// Defaults for new window form: now → +2h
function defaultStart() {
const d = new Date();
d.setSeconds(0, 0);
return d.toISOString().slice(0, 16);
}
function defaultEnd() {
const d = new Date(Date.now() + 2 * 3600_000);
d.setSeconds(0, 0);
return d.toISOString().slice(0, 16);
}
export default function MaintenancePage() {
const [windows, setWindows] = useState<MaintenanceWindow[]>([]);
const [loading, setLoading] = useState(true);
const [showForm, setShowForm] = useState(false);
const [submitting, setSubmitting] = useState(false);
const [deleting, setDeleting] = useState<string | null>(null);
// Form state
const [title, setTitle] = useState("");
const [target, setTarget] = useState("all");
const [startDt, setStartDt] = useState(defaultStart);
const [endDt, setEndDt] = useState(defaultEnd);
const [suppress, setSuppress] = useState(true);
const [notes, setNotes] = useState("");
const load = useCallback(async () => {
try {
const data = await fetchMaintenanceWindows(SITE_ID);
setWindows(data);
} catch { toast.error("Failed to load maintenance windows"); }
finally { setLoading(false); }
}, []);
useEffect(() => { load(); }, [load]);
async function handleCreate(e: React.FormEvent) {
e.preventDefault();
if (!title.trim()) return;
setSubmitting(true);
try {
const targetLabel = TARGETS_FLAT.find(t => t.value === target)?.label ?? target;
await createMaintenanceWindow({
site_id: SITE_ID,
title: title.trim(),
target,
target_label: targetLabel,
start_dt: new Date(startDt).toISOString(),
end_dt: new Date(endDt).toISOString(),
suppress_alarms: suppress,
notes: notes.trim(),
});
await load();
toast.success("Maintenance window created");
setShowForm(false);
setTitle(""); setNotes(""); setStartDt(defaultStart()); setEndDt(defaultEnd());
} catch { toast.error("Failed to create maintenance window"); }
finally { setSubmitting(false); }
}
async function handleDelete(id: string) {
setDeleting(id);
try { await deleteMaintenanceWindow(id); toast.success("Maintenance window deleted"); await load(); }
catch { toast.error("Failed to delete maintenance window"); }
finally { setDeleting(null); }
}
const active = windows.filter(w => w.status === "active").length;
const scheduled = windows.filter(w => w.status === "scheduled").length;
return (
<PageShell className="p-6">
<div className="flex items-start justify-between">
<div>
<h1 className="text-xl font-semibold">Maintenance Windows</h1>
<p className="text-sm text-muted-foreground">Singapore DC01 planned outages &amp; alarm suppression</p>
</div>
<div className="flex items-center gap-2">
<button onClick={load} className="flex items-center gap-1.5 text-xs text-muted-foreground hover:text-foreground transition-colors">
<RefreshCw className="w-3.5 h-3.5" />
</button>
<Button size="sm" onClick={() => { setShowForm(true); setStartDt(defaultStart()); setEndDt(defaultEnd()); }} className="flex items-center gap-1.5">
<Plus className="w-3.5 h-3.5" /> New Window
</Button>
</div>
</div>
{/* Summary */}
{!loading && (
<div className="flex items-center gap-4 text-sm">
{active > 0 && (
<span className="flex items-center gap-1.5">
<span className="w-2 h-2 rounded-full bg-green-500 animate-pulse" />
<span className="font-semibold text-green-400">{active}</span>
<span className="text-muted-foreground">active</span>
</span>
)}
{scheduled > 0 && (
<span className="flex items-center gap-1.5">
<span className="w-2 h-2 rounded-full bg-blue-500" />
<span className="font-semibold">{scheduled}</span>
<span className="text-muted-foreground">scheduled</span>
</span>
)}
{active === 0 && scheduled === 0 && (
<span className="text-muted-foreground text-xs">No active or scheduled maintenance</span>
)}
</div>
)}
{/* Create form */}
{showForm && (
<Card className="border-primary/30">
<CardHeader className="pb-3">
<div className="flex items-center justify-between">
<CardTitle className="text-sm font-semibold flex items-center gap-2">
<CalendarClock className="w-4 h-4 text-primary" /> New Maintenance Window
</CardTitle>
<button onClick={() => setShowForm(false)} className="text-muted-foreground hover:text-foreground transition-colors">
<X className="w-4 h-4" />
</button>
</div>
</CardHeader>
<CardContent>
<form onSubmit={handleCreate} className="space-y-4">
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div className="sm:col-span-2 space-y-1">
<label className="text-xs font-medium text-muted-foreground">Title *</label>
<input
required
value={title}
onChange={e => setTitle(e.target.value)}
placeholder="e.g. UPS-01 firmware update"
className="w-full h-9 rounded-md border border-border bg-muted/30 px-3 text-sm focus:outline-none focus:ring-1 focus:ring-primary"
/>
</div>
<div className="space-y-1">
<label className="text-xs font-medium text-muted-foreground">Target</label>
<select
value={target}
onChange={e => setTarget(e.target.value)}
className="w-full h-9 rounded-md border border-border bg-muted/30 px-3 text-sm focus:outline-none focus:ring-1 focus:ring-primary"
>
{TARGET_GROUPS.map(group => (
<optgroup key={group.label} label={group.label}>
{group.targets.map(t => (
<option key={t.value} value={t.value}>{t.label}</option>
))}
</optgroup>
))}
</select>
</div>
<div className="space-y-1 flex items-end gap-3">
<label className="flex items-center gap-2 cursor-pointer text-sm mb-1">
<input
type="checkbox"
checked={suppress}
onChange={e => setSuppress(e.target.checked)}
className="rounded"
/>
<span className="text-xs font-medium">Suppress alarms</span>
</label>
</div>
<div className="space-y-1">
<label className="text-xs font-medium text-muted-foreground">Start</label>
<input
type="datetime-local"
required
value={startDt}
onChange={e => setStartDt(e.target.value)}
className="w-full h-9 rounded-md border border-border bg-muted/30 px-3 text-sm focus:outline-none focus:ring-1 focus:ring-primary"
/>
</div>
<div className="space-y-1">
<label className="text-xs font-medium text-muted-foreground">End</label>
<input
type="datetime-local"
required
value={endDt}
onChange={e => setEndDt(e.target.value)}
className="w-full h-9 rounded-md border border-border bg-muted/30 px-3 text-sm focus:outline-none focus:ring-1 focus:ring-primary"
/>
</div>
<div className="sm:col-span-2 space-y-1">
<label className="text-xs font-medium text-muted-foreground">Notes (optional)</label>
<textarea
value={notes}
onChange={e => setNotes(e.target.value)}
rows={2}
placeholder="Reason, affected systems, contacts…"
className="w-full rounded-md border border-border bg-muted/30 px-3 py-2 text-sm focus:outline-none focus:ring-1 focus:ring-primary resize-none"
/>
</div>
</div>
<div className="flex items-center justify-end gap-2">
<Button type="button" variant="ghost" size="sm" onClick={() => setShowForm(false)}>Cancel</Button>
<Button type="submit" size="sm" disabled={submitting}>
{submitting ? "Creating…" : "Create Window"}
</Button>
</div>
</form>
</CardContent>
</Card>
)}
{/* Windows list */}
{loading ? (
<div className="space-y-3">
{Array.from({ length: 3 }).map((_, i) => <Skeleton key={i} className="h-20" />)}
</div>
) : windows.length === 0 ? (
<div className="flex flex-col items-center justify-center h-48 gap-3 text-muted-foreground">
<CalendarClock className="w-8 h-8 opacity-30" />
<p className="text-sm">No maintenance windows</p>
<p className="text-xs">Click &quot;New Window&quot; to schedule planned downtime</p>
</div>
) : (
<>
{/* 7-day timeline strip */}
<TimelineStrip windows={windows} />
<div className="space-y-3">
{[...windows].sort((a, b) => {
const order = { active: 0, scheduled: 1, expired: 2 };
return (order[a.status] ?? 9) - (order[b.status] ?? 9) || a.start_dt.localeCompare(b.start_dt);
}).map(w => (
<Card key={w.id} className={cn(
"border",
w.status === "active" && "border-green-500/30",
w.status === "scheduled" && "border-blue-500/20",
w.status === "expired" && "opacity-60",
)}>
<CardContent className="p-4">
<div className="flex items-start justify-between gap-3">
<div className="space-y-1 min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<span className="font-semibold text-sm truncate">{w.title}</span>
<StatusChip status={w.status} />
{w.suppress_alarms && (
<span className="inline-flex items-center gap-1 text-[10px] text-muted-foreground">
<BellOff className="w-3 h-3" /> Alarms suppressed
</span>
)}
</div>
<div className="flex items-center gap-3 text-xs text-muted-foreground flex-wrap">
<span>Target: <strong className="text-foreground">{w.target_label}</strong></span>
<span>{formatDt(w.start_dt)} {formatDt(w.end_dt)}</span>
</div>
{w.notes && (
<p className="text-xs text-muted-foreground mt-1">{w.notes}</p>
)}
</div>
<Button
size="sm"
variant="ghost"
className="text-muted-foreground hover:text-destructive shrink-0"
disabled={deleting === w.id}
onClick={() => handleDelete(w.id)}
>
<Trash2 className="w-3.5 h-3.5" />
</Button>
</div>
</CardContent>
</Card>
))}
</div>
</>
)}
</PageShell>
);
}

View file

@ -0,0 +1,256 @@
"use client";
import { useEffect, useState, useCallback } from "react";
import { toast } from "sonner";
import { fetchNetworkStatus, type NetworkSwitchStatus } from "@/lib/api";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Skeleton } from "@/components/ui/skeleton";
import {
Network, Wifi, WifiOff, AlertTriangle, CheckCircle2,
RefreshCw, Cpu, HardDrive, Thermometer, Activity,
} from "lucide-react";
import { cn } from "@/lib/utils";
const SITE_ID = "sg-01";
function formatUptime(seconds: number | null): string {
if (seconds === null) return "—";
const d = Math.floor(seconds / 86400);
const h = Math.floor((seconds % 86400) / 3600);
if (d > 0) return `${d}d ${h}h`;
const m = Math.floor((seconds % 3600) / 60);
return h > 0 ? `${h}h ${m}m` : `${m}m`;
}
function StateChip({ state }: { state: NetworkSwitchStatus["state"] }) {
const cfg = {
up: { label: "Up", icon: CheckCircle2, cls: "bg-green-500/10 text-green-400 border-green-500/20" },
degraded: { label: "Degraded", icon: AlertTriangle, cls: "bg-amber-500/10 text-amber-400 border-amber-500/20" },
down: { label: "Down", icon: WifiOff, cls: "bg-destructive/10 text-destructive border-destructive/20" },
unknown: { label: "Unknown", icon: WifiOff, cls: "bg-muted/50 text-muted-foreground border-border" },
}[state] ?? { label: state, icon: WifiOff, cls: "bg-muted/50 text-muted-foreground border-border" };
const Icon = cfg.icon;
return (
<span className={cn("inline-flex items-center gap-1.5 px-2.5 py-0.5 rounded-full text-[10px] font-semibold uppercase tracking-wide border", cfg.cls)}>
<Icon className="w-3 h-3" /> {cfg.label}
</span>
);
}
function MiniBar({ value, max, className }: { value: number; max: number; className?: string }) {
const pct = Math.min(100, (value / max) * 100);
return (
<div className="flex-1 h-1.5 rounded-full bg-muted overflow-hidden">
<div className={cn("h-full rounded-full transition-all", className)} style={{ width: `${pct}%` }} />
</div>
);
}
function SwitchCard({ sw }: { sw: NetworkSwitchStatus }) {
const portPct = sw.active_ports !== null ? Math.round((sw.active_ports / sw.port_count) * 100) : null;
const stateOk = sw.state === "up";
const stateDeg = sw.state === "degraded";
return (
<Card className={cn(
"border",
sw.state === "down" && "border-destructive/40",
sw.state === "degraded" && "border-amber-500/30",
)}>
<CardHeader className="pb-3">
<div className="flex items-start justify-between gap-2">
<div className="space-y-0.5 min-w-0">
<CardTitle className="text-sm font-semibold flex items-center gap-2">
<Network className={cn(
"w-4 h-4 shrink-0",
stateOk ? "text-green-400" : stateDeg ? "text-amber-400" : "text-destructive"
)} />
<span className="truncate">{sw.name}</span>
</CardTitle>
<p className="text-[10px] text-muted-foreground font-mono">{sw.model}</p>
</div>
<StateChip state={sw.state} />
</div>
<div className="flex items-center gap-3 text-[10px] text-muted-foreground mt-1">
<span className="capitalize">{sw.role}</span>
<span>·</span>
<span>{sw.room_id}</span>
<span>·</span>
<span className="font-mono">{sw.rack_id}</span>
</div>
</CardHeader>
<CardContent className="space-y-3">
{/* Ports headline */}
<div className="flex items-start justify-between">
<div>
<p className="text-[10px] text-muted-foreground uppercase tracking-wide">Ports Active</p>
<p className="text-base font-bold tabular-nums leading-tight">
{sw.active_ports !== null ? Math.round(sw.active_ports) : "—"} / {sw.port_count}
</p>
{portPct !== null && <p className="text-[10px] text-muted-foreground">{portPct}% utilised</p>}
</div>
</div>
<MiniBar
value={sw.active_ports ?? 0}
max={sw.port_count}
className={portPct !== null && portPct >= 90 ? "bg-amber-500" : "bg-primary"}
/>
{/* Bandwidth */}
<div className="grid grid-cols-2 gap-3">
<div className="space-y-1">
<p className="text-[10px] text-muted-foreground flex items-center gap-1">
<Activity className="w-3 h-3" /> Ingress
</p>
<p className="text-sm font-semibold tabular-nums">
{sw.bandwidth_in_mbps !== null ? `${sw.bandwidth_in_mbps.toFixed(0)} Mbps` : "—"}
</p>
</div>
<div className="space-y-1">
<p className="text-[10px] text-muted-foreground flex items-center gap-1">
<Activity className="w-3 h-3 rotate-180" /> Egress
</p>
<p className="text-sm font-semibold tabular-nums">
{sw.bandwidth_out_mbps !== null ? `${sw.bandwidth_out_mbps.toFixed(0)} Mbps` : "—"}
</p>
</div>
</div>
{/* CPU + Mem */}
<div className="border-t border-border/40 pt-2 space-y-2">
<div className="flex items-center gap-2">
<Cpu className="w-3 h-3 text-muted-foreground shrink-0" />
<span className="text-[10px] text-muted-foreground w-8">CPU</span>
<MiniBar
value={sw.cpu_pct ?? 0}
max={100}
className={
(sw.cpu_pct ?? 0) >= 80 ? "bg-destructive" :
(sw.cpu_pct ?? 0) >= 60 ? "bg-amber-500" : "bg-green-500"
}
/>
<span className="text-xs font-semibold tabular-nums w-10 text-right">
{sw.cpu_pct !== null ? `${sw.cpu_pct.toFixed(0)}%` : "—"}
</span>
</div>
<div className="flex items-center gap-2">
<HardDrive className="w-3 h-3 text-muted-foreground shrink-0" />
<span className="text-[10px] text-muted-foreground w-8">Mem</span>
<MiniBar
value={sw.mem_pct ?? 0}
max={100}
className={
(sw.mem_pct ?? 0) >= 85 ? "bg-destructive" :
(sw.mem_pct ?? 0) >= 70 ? "bg-amber-500" : "bg-blue-500"
}
/>
<span className="text-xs font-semibold tabular-nums w-10 text-right">
{sw.mem_pct !== null ? `${sw.mem_pct.toFixed(0)}%` : "—"}
</span>
</div>
</div>
{/* Footer stats */}
<div className="flex items-center justify-between pt-1 border-t border-border/50 text-[10px] text-muted-foreground">
<span className="flex items-center gap-1">
<Thermometer className="w-3 h-3" />
{sw.temperature_c !== null ? `${sw.temperature_c.toFixed(0)}°C` : "—"}
</span>
<span>
Pkt loss: <span className={cn(
"font-semibold",
(sw.packet_loss_pct ?? 0) > 1 ? "text-destructive" :
(sw.packet_loss_pct ?? 0) > 0.1 ? "text-amber-400" : "text-green-400"
)}>
{sw.packet_loss_pct !== null ? `${sw.packet_loss_pct.toFixed(2)}%` : "—"}
</span>
</span>
<span>Up: {formatUptime(sw.uptime_s)}</span>
</div>
</CardContent>
</Card>
);
}
export default function NetworkPage() {
const [switches, setSwitches] = useState<NetworkSwitchStatus[]>([]);
const [loading, setLoading] = useState(true);
const load = useCallback(async () => {
try {
const data = await fetchNetworkStatus(SITE_ID);
setSwitches(data);
} catch { toast.error("Failed to load network data"); }
finally { setLoading(false); }
}, []);
useEffect(() => {
load();
const id = setInterval(load, 30_000);
return () => clearInterval(id);
}, [load]);
const down = switches.filter((s) => s.state === "down").length;
const degraded = switches.filter((s) => s.state === "degraded").length;
const up = switches.filter((s) => s.state === "up").length;
return (
<div className="p-6 space-y-6">
<div className="flex items-start justify-between">
<div>
<h1 className="text-xl font-semibold">Network Infrastructure</h1>
<p className="text-sm text-muted-foreground">Singapore DC01 switch health · refreshes every 30s</p>
</div>
<button
onClick={load}
className="flex items-center gap-1.5 text-xs text-muted-foreground hover:text-foreground transition-colors"
>
<RefreshCw className="w-3.5 h-3.5" /> Refresh
</button>
</div>
{/* Summary chips */}
{!loading && switches.length > 0 && (
<div className="flex items-center gap-3 flex-wrap">
<span className="flex items-center gap-1.5 text-sm">
<span className="w-2 h-2 rounded-full bg-green-500" />
<span className="font-semibold">{up}</span>
<span className="text-muted-foreground">up</span>
</span>
{degraded > 0 && (
<span className="flex items-center gap-1.5 text-sm">
<span className="w-2 h-2 rounded-full bg-amber-500" />
<span className="font-semibold text-amber-400">{degraded}</span>
<span className="text-muted-foreground">degraded</span>
</span>
)}
{down > 0 && (
<span className="flex items-center gap-1.5 text-sm">
<span className="w-2 h-2 rounded-full bg-destructive" />
<span className="font-semibold text-destructive">{down}</span>
<span className="text-muted-foreground">down</span>
</span>
)}
<span className="text-xs text-muted-foreground ml-2">{switches.length} switches total</span>
</div>
)}
{/* Switch cards */}
{loading ? (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{Array.from({ length: 3 }).map((_, i) => <Skeleton key={i} className="h-64" />)}
</div>
) : switches.length === 0 ? (
<div className="flex flex-col items-center justify-center h-48 gap-3 text-muted-foreground">
<Wifi className="w-8 h-8 opacity-30" />
<p className="text-sm">No network switch data available</p>
<p className="text-xs text-center">Ensure the simulator is running and network bots are publishing data</p>
</div>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{switches.map((sw) => <SwitchCard key={sw.switch_id} sw={sw} />)}
</div>
)}
</div>
);
}

View file

@ -0,0 +1,858 @@
"use client";
import React from "react";
import { useEffect, useState, useCallback } from "react";
import {
fetchKpis, fetchRackBreakdown, fetchRoomPowerHistory, fetchUpsStatus, fetchCapacitySummary,
fetchGeneratorStatus, fetchAtsStatus, fetchPowerRedundancy, fetchPhaseBreakdown,
type KpiData, type RoomPowerBreakdown, type PowerHistoryBucket, type UpsAsset, type CapacitySummary,
type GeneratorStatus, type AtsStatus, type PowerRedundancy, type RoomPhase,
} from "@/lib/api";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Skeleton } from "@/components/ui/skeleton";
import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs";
import {
BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, ReferenceLine,
AreaChart, Area, Cell,
} from "recharts";
import { Zap, Battery, AlertTriangle, CheckCircle2, Activity, Fuel, ArrowLeftRight, ShieldCheck, Server, Thermometer, Gauge } from "lucide-react";
import { UpsDetailSheet } from "@/components/dashboard/ups-detail-sheet";
import { cn } from "@/lib/utils";
const SITE_ID = "sg-01";
const ROOM_COLORS: Record<string, string> = {
"hall-a": "oklch(0.62 0.17 212)",
"hall-b": "oklch(0.7 0.15 162)",
};
const roomLabels: Record<string, string> = {
"hall-a": "Hall A",
"hall-b": "Hall B",
};
function formatTime(iso: string) {
return new Date(iso).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
}
// ── Site capacity bar ─────────────────────────────────────────────────────────
function SiteCapacityBar({ usedKw, capacityKw }: { usedKw: number; capacityKw: number }) {
const pct = capacityKw > 0 ? Math.min(100, (usedKw / capacityKw) * 100) : 0;
const barColor =
pct >= 85 ? "bg-destructive" :
pct >= 70 ? "bg-amber-500" :
"bg-primary";
const textColor =
pct >= 85 ? "text-destructive" :
pct >= 70 ? "text-amber-400" :
"text-green-400";
return (
<div className="rounded-xl border border-border bg-muted/10 px-5 py-4 space-y-3">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2 text-sm font-medium">
<Zap className="w-4 h-4 text-primary" />
Site IT Load vs Rated Capacity
</div>
<span className={cn("text-xs font-semibold", textColor)}>
{pct.toFixed(1)}% utilised
</span>
</div>
<div className="h-3 rounded-full bg-muted overflow-hidden">
<div
className={cn("h-full rounded-full transition-all duration-700", barColor)}
style={{ width: `${pct}%` }}
/>
</div>
<div className="flex justify-between text-xs text-muted-foreground">
<span>
<strong className="text-foreground">{usedKw.toFixed(1)} kW</strong> in use
</span>
<span>
<strong className="text-foreground">{(capacityKw - usedKw).toFixed(1)} kW</strong> headroom
</span>
<span>
<strong className="text-foreground">{capacityKw.toFixed(0)} kW</strong> rated
</span>
</div>
</div>
);
}
// ── KPI card ──────────────────────────────────────────────────────────────────
function KpiCard({ label, value, sub, icon: Icon, accent }: {
label: string; value: string; sub?: string; icon: React.ElementType; accent?: boolean
}) {
return (
<Card>
<CardContent className="p-4 flex items-center gap-3">
<div className={cn("p-2 rounded-lg", accent ? "bg-primary/10" : "bg-muted")}>
<Icon className={cn("w-4 h-4", accent ? "text-primary" : "text-muted-foreground")} />
</div>
<div>
<p className="text-2xl font-bold">{value}</p>
<p className="text-xs text-muted-foreground">{label}</p>
{sub && <p className="text-[10px] text-muted-foreground">{sub}</p>}
</div>
</CardContent>
</Card>
);
}
// ── UPS card ──────────────────────────────────────────────────────────────────
function UpsCard({ ups, onClick }: { ups: UpsAsset; onClick: () => void }) {
const onBattery = ups.state === "battery";
const overload = ups.state === "overload";
const abnormal = onBattery || overload;
const charge = ups.charge_pct ?? 0;
const runtime = ups.runtime_min ?? null;
return (
<Card
className={cn("border cursor-pointer hover:border-primary/40 transition-colors",
overload && "border-destructive/50",
onBattery && !overload && "border-amber-500/40",
)}
onClick={onClick}
>
<CardHeader className="pb-2">
<div className="flex items-center justify-between">
<CardTitle className="text-sm font-semibold flex items-center gap-2">
<Battery className="w-4 h-4 text-primary" />
{ups.ups_id.toUpperCase()}
</CardTitle>
<span className={cn(
"flex items-center gap-1.5 text-[10px] font-semibold px-2 py-0.5 rounded-full uppercase tracking-wide",
overload ? "bg-destructive/10 text-destructive" :
onBattery ? "bg-amber-500/10 text-amber-400" :
"bg-green-500/10 text-green-400"
)}>
{abnormal ? <AlertTriangle className="w-3 h-3" /> : <CheckCircle2 className="w-3 h-3" />}
{overload ? "Overloaded" : onBattery ? "On Battery" : "Mains"}
</span>
</div>
</CardHeader>
<CardContent className="space-y-3">
{/* Battery charge */}
<div className="space-y-1">
<div className="flex justify-between text-[11px] text-muted-foreground">
<span>Battery charge</span>
<span className="font-medium text-foreground">{ups.charge_pct !== null ? `${ups.charge_pct}%` : "—"}</span>
</div>
<div className="h-2 rounded-full bg-muted overflow-hidden">
<div
className={cn("h-full rounded-full transition-all duration-500",
charge < 50 ? "bg-destructive" : charge < 80 ? "bg-amber-500" : "bg-green-500"
)}
style={{ width: `${charge}%` }}
/>
</div>
</div>
<div className="grid grid-cols-3 gap-3 text-xs">
<div>
<p className="text-muted-foreground">Load</p>
<p className={cn("font-semibold",
(ups.load_pct ?? 0) >= 95 ? "text-destructive" :
(ups.load_pct ?? 0) >= 85 ? "text-amber-400" : "",
)}>
{ups.load_pct !== null ? `${ups.load_pct}%` : "—"}
</p>
</div>
<div>
<p className="text-muted-foreground">Runtime</p>
<p className={cn(
"font-semibold",
runtime !== null && runtime < 5 ? "text-destructive" :
runtime !== null && runtime < 15 ? "text-amber-400" : ""
)}>
{runtime !== null ? `${Math.round(runtime)} min` : "—"}
</p>
</div>
<div>
<p className="text-muted-foreground">Voltage</p>
<p className={cn("font-semibold",
ups.voltage_v !== null && (ups.voltage_v < 210 || ups.voltage_v > 250) ? "text-amber-400" : "",
)}>
{ups.voltage_v !== null ? `${ups.voltage_v} V` : "—"}
</p>
</div>
</div>
{/* Runtime bar */}
{runtime !== null && (
<div className="space-y-1">
<div className="flex justify-between text-[10px] text-muted-foreground">
<span>Est. runtime remaining</span>
<span className={cn(
"font-medium",
runtime < 5 ? "text-destructive" :
runtime < 15 ? "text-amber-400" : "text-green-400"
)}>
{runtime < 5 ? "Critical" : runtime < 15 ? "Low" : "OK"}
</span>
</div>
<div className="h-2 rounded-full bg-muted overflow-hidden">
<div
className={cn("h-full rounded-full transition-all duration-500",
runtime < 5 ? "bg-destructive" :
runtime < 15 ? "bg-amber-500" : "bg-green-500"
)}
style={{ width: `${Math.min(100, (runtime / 120) * 100)}%` }}
/>
</div>
</div>
)}
<p className="text-[10px] text-muted-foreground/50 text-right">Click for details</p>
</CardContent>
</Card>
);
}
// ── Rack bar chart ────────────────────────────────────────────────────────────
function RackPowerChart({ rooms }: { rooms: RoomPowerBreakdown[] }) {
const [activeRoom, setActiveRoom] = useState(rooms[0]?.room_id ?? "");
const room = rooms.find((r) => r.room_id === activeRoom);
if (!room) return null;
const maxKw = Math.max(...room.racks.map((r) => r.power_kw ?? 0), 1);
return (
<Card>
<CardHeader className="pb-2">
<div className="flex items-center justify-between">
<CardTitle className="text-sm font-semibold">Per-Rack Power (kW)</CardTitle>
<Tabs value={activeRoom} onValueChange={setActiveRoom}>
<TabsList className="h-7">
{rooms.map((r) => (
<TabsTrigger key={r.room_id} value={r.room_id} className="text-xs px-2 py-0.5">
{roomLabels[r.room_id] ?? r.room_id}
</TabsTrigger>
))}
</TabsList>
</Tabs>
</div>
</CardHeader>
<CardContent>
<div className="flex items-center gap-3 mb-3 text-[10px] text-muted-foreground">
{[
{ color: "oklch(0.62 0.17 212)", label: "Normal" },
{ color: "oklch(0.68 0.14 162)", label: "Moderate" },
{ color: "oklch(0.65 0.20 45)", label: "High (≥7.5 kW)" },
{ color: "oklch(0.55 0.22 25)", label: "Critical (≥9.5 kW)" },
].map(({ color, label }) => (
<span key={label} className="flex items-center gap-1">
<span className="w-3 h-2 rounded-sm inline-block" style={{ backgroundColor: color }} />
{label}
</span>
))}
</div>
<ResponsiveContainer width="100%" height={220}>
<BarChart data={room.racks} margin={{ top: 4, right: 4, left: -20, bottom: 0 }}>
<CartesianGrid strokeDasharray="3 3" stroke="oklch(1 0 0 / 8%)" />
<XAxis
dataKey="rack_id"
tick={{ fontSize: 9, fill: "oklch(0.65 0.04 257)" }}
tickLine={false}
axisLine={false}
tickFormatter={(v) => v.replace("rack-", "")}
/>
<YAxis
tick={{ fontSize: 10, fill: "oklch(0.65 0.04 257)" }}
tickLine={false}
axisLine={false}
domain={[0, Math.ceil(maxKw * 1.2)]}
/>
<Tooltip
contentStyle={{ backgroundColor: "oklch(0.16 0.04 265)", border: "1px solid oklch(1 0 0 / 9%)", borderRadius: "6px", fontSize: "12px" }}
formatter={(v) => [`${v} kW`, "Power"]}
labelFormatter={(l) => l}
/>
<ReferenceLine y={7.5} stroke="oklch(0.72 0.18 84)" strokeDasharray="4 4" strokeWidth={1}
label={{ value: "Warn 7.5kW", fontSize: 9, fill: "oklch(0.72 0.18 84)", position: "insideTopRight" }} />
<ReferenceLine y={9.5} stroke="oklch(0.55 0.22 25)" strokeDasharray="4 4" strokeWidth={1}
label={{ value: "Crit 9.5kW", fontSize: 9, fill: "oklch(0.55 0.22 25)", position: "insideTopRight" }} />
<Bar dataKey="power_kw" radius={[3, 3, 0, 0]} maxBarSize={32}>
{room.racks.map((r) => (
<Cell
key={r.rack_id}
fill={
(r.power_kw ?? 0) >= 9.5 ? "oklch(0.55 0.22 25)" :
(r.power_kw ?? 0) >= 7.5 ? "oklch(0.65 0.20 45)" :
(r.power_kw ?? 0) >= 4.0 ? "oklch(0.68 0.14 162)" :
ROOM_COLORS[room.room_id] ?? "oklch(0.62 0.17 212)"
}
/>
))}
</Bar>
</BarChart>
</ResponsiveContainer>
</CardContent>
</Card>
);
}
// ── Room power history chart ──────────────────────────────────────────────────
function RoomPowerHistoryChart({ data }: { data: PowerHistoryBucket[] }) {
type Row = { time: string; [room: string]: string | number };
const bucketMap = new Map<string, Row>();
for (const row of data) {
const time = formatTime(row.bucket);
if (!bucketMap.has(time)) bucketMap.set(time, { time });
bucketMap.get(time)![row.room_id] = row.total_kw;
}
const chartData = Array.from(bucketMap.values());
const roomIds = [...new Set(data.map((d) => d.room_id))].sort();
return (
<Card>
<CardHeader className="pb-2">
<div className="flex items-center justify-between">
<CardTitle className="text-sm font-semibold">Power by Room</CardTitle>
<div className="flex items-center gap-3 text-xs text-muted-foreground">
{roomIds.map((id) => (
<span key={id} className="flex items-center gap-1">
<span className="w-3 h-0.5 inline-block" style={{ backgroundColor: ROOM_COLORS[id] }} />
{roomLabels[id] ?? id}
</span>
))}
</div>
</div>
</CardHeader>
<CardContent>
{chartData.length === 0 ? (
<div className="h-[200px] flex items-center justify-center text-sm text-muted-foreground">
Waiting for data...
</div>
) : (
<ResponsiveContainer width="100%" height={200}>
<AreaChart data={chartData} margin={{ top: 4, right: 4, left: -20, bottom: 0 }}>
<defs>
{roomIds.map((id) => (
<linearGradient key={id} id={`grad-${id}`} x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor={ROOM_COLORS[id]} stopOpacity={0.3} />
<stop offset="95%" stopColor={ROOM_COLORS[id]} stopOpacity={0} />
</linearGradient>
))}
</defs>
<CartesianGrid strokeDasharray="3 3" stroke="oklch(1 0 0 / 8%)" />
<XAxis dataKey="time" tick={{ fontSize: 10, fill: "oklch(0.65 0.04 257)" }} tickLine={false} axisLine={false} />
<YAxis tick={{ fontSize: 10, fill: "oklch(0.65 0.04 257)" }} tickLine={false} axisLine={false} />
<Tooltip
contentStyle={{ backgroundColor: "oklch(0.16 0.04 265)", border: "1px solid oklch(1 0 0 / 9%)", borderRadius: "6px", fontSize: "12px" }}
formatter={(v, name) => [`${v} kW`, roomLabels[String(name)] ?? String(name)]}
/>
{roomIds.map((id) => (
<Area key={id} type="monotone" dataKey={id} stroke={ROOM_COLORS[id]} fill={`url(#grad-${id})`} strokeWidth={2} dot={false} />
))}
</AreaChart>
</ResponsiveContainer>
)}
</CardContent>
</Card>
);
}
// ── Generator card ────────────────────────────────────────────────
function GeneratorCard({ gen }: { gen: GeneratorStatus }) {
const faulted = gen.state === "fault";
const running = gen.state === "running" || gen.state === "test";
const fuel = gen.fuel_pct ?? 0;
const stateLabel = { standby: "Standby", running: "Running", test: "Test Run", fault: "FAULT", unknown: "Unknown" }[gen.state] ?? gen.state;
return (
<Card className={cn("border", faulted && "border-destructive/50")}>
<CardHeader className="pb-2">
<div className="flex items-center justify-between">
<CardTitle className="text-sm font-semibold flex items-center gap-2">
<Fuel className="w-4 h-4 text-primary" />
{gen.gen_id.toUpperCase()}
</CardTitle>
<span className={cn("text-[10px] font-semibold px-2 py-0.5 rounded-full uppercase tracking-wide",
faulted ? "bg-destructive/10 text-destructive" :
running ? "bg-amber-500/10 text-amber-400" :
"bg-green-500/10 text-green-400",
)}>
{faulted ? <AlertTriangle className="w-3 h-3 inline mr-0.5" /> : <CheckCircle2 className="w-3 h-3 inline mr-0.5" />}
{stateLabel}
</span>
</div>
</CardHeader>
<CardContent className="space-y-3">
<div className="space-y-1">
<div className="flex justify-between text-[11px] text-muted-foreground">
<span>Fuel level</span>
<span className={cn("font-medium", fuel < 10 ? "text-destructive" : fuel < 25 ? "text-amber-400" : "text-foreground")}>
{gen.fuel_pct != null ? `${gen.fuel_pct.toFixed(1)}%` : "—"}
{gen.fuel_litres != null ? ` (${gen.fuel_litres.toFixed(0)} L)` : ""}
</span>
</div>
<div className="h-2 rounded-full bg-muted overflow-hidden">
<div className={cn("h-full rounded-full transition-all duration-500",
fuel < 10 ? "bg-destructive" : fuel < 25 ? "bg-amber-500" : "bg-green-500"
)} style={{ width: `${fuel}%` }} />
</div>
</div>
<div className="grid grid-cols-2 gap-3 text-xs">
<div><p className="text-muted-foreground">Load</p>
<p className={cn("font-semibold",
(gen.load_pct ?? 0) >= 95 ? "text-destructive" :
(gen.load_pct ?? 0) >= 85 ? "text-amber-400" : ""
)}>
{gen.load_kw != null ? `${gen.load_kw} kW (${gen.load_pct}%)` : "—"}
</p>
</div>
<div><p className="text-muted-foreground">Run hours</p>
<p className="font-semibold">{gen.run_hours != null ? `${gen.run_hours.toFixed(0)} h` : "—"}</p></div>
<div><p className="text-muted-foreground">Output voltage</p>
<p className="font-semibold">{gen.voltage_v != null && gen.voltage_v > 0 ? `${gen.voltage_v} V` : "—"}</p></div>
<div><p className="text-muted-foreground">Frequency</p>
<p className="font-semibold">{gen.frequency_hz != null && gen.frequency_hz > 0 ? `${gen.frequency_hz} Hz` : "—"}</p></div>
<div><p className="text-muted-foreground flex items-center gap-1">
<Thermometer className="w-3 h-3" />Coolant
</p>
<p className={cn("font-semibold",
(gen.coolant_temp_c ?? 0) >= 105 ? "text-destructive" :
(gen.coolant_temp_c ?? 0) >= 95 ? "text-amber-400" : ""
)}>
{gen.coolant_temp_c != null ? `${gen.coolant_temp_c}°C` : "—"}
</p>
</div>
<div><p className="text-muted-foreground flex items-center gap-1">
<Thermometer className="w-3 h-3" />Exhaust
</p>
<p className="font-semibold">{gen.exhaust_temp_c != null && gen.exhaust_temp_c > 0 ? `${gen.exhaust_temp_c}°C` : "—"}</p>
</div>
<div><p className="text-muted-foreground flex items-center gap-1">
<Gauge className="w-3 h-3" />Oil pressure
</p>
<p className={cn("font-semibold",
gen.oil_pressure_bar !== null && gen.oil_pressure_bar < 2 ? "text-destructive" : ""
)}>
{gen.oil_pressure_bar != null && gen.oil_pressure_bar > 0 ? `${gen.oil_pressure_bar} bar` : "—"}
</p>
</div>
<div><p className="text-muted-foreground">Battery</p>
<p className="font-semibold">{gen.battery_v != null ? `${gen.battery_v} V` : "—"}</p></div>
</div>
</CardContent>
</Card>
);
}
// ── ATS card ──────────────────────────────────────────────────────
function AtsCard({ ats }: { ats: AtsStatus }) {
const onGenerator = ats.active_feed === "generator";
const transferring = ats.state === "transferring";
const feedLabel: Record<string, string> = {
"utility-a": "Utility A", "utility-b": "Utility B", "generator": "Generator",
};
return (
<Card className={cn("border", onGenerator && "border-amber-500/40")}>
<CardHeader className="pb-2">
<div className="flex items-center justify-between">
<CardTitle className="text-sm font-semibold flex items-center gap-2">
<ArrowLeftRight className="w-4 h-4 text-primary" />
{ats.ats_id.toUpperCase()}
</CardTitle>
<span className={cn("text-[10px] font-semibold px-2 py-0.5 rounded-full uppercase tracking-wide",
transferring ? "bg-amber-500/10 text-amber-400 animate-pulse" :
onGenerator ? "bg-amber-500/10 text-amber-400" :
"bg-green-500/10 text-green-400",
)}>
{transferring ? "Transferring" : onGenerator ? "Generator feed" : "Stable"}
</span>
</div>
</CardHeader>
<CardContent className="space-y-3">
<div className="text-center rounded-lg bg-muted/20 py-3">
<p className="text-[10px] text-muted-foreground uppercase tracking-wide mb-1">Active Feed</p>
<p className={cn("text-xl font-bold", onGenerator ? "text-amber-400" : "text-green-400")}>
{feedLabel[ats.active_feed] ?? ats.active_feed}
</p>
</div>
<div className="grid grid-cols-3 gap-2 text-[11px]">
{[
{ label: "Utility A", v: ats.utility_a_v, active: ats.active_feed === "utility-a" },
{ label: "Utility B", v: ats.utility_b_v, active: ats.active_feed === "utility-b" },
{ label: "Generator", v: ats.generator_v, active: ats.active_feed === "generator" },
].map(({ label, v, active }) => (
<div key={label} className={cn("rounded-md px-2 py-1.5 text-center",
active ? "bg-primary/10 border border-primary/20" : "bg-muted/20",
)}>
<p className="text-muted-foreground">{label}</p>
<p className={cn("font-semibold", active ? "text-foreground" : "text-muted-foreground")}>
{v != null && v > 0 ? `${v.toFixed(0)} V` : "—"}
</p>
</div>
))}
</div>
<div className="flex justify-between text-[11px] text-muted-foreground border-t border-border/30 pt-2">
<span>Transfers: <strong className="text-foreground">{ats.transfer_count}</strong></span>
{ats.last_transfer_ms != null && (
<span>Last xfer: <strong className="text-foreground">{ats.last_transfer_ms} ms</strong></span>
)}
</div>
</CardContent>
</Card>
);
}
// ── Redundancy banner ─────────────────────────────────────────────
function RedundancyBanner({ r }: { r: PowerRedundancy }) {
const color =
r.level === "2N" ? "border-green-500/30 bg-green-500/5 text-green-400" :
r.level === "N+1" ? "border-amber-500/30 bg-amber-500/5 text-amber-400" :
"border-destructive/30 bg-destructive/5 text-destructive";
return (
<div className={cn("flex items-center justify-between rounded-xl border px-5 py-3", color)}>
<div className="flex items-center gap-3">
<ShieldCheck className="w-5 h-5 shrink-0" />
<div>
<p className="text-sm font-bold">Power Redundancy: {r.level}</p>
<p className="text-xs opacity-70">{r.notes}</p>
</div>
</div>
<div className="text-right text-xs opacity-80 space-y-0.5">
<p>UPS online: {r.ups_online}/{r.ups_total}</p>
<p>Generator: {r.generator_ok ? "available" : "unavailable"}</p>
<p>Feed: {r.ats_active_feed ?? "—"}</p>
</div>
</div>
);
}
// ── Phase imbalance table ──────────────────────────────────────────
function PhaseImbalanceTable({ rooms }: { rooms: RoomPhase[] }) {
const allRacks = rooms.flatMap(r => r.racks).filter(r => (r.imbalance_pct ?? 0) > 5);
if (allRacks.length === 0) return null;
return (
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-semibold flex items-center gap-2">
<AlertTriangle className="w-4 h-4 text-amber-400" />
Phase Imbalance {allRacks.length} rack{allRacks.length !== 1 ? "s" : ""} flagged
</CardTitle>
</CardHeader>
<CardContent>
<div className="overflow-x-auto">
<table className="w-full text-xs">
<thead>
<tr className="text-muted-foreground border-b border-border/30">
<th className="text-left py-1.5 pr-3 font-medium">Rack</th>
<th className="text-right py-1.5 pr-3 font-medium">Phase A kW</th>
<th className="text-right py-1.5 pr-3 font-medium">Phase B kW</th>
<th className="text-right py-1.5 pr-3 font-medium">Phase C kW</th>
<th className="text-right py-1.5 font-medium">Imbalance</th>
</tr>
</thead>
<tbody>
{allRacks.map(rack => {
const imb = rack.imbalance_pct ?? 0;
const crit = imb >= 15;
return (
<tr key={rack.rack_id} className="border-b border-border/10">
<td className="py-1.5 pr-3 font-medium">{rack.rack_id}</td>
<td className="text-right pr-3 tabular-nums">{rack.phase_a_kw?.toFixed(2) ?? "—"}</td>
<td className="text-right pr-3 tabular-nums">{rack.phase_b_kw?.toFixed(2) ?? "—"}</td>
<td className="text-right pr-3 tabular-nums">{rack.phase_c_kw?.toFixed(2) ?? "—"}</td>
<td className={cn("text-right tabular-nums font-semibold", crit ? "text-destructive" : "text-amber-400")}>
{imb.toFixed(1)}%
</td>
</tr>
);
})}
</tbody>
</table>
</div>
</CardContent>
</Card>
);
}
// ── Page ──────────────────────────────────────────────────────────────────────
export default function PowerPage() {
const [kpis, setKpis] = useState<KpiData | null>(null);
const [racks, setRacks] = useState<RoomPowerBreakdown[]>([]);
const [history, setHistory] = useState<PowerHistoryBucket[]>([]);
const [ups, setUps] = useState<UpsAsset[]>([]);
const [capacity, setCapacity] = useState<CapacitySummary | null>(null);
const [generators, setGenerators] = useState<GeneratorStatus[]>([]);
const [atsUnits, setAtsUnits] = useState<AtsStatus[]>([]);
const [redundancy, setRedundancy] = useState<PowerRedundancy | null>(null);
const [phases, setPhases] = useState<RoomPhase[]>([]);
const [historyHours, setHistoryHours] = useState(6);
const [loading, setLoading] = useState(true);
const [phaseExpanded, setPhaseExpanded] = useState(false);
const [selectedUps, setSelectedUps] = useState<typeof ups[0] | null>(null);
const loadHistory = useCallback(async () => {
try {
const h = await fetchRoomPowerHistory(SITE_ID, historyHours);
setHistory(h);
} catch { /* keep stale */ }
}, [historyHours]);
const load = useCallback(async () => {
try {
const [k, r, h, u, cap, g, a, red, ph] = await Promise.all([
fetchKpis(SITE_ID),
fetchRackBreakdown(SITE_ID),
fetchRoomPowerHistory(SITE_ID, historyHours),
fetchUpsStatus(SITE_ID),
fetchCapacitySummary(SITE_ID),
fetchGeneratorStatus(SITE_ID).catch(() => []),
fetchAtsStatus(SITE_ID).catch(() => []),
fetchPowerRedundancy(SITE_ID).catch(() => null),
fetchPhaseBreakdown(SITE_ID).catch(() => []),
]);
setKpis(k);
setRacks(r);
setHistory(h);
setUps(u);
setCapacity(cap);
setGenerators(g);
setAtsUnits(a);
setRedundancy(red);
setPhases(ph);
} catch {
// keep stale data
} finally {
setLoading(false);
}
}, [historyHours]);
useEffect(() => {
load();
const id = setInterval(load, 30_000);
return () => clearInterval(id);
}, [load]);
useEffect(() => { loadHistory(); }, [historyHours, loadHistory]);
const totalKw = kpis?.total_power_kw ?? 0;
const hallAKw = racks.find((r) => r.room_id === "hall-a")?.racks.reduce((s, r) => s + (r.power_kw ?? 0), 0) ?? 0;
const hallBKw = racks.find((r) => r.room_id === "hall-b")?.racks.reduce((s, r) => s + (r.power_kw ?? 0), 0) ?? 0;
const siteCapacity = capacity ? capacity.rooms.reduce((s, r) => s + r.power.capacity_kw, 0) : 0;
// Phase summary data
const allPhaseRacks = phases.flatMap(r => r.racks);
const phaseViolations = allPhaseRacks.filter(r => (r.imbalance_pct ?? 0) > 5);
return (
<div className="p-6 space-y-6">
<div>
<h1 className="text-xl font-semibold">Power Management</h1>
<p className="text-sm text-muted-foreground">Singapore DC01 refreshes every 30s</p>
</div>
{/* Internal anchor sub-nav */}
<div className="sticky top-14 z-20 -mx-6 px-6 py-2 bg-background/95 backdrop-blur-sm border-b border-border/30">
<nav className="flex gap-1">
{[
{ label: "Overview", href: "#power-overview" },
{ label: "UPS", href: "#power-ups" },
{ label: "Generator", href: "#power-generator" },
{ label: "Transfer Switch", href: "#power-ats" },
{ label: "Phase Analysis", href: "#power-phase" },
].map(({ label, href }) => (
<a key={href} href={href} className="px-3 py-1 rounded-md text-xs font-medium text-muted-foreground hover:text-foreground hover:bg-muted transition-colors">
{label}
</a>
))}
</nav>
</div>
{/* Site capacity bar */}
<div id="power-overview">
{!loading && siteCapacity > 0 && (
<SiteCapacityBar usedKw={totalKw} capacityKw={siteCapacity} />
)}
</div>
{/* KPIs */}
{loading ? (
<div className="grid grid-cols-2 sm:grid-cols-4 gap-4">
{Array.from({ length: 4 }).map((_, i) => <Skeleton key={i} className="h-20" />)}
</div>
) : (
<div className="grid grid-cols-2 sm:grid-cols-4 gap-4">
<KpiCard label="Total Site Load" value={`${totalKw} kW`} icon={Zap} accent />
<KpiCard label="PUE" value={kpis?.pue.toFixed(2) ?? "—"} icon={Activity} sub="Target: < 1.4" />
<KpiCard label="Hall A" value={`${hallAKw.toFixed(1)} kW`} icon={Zap} />
<KpiCard label="Hall B" value={`${hallBKw.toFixed(1)} kW`} icon={Zap} />
</div>
)}
{/* Power path diagram */}
{!loading && redundancy && (
<div className="rounded-xl border border-border bg-muted/10 px-5 py-3">
<p className="text-[10px] text-muted-foreground uppercase tracking-wider font-semibold mb-3">Power Path</p>
<div className="flex items-center gap-2 flex-wrap">
{[
{ label: "Grid", icon: Zap, ok: redundancy.ats_active_feed !== "generator" },
{ label: "ATS", icon: ArrowLeftRight, ok: true },
{ label: "UPS", icon: Battery, ok: redundancy.ups_online > 0 },
{ label: "Racks", icon: Server, ok: true },
].map(({ label, icon: Icon, ok }, i, arr) => (
<React.Fragment key={label}>
<div className={cn(
"flex items-center gap-2 rounded-lg px-3 py-2 border text-xs font-medium",
ok ? "border-green-500/30 bg-green-500/5 text-green-400" : "border-amber-500/30 bg-amber-500/5 text-amber-400"
)}>
<Icon className="w-3.5 h-3.5" />
{label}
</div>
{i < arr.length - 1 && (
<div className="h-px flex-1 min-w-4 border-t border-dashed border-muted-foreground/30" />
)}
</React.Fragment>
))}
{redundancy.ats_active_feed === "generator" && (
<span className="ml-auto text-[10px] text-amber-400 font-medium flex items-center gap-1">
<AlertTriangle className="w-3 h-3" /> Running on generator
</span>
)}
</div>
</div>
)}
{/* Charts */}
<div className="flex items-center justify-between">
<p className="text-xs text-muted-foreground font-medium uppercase tracking-wider">Power History</p>
<select
value={historyHours}
onChange={(e) => setHistoryHours(Number(e.target.value))}
className="text-xs bg-muted border border-border rounded px-2 py-1 text-foreground"
>
{[1, 3, 6, 12, 24].map((h) => (
<option key={h} value={h}>{h}h</option>
))}
</select>
</div>
<div className="space-y-4">
{loading ? (
<>
<Skeleton className="h-64" />
<Skeleton className="h-64" />
</>
) : (
<>
{racks.length > 0 && <RackPowerChart rooms={racks} />}
<RoomPowerHistoryChart data={history} />
</>
)}
</div>
{/* Redundancy banner */}
{!loading && redundancy && <RedundancyBanner r={redundancy} />}
{/* UPS */}
<div id="power-ups">
<h2 className="text-xs font-semibold uppercase tracking-widest text-muted-foreground mb-3">UPS Units</h2>
{loading ? (
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<Skeleton className="h-48" />
<Skeleton className="h-48" />
</div>
) : (
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
{ups.map((u) => (
<UpsCard key={u.ups_id} ups={u} onClick={() => setSelectedUps(u)} />
))}
</div>
)}
</div>
{/* Generator */}
{(loading || generators.length > 0) && (
<div id="power-generator">
<h2 className="text-xs font-semibold uppercase tracking-widest text-muted-foreground mb-3">Generators</h2>
{loading ? (
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4"><Skeleton className="h-48" /></div>
) : (
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
{generators.map((g) => <GeneratorCard key={g.gen_id} gen={g} />)}
</div>
)}
</div>
)}
{/* ATS */}
{(loading || atsUnits.length > 0) && (
<div id="power-ats">
<h2 className="text-xs font-semibold uppercase tracking-widest text-muted-foreground mb-3">Transfer Switches</h2>
{loading ? (
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4"><Skeleton className="h-40" /></div>
) : (
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
{atsUnits.map((a) => <AtsCard key={a.ats_id} ats={a} />)}
</div>
)}
</div>
)}
{/* UPS detail sheet */}
<UpsDetailSheet ups={selectedUps} onClose={() => setSelectedUps(null)} />
{/* Phase analysis — always visible summary */}
<div id="power-phase">
{!loading && phases.length > 0 && (
<div className="space-y-3">
<div className="flex items-center justify-between">
<h2 className="text-xs font-semibold uppercase tracking-widest text-muted-foreground">Phase Analysis</h2>
{phaseViolations.length === 0 ? (
<span className="flex items-center gap-1.5 text-[10px] font-semibold px-2 py-0.5 rounded-full bg-green-500/10 text-green-400">
<CheckCircle2 className="w-3 h-3" /> Phase balance OK
</span>
) : (
<button
onClick={() => setPhaseExpanded(!phaseExpanded)}
className="flex items-center gap-1.5 text-[10px] font-semibold px-2 py-0.5 rounded-full bg-amber-500/10 text-amber-400 hover:bg-amber-500/20 transition-colors"
>
<AlertTriangle className="w-3 h-3" />
{phaseViolations.length} rack{phaseViolations.length !== 1 ? "s" : ""} flagged
{phaseExpanded ? " — Hide details" : " — Show details"}
</button>
)}
</div>
{/* Phase summary row */}
<div className="grid grid-cols-3 gap-3">
{(["Phase A", "Phase B", "Phase C"] as const).map((phase, idx) => {
const phaseKey = (["phase_a_kw", "phase_b_kw", "phase_c_kw"] as const)[idx];
const total = allPhaseRacks.reduce((s, r) => s + (r[phaseKey] ?? 0), 0);
return (
<div key={phase} className="rounded-lg bg-muted/20 px-4 py-3 text-center">
<p className="text-[10px] text-muted-foreground uppercase tracking-wide mb-1">{phase}</p>
<p className="text-lg font-bold tabular-nums">{total.toFixed(1)} kW</p>
</div>
);
})}
</div>
{/* Expanded violation table */}
{phaseExpanded && phaseViolations.length > 0 && (
<PhaseImbalanceTable rooms={phases} />
)}
</div>
)}
</div>
</div>
);
}

View file

@ -0,0 +1,426 @@
"use client";
import { useEffect, useState, useCallback } from "react";
import { toast } from "sonner";
import { fetchReportSummary, fetchKpis, fetchAlarmStats, fetchEnergyReport, reportExportUrl, type ReportSummary, type KpiData, type AlarmStats, type EnergyReport } from "@/lib/api";
import { PageShell } from "@/components/layout/page-shell";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Skeleton } from "@/components/ui/skeleton";
import {
Zap, Thermometer, Bell, AlertTriangle, CheckCircle2, Clock,
Download, Wind, Battery, RefreshCw, Activity, DollarSign,
} from "lucide-react";
import {
AreaChart, Area, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer,
} from "recharts";
import { cn } from "@/lib/utils";
const SITE_ID = "sg-01";
function UptimeBar({ pct }: { pct: number }) {
const color = pct >= 99 ? "bg-green-500" : pct >= 95 ? "bg-amber-500" : "bg-destructive";
return (
<div className="flex items-center gap-3">
<div className="flex-1 h-2 rounded-full bg-muted overflow-hidden">
<div className={cn("h-full rounded-full transition-all", color)} style={{ width: `${pct}%` }} />
</div>
<span className={cn(
"text-sm font-bold tabular-nums w-16 text-right",
pct >= 99 ? "text-green-400" : pct >= 95 ? "text-amber-400" : "text-destructive"
)}>
{pct.toFixed(1)}%
</span>
</div>
);
}
function ExportCard({ hours, setHours }: { hours: number; setHours: (h: number) => void }) {
const exports: { label: string; type: "power" | "temperature" | "alarms"; icon: React.ElementType }[] = [
{ label: "Power History", type: "power", icon: Zap },
{ label: "Temperature History", type: "temperature", icon: Thermometer },
{ label: "Alarm Log", type: "alarms", icon: Bell },
];
return (
<Card>
<CardHeader className="pb-3">
<div className="flex items-center justify-between">
<CardTitle className="text-sm font-semibold flex items-center gap-2">
<Download className="w-4 h-4 text-primary" /> Export Data
</CardTitle>
<div className="flex items-center gap-1">
{([24, 48, 168] as const).map((h) => (
<button
key={h}
onClick={() => setHours(h)}
className={cn(
"px-2 py-0.5 rounded text-xs font-medium transition-colors",
hours === h ? "bg-primary text-primary-foreground" : "text-muted-foreground hover:text-foreground hover:bg-muted"
)}
>
{h === 168 ? "7d" : `${h}h`}
</button>
))}
</div>
</div>
</CardHeader>
<CardContent className="space-y-2">
{exports.map(({ label, type, icon: Icon }) => (
<a
key={type}
href={reportExportUrl(type, SITE_ID, hours)}
download
className="flex items-center justify-between rounded-lg border border-border px-3 py-2.5 hover:bg-muted/40 transition-colors group"
>
<div className="flex items-center gap-2 text-sm">
<Icon className="w-4 h-4 text-muted-foreground group-hover:text-primary transition-colors" />
<span>{label}</span>
{type !== "alarms" && (
<span className="text-xs text-muted-foreground">
(last {hours === 168 ? "7 days" : `${hours}h`})
</span>
)}
</div>
<Download className="w-3.5 h-3.5 text-muted-foreground group-hover:text-primary transition-colors" />
</a>
))}
<p className="text-[10px] text-muted-foreground pt-1">CSV format 5-minute bucketed averages</p>
</CardContent>
</Card>
);
}
const RANGE_HOURS: Record<"24h" | "7d" | "30d", number> = { "24h": 24, "7d": 168, "30d": 720 };
const RANGE_DAYS: Record<"24h" | "7d" | "30d", number> = { "24h": 1, "7d": 7, "30d": 30 };
export default function ReportsPage() {
const [summary, setSummary] = useState<ReportSummary | null>(null);
const [kpis, setKpis] = useState<KpiData | null>(null);
const [alarmStats, setAlarmStats] = useState<AlarmStats | null>(null);
const [energy, setEnergy] = useState<EnergyReport | null>(null);
const [loading, setLoading] = useState(true);
const [exportHours, setExportHours] = useState(720);
const [dateRange, setDateRange] = useState<"24h" | "7d" | "30d">("30d");
const load = useCallback(async () => {
try {
const [s, k, a, e] = await Promise.all([
fetchReportSummary(SITE_ID),
fetchKpis(SITE_ID),
fetchAlarmStats(SITE_ID),
fetchEnergyReport(SITE_ID, RANGE_DAYS[dateRange]).catch(() => null),
]);
setSummary(s);
setKpis(k);
setAlarmStats(a);
setEnergy(e);
} catch { toast.error("Failed to load report data"); }
finally { setLoading(false); }
}, [dateRange]);
useEffect(() => { load(); }, [load]);
function handleRangeChange(r: "24h" | "7d" | "30d") {
setDateRange(r);
setExportHours(RANGE_HOURS[r]);
}
return (
<PageShell className="p-6">
<div className="flex items-start justify-between gap-4 flex-wrap">
<div>
<h1 className="text-xl font-semibold">Reports</h1>
<p className="text-sm text-muted-foreground">Singapore DC01 site summary &amp; data exports</p>
</div>
<div className="flex items-center gap-2">
{/* Date range picker */}
<div className="flex items-center gap-0.5 rounded-lg border border-border p-0.5 text-xs">
{(["24h", "7d", "30d"] as const).map((r) => (
<button
key={r}
onClick={() => handleRangeChange(r)}
className={cn(
"px-2.5 py-1 rounded-md font-medium transition-colors",
dateRange === r ? "bg-primary text-primary-foreground" : "text-muted-foreground hover:text-foreground hover:bg-muted"
)}
>
{r}
</button>
))}
</div>
<button
onClick={load}
className="flex items-center gap-1.5 text-xs text-muted-foreground hover:text-foreground transition-colors"
>
<RefreshCw className="w-3.5 h-3.5" />
Refresh
</button>
</div>
</div>
{/* Export always visible at top */}
<ExportCard hours={exportHours} setHours={setExportHours} />
{loading ? (
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{Array.from({ length: 4 }).map((_, i) => <Skeleton key={i} className="h-48" />)}
</div>
) : !summary ? (
<div className="flex items-center justify-center h-48 text-sm text-muted-foreground">
Unable to load report data.
</div>
) : (
<>
<p className="text-xs text-muted-foreground">
Generated {new Date(summary.generated_at).toLocaleString()} · Showing last {dateRange}
</p>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{/* KPI snapshot — expanded */}
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-sm font-semibold flex items-center gap-2">
<Zap className="w-4 h-4 text-primary" /> Site KPI Snapshot
</CardTitle>
</CardHeader>
<CardContent className="grid grid-cols-2 gap-4 sm:grid-cols-3">
<div className="space-y-1">
<p className="text-xs text-muted-foreground flex items-center gap-1">
<Zap className="w-3 h-3" /> Total Power
</p>
<p className="text-2xl font-bold">
{summary.kpis.total_power_kw} <span className="text-sm font-normal text-muted-foreground">kW</span>
</p>
</div>
<div className="space-y-1">
<p className="text-xs text-muted-foreground flex items-center gap-1">
<Activity className="w-3 h-3" /> PUE
</p>
<p className={cn(
"text-2xl font-bold",
kpis && kpis.pue > 1.5 ? "text-amber-400" : ""
)}>
{kpis?.pue.toFixed(2) ?? "—"}
<span className="text-xs font-normal text-muted-foreground ml-1">target &lt;1.4</span>
</p>
</div>
<div className="space-y-1">
<p className="text-xs text-muted-foreground flex items-center gap-1">
<Thermometer className="w-3 h-3" /> Avg Temp
</p>
<p className={cn(
"text-2xl font-bold",
summary.kpis.avg_temperature >= 28 ? "text-destructive" :
summary.kpis.avg_temperature >= 25 ? "text-amber-400" : ""
)}>
{summary.kpis.avg_temperature}°<span className="text-sm font-normal text-muted-foreground">C</span>
</p>
</div>
<div className="space-y-1">
<p className="text-xs text-muted-foreground flex items-center gap-1">
<Bell className="w-3 h-3" /> Active Alarms
</p>
<p className={cn("text-2xl font-bold", (alarmStats?.active ?? 0) > 0 ? "text-destructive" : "")}>
{alarmStats?.active ?? "—"}
</p>
</div>
<div className="space-y-1">
<p className="text-xs text-muted-foreground flex items-center gap-1">
<AlertTriangle className="w-3 h-3 text-destructive" /> Critical
</p>
<p className={cn("text-2xl font-bold", (alarmStats?.critical ?? 0) > 0 ? "text-destructive" : "")}>
{alarmStats?.critical ?? "—"}
</p>
</div>
<div className="space-y-1">
<p className="text-xs text-muted-foreground flex items-center gap-1">
<CheckCircle2 className="w-3 h-3 text-green-400" /> Resolved
</p>
<p className="text-2xl font-bold text-green-400">
{alarmStats?.resolved ?? "—"}
</p>
</div>
</CardContent>
</Card>
{/* Alarm breakdown */}
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-sm font-semibold flex items-center gap-2">
<Bell className="w-4 h-4 text-primary" /> Alarm Breakdown
</CardTitle>
</CardHeader>
<CardContent>
<div className="grid grid-cols-3 gap-3">
{[
{ label: "Active", value: summary.alarm_stats.active, icon: AlertTriangle, color: "text-destructive" },
{ label: "Acknowledged", value: summary.alarm_stats.acknowledged, icon: Clock, color: "text-amber-400" },
{ label: "Resolved", value: summary.alarm_stats.resolved, icon: CheckCircle2, color: "text-green-400" },
].map(({ label, value, icon: Icon, color }) => (
<div key={label} className="text-center space-y-1 rounded-lg bg-muted/30 p-3">
<Icon className={cn("w-4 h-4 mx-auto", color)} />
<p className={cn("text-xl font-bold", value > 0 && label === "Active" ? "text-destructive" : "")}>{value}</p>
<p className="text-[10px] text-muted-foreground">{label}</p>
</div>
))}
</div>
<div className="flex items-center gap-4 mt-4 pt-3 border-t border-border text-xs">
<span className="text-muted-foreground">By severity:</span>
<span className="flex items-center gap-1 text-destructive font-medium">
<span className="w-1.5 h-1.5 rounded-full bg-destructive" />
{summary.alarm_stats.critical} critical
</span>
<span className="flex items-center gap-1 text-amber-400 font-medium">
<span className="w-1.5 h-1.5 rounded-full bg-amber-500" />
{summary.alarm_stats.warning} warning
</span>
</div>
</CardContent>
</Card>
{/* CRAC uptime */}
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-sm font-semibold flex items-center gap-2">
<Wind className="w-4 h-4 text-primary" /> CRAC Uptime (last 24h)
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
{summary.crac_uptime.map((crac) => (
<div key={crac.crac_id} className="space-y-1.5">
<div className="flex justify-between text-xs">
<span className="font-medium">{crac.crac_id.toUpperCase()}</span>
<span className="text-muted-foreground">{crac.room_id}</span>
</div>
<UptimeBar pct={crac.uptime_pct} />
</div>
))}
{summary.crac_uptime.length === 0 && (
<p className="text-sm text-muted-foreground text-center py-4">No CRAC data available</p>
)}
</CardContent>
</Card>
{/* UPS uptime */}
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-sm font-semibold flex items-center gap-2">
<Battery className="w-4 h-4 text-primary" /> UPS Uptime (last 24h)
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
{summary.ups_uptime.map((ups) => (
<div key={ups.ups_id} className="space-y-1.5">
<div className="text-xs font-medium">{ups.ups_id.toUpperCase()}</div>
<UptimeBar pct={ups.uptime_pct} />
</div>
))}
{summary.ups_uptime.length === 0 && (
<p className="text-sm text-muted-foreground text-center py-4">No UPS data available</p>
)}
</CardContent>
</Card>
</div>
{/* Energy cost section */}
{energy && (
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-sm font-semibold flex items-center gap-2">
<DollarSign className="w-4 h-4 text-primary" /> Energy Cost Last {energy.period_days} Days
</CardTitle>
</CardHeader>
<CardContent>
<div className="grid grid-cols-2 sm:grid-cols-4 gap-4 mb-6">
<div className="space-y-1">
<p className="text-xs text-muted-foreground">Total kWh</p>
<p className="text-2xl font-bold">{energy.kwh_total.toFixed(0)}</p>
<p className="text-[10px] text-muted-foreground">{energy.from_date} {energy.to_date}</p>
</div>
<div className="space-y-1">
<p className="text-xs text-muted-foreground">Cost ({energy.currency})</p>
<p className="text-2xl font-bold">${energy.cost_sgd.toLocaleString()}</p>
<p className="text-[10px] text-muted-foreground">@ ${energy.tariff_sgd_kwh}/kWh</p>
</div>
<div className="space-y-1">
<p className="text-xs text-muted-foreground">Est. Annual kWh</p>
<p className="text-2xl font-bold">{(energy.pue_trend.length > 0 ? energy.kwh_total / energy.period_days * 365 : 0).toFixed(0)}</p>
<p className="text-[10px] text-muted-foreground">at current pace</p>
</div>
<div className="space-y-1">
<p className="text-xs text-muted-foreground">PUE (estimated)</p>
<p className={cn("text-2xl font-bold", energy.pue_estimated > 1.5 ? "text-amber-400" : "")}>{energy.pue_estimated.toFixed(2)}</p>
<p className="text-[10px] text-muted-foreground">target &lt; 1.4</p>
</div>
</div>
{(() => {
const trend = energy.pue_trend ?? [];
const thisWeek = trend.slice(-7);
const lastWeek = trend.slice(-14, -7);
const thisKwh = thisWeek.reduce((s, d) => s + d.avg_it_kw * 24, 0);
const lastKwh = lastWeek.reduce((s, d) => s + d.avg_it_kw * 24, 0);
const kwhDelta = lastKwh > 0 ? ((thisKwh - lastKwh) / lastKwh * 100) : 0;
const thisAvgKw = thisWeek.length > 0 ? thisWeek.reduce((s, d) => s + d.avg_it_kw, 0) / thisWeek.length : 0;
const lastAvgKw = lastWeek.length > 0 ? lastWeek.reduce((s, d) => s + d.avg_it_kw, 0) / lastWeek.length : 0;
const kwDelta = lastAvgKw > 0 ? ((thisAvgKw - lastAvgKw) / lastAvgKw * 100) : 0;
if (thisWeek.length === 0 || lastWeek.length === 0) return null;
return (
<div className="rounded-lg border border-border bg-muted/10 px-4 py-3 flex items-center gap-6 text-xs flex-wrap mb-6">
<span className="text-muted-foreground font-medium">This week vs last week:</span>
<div className="flex items-center gap-1.5">
<span className="text-muted-foreground">kWh:</span>
<span className="font-bold">{thisKwh.toFixed(0)}</span>
<span className={cn(
"font-semibold",
kwhDelta > 5 ? "text-destructive" : kwhDelta < -5 ? "text-green-400" : "text-muted-foreground"
)}>
({kwhDelta > 0 ? "+" : ""}{kwhDelta.toFixed(1)}%)
</span>
</div>
<div className="flex items-center gap-1.5">
<span className="text-muted-foreground">Avg IT load:</span>
<span className="font-bold">{thisAvgKw.toFixed(1)} kW</span>
<span className={cn(
"font-semibold",
kwDelta > 5 ? "text-amber-400" : kwDelta < -5 ? "text-green-400" : "text-muted-foreground"
)}>
({kwDelta > 0 ? "+" : ""}{kwDelta.toFixed(1)}%)
</span>
</div>
<span className="text-muted-foreground text-[10px] ml-auto">based on last {thisWeek.length + lastWeek.length} days of data</span>
</div>
);
})()}
{energy.pue_trend.length > 0 && (
<>
<p className="text-xs text-muted-foreground mb-2 uppercase font-medium tracking-wider">Daily IT Load (kW)</p>
<ResponsiveContainer width="100%" height={140}>
<AreaChart data={energy.pue_trend} margin={{ top: 4, right: 4, left: -20, bottom: 0 }}>
<defs>
<linearGradient id="energy-grad" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor="oklch(0.62 0.17 212)" stopOpacity={0.3} />
<stop offset="95%" stopColor="oklch(0.62 0.17 212)" stopOpacity={0} />
</linearGradient>
</defs>
<CartesianGrid strokeDasharray="3 3" stroke="oklch(1 0 0 / 8%)" />
<XAxis dataKey="day" tick={{ fontSize: 9, fill: "oklch(0.65 0.04 257)" }} tickLine={false} axisLine={false}
tickFormatter={(v) => new Date(v).toLocaleDateString([], { month: "short", day: "numeric" })} />
<YAxis tick={{ fontSize: 9, fill: "oklch(0.65 0.04 257)" }} tickLine={false} axisLine={false} />
<Tooltip
contentStyle={{ backgroundColor: "oklch(0.16 0.04 265)", border: "1px solid oklch(1 0 0 / 9%)", borderRadius: "6px", fontSize: "11px" }}
formatter={(v) => [`${Number(v).toFixed(1)} kW`, "Avg IT Load"]}
labelFormatter={(l) => new Date(l).toLocaleDateString()}
/>
<Area type="monotone" dataKey="avg_it_kw" stroke="oklch(0.62 0.17 212)" fill="url(#energy-grad)" strokeWidth={2} dot={false} />
</AreaChart>
</ResponsiveContainer>
</>
)}
</CardContent>
</Card>
)}
</>
)}
</PageShell>
);
}

View file

@ -0,0 +1,468 @@
"use client";
import { useEffect, useState, useCallback } from "react";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
import {
Settings, Database, Bell, Globe, Sliders, Plug,
Save, CheckCircle2, AlertTriangle,
} from "lucide-react";
import {
fetchSiteSettings, updateSiteSettings,
fetchNotifications, updateNotifications,
fetchIntegrations, updateIntegrations,
fetchPagePrefs, updatePagePrefs,
type SiteSettings, type NotificationSettings,
type IntegrationSettings, type PagePrefs,
type SensorDevice,
} from "@/lib/api";
import { SensorTable } from "@/components/settings/SensorTable";
import { SensorSheet } from "@/components/settings/SensorSheet";
import { SensorDetailSheet } from "@/components/settings/SensorDetailSheet";
import { ThresholdEditor } from "@/components/settings/ThresholdEditor";
const SITE_ID = "sg-01";
// ── Helpers ────────────────────────────────────────────────────────────────────
function FieldGroup({ label, hint, children }: { label: string; hint?: string; children: React.ReactNode }) {
return (
<div className="space-y-1.5">
<label className="text-xs font-medium text-muted-foreground">{label}</label>
{children}
{hint && <p className="text-[10px] text-muted-foreground">{hint}</p>}
</div>
);
}
function TextInput({ value, onChange, disabled, placeholder, type = "text" }: {
value: string; onChange?: (v: string) => void; disabled?: boolean; placeholder?: string; type?: string;
}) {
return (
<input
type={type}
value={value}
disabled={disabled}
placeholder={placeholder}
onChange={e => onChange?.(e.target.value)}
className="w-full border border-border rounded-md px-3 py-1.5 text-sm bg-background text-foreground focus:outline-none focus:ring-1 focus:ring-primary disabled:opacity-50"
/>
);
}
function NumberInput({ value, onChange, min, max }: { value: number; onChange: (v: number) => void; min?: number; max?: number }) {
return (
<input
type="number"
value={value}
min={min}
max={max}
onChange={e => onChange(Number(e.target.value))}
className="w-full border border-border rounded-md px-3 py-1.5 text-sm bg-background text-foreground focus:outline-none focus:ring-1 focus:ring-primary"
/>
);
}
function Toggle({ checked, onChange, label }: { checked: boolean; onChange: (v: boolean) => void; label?: string }) {
return (
<div className="flex items-center gap-3">
<button
role="switch"
aria-checked={checked}
onClick={() => onChange(!checked)}
className={cn(
"relative inline-flex h-5 w-9 shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors",
checked ? "bg-primary" : "bg-muted",
)}
>
<span className={cn(
"inline-block h-4 w-4 transform rounded-full bg-white shadow transition-transform",
checked ? "translate-x-4" : "translate-x-0",
)} />
</button>
{label && <span className="text-sm">{label}</span>}
</div>
);
}
function SaveButton({ saving, saved, onClick }: { saving: boolean; saved: boolean; onClick: () => void }) {
return (
<Button size="sm" onClick={onClick} disabled={saving} className="gap-2">
{saving ? (
<span className="w-3.5 h-3.5 border-2 border-white/30 border-t-white rounded-full animate-spin" />
) : saved ? (
<CheckCircle2 className="w-3.5 h-3.5" />
) : (
<Save className="w-3.5 h-3.5" />
)}
{saving ? "Saving..." : saved ? "Saved" : "Save Changes"}
</Button>
);
}
function SectionCard({ title, children, action }: { title: string; children: React.ReactNode; action?: React.ReactNode }) {
return (
<Card>
<CardHeader className="pb-3 border-b border-border/30">
<div className="flex items-center justify-between">
<CardTitle className="text-sm font-semibold">{title}</CardTitle>
{action}
</div>
</CardHeader>
<CardContent className="pt-4 space-y-4">
{children}
</CardContent>
</Card>
);
}
// ── Site Tab ───────────────────────────────────────────────────────────────────
function SiteTab() {
const [form, setForm] = useState<SiteSettings>({ name: "", timezone: "", description: "" });
const [saving, setSaving] = useState(false);
const [saved, setSaved] = useState(false);
useEffect(() => {
fetchSiteSettings(SITE_ID).then(setForm).catch(() => {});
}, []);
const save = async () => {
setSaving(true);
try {
const updated = await updateSiteSettings(SITE_ID, form);
setForm(updated);
setSaved(true);
setTimeout(() => setSaved(false), 3000);
} catch { /* ignore */ }
finally { setSaving(false); }
};
const TIMEZONES = [
"Asia/Singapore", "Asia/Tokyo", "Asia/Hong_Kong", "Asia/Kuala_Lumpur",
"Europe/London", "Europe/Paris", "America/New_York", "America/Los_Angeles",
"UTC",
];
return (
<div className="space-y-4 max-w-xl">
<SectionCard title="Site Information">
<FieldGroup label="Site Name">
<TextInput value={form.name} onChange={v => setForm(f => ({ ...f, name: v }))} />
</FieldGroup>
<FieldGroup label="Description">
<TextInput value={form.description} onChange={v => setForm(f => ({ ...f, description: v }))} placeholder="Optional description" />
</FieldGroup>
<FieldGroup label="Timezone">
<select
value={form.timezone}
onChange={e => setForm(f => ({ ...f, timezone: e.target.value }))}
className="w-full border border-border rounded-md px-3 py-1.5 text-sm bg-background text-foreground focus:outline-none focus:ring-1 focus:ring-primary"
>
{TIMEZONES.map(tz => <option key={tz} value={tz}>{tz}</option>)}
</select>
</FieldGroup>
<div className="pt-2">
<SaveButton saving={saving} saved={saved} onClick={save} />
</div>
</SectionCard>
<SectionCard title="Site Overview">
<div className="grid grid-cols-2 gap-3 text-xs">
{[
{ label: "Site ID", value: SITE_ID },
{ label: "Halls", value: "2 (Hall A, Hall B)" },
{ label: "Total Racks", value: "80 (40 per hall)" },
{ label: "UPS Units", value: "2" },
{ label: "Generators", value: "1" },
{ label: "CRAC Units", value: "2" },
].map(({ label, value }) => (
<div key={label}>
<p className="text-muted-foreground">{label}</p>
<p className="font-medium mt-0.5">{value}</p>
</div>
))}
</div>
</SectionCard>
</div>
);
}
// ── Sensors Tab ────────────────────────────────────────────────────────────────
function SensorsTab() {
const [editingSensor, setEditingSensor] = useState<SensorDevice | null>(null);
const [addOpen, setAddOpen] = useState(false);
const [detailId, setDetailId] = useState<number | null>(null);
const [tableKey, setTableKey] = useState(0); // force re-render after save
const handleSaved = () => {
setAddOpen(false);
setEditingSensor(null);
setTableKey(k => k + 1);
};
return (
<div className="space-y-4">
<div className="text-sm text-muted-foreground">
Device-level sensor registry. Each device can be enabled/disabled, and protocol configuration is stored for all supported connection types. Currently only MQTT is active.
</div>
<SensorTable
key={tableKey}
onAdd={() => setAddOpen(true)}
onEdit={s => setEditingSensor(s)}
onDetail={id => setDetailId(id)}
/>
<SensorSheet
siteId={SITE_ID}
sensor={editingSensor}
open={addOpen || !!editingSensor}
onClose={() => { setAddOpen(false); setEditingSensor(null); }}
onSaved={handleSaved}
/>
<SensorDetailSheet
sensorId={detailId}
onClose={() => setDetailId(null)}
/>
</div>
);
}
// ── Notifications Tab ──────────────────────────────────────────────────────────
function NotificationsTab() {
const [form, setForm] = useState<NotificationSettings>({
critical_alarms: true, warning_alarms: true,
generator_events: true, maintenance_reminders: true,
webhook_url: "", email_recipients: "",
});
const [saving, setSaving] = useState(false);
const [saved, setSaved] = useState(false);
useEffect(() => {
fetchNotifications(SITE_ID).then(setForm).catch(() => {});
}, []);
const save = async () => {
setSaving(true);
try {
const updated = await updateNotifications(SITE_ID, form);
setForm(updated);
setSaved(true);
setTimeout(() => setSaved(false), 3000);
} catch { /* ignore */ }
finally { setSaving(false); }
};
const toggles: { key: keyof NotificationSettings; label: string; desc: string }[] = [
{ key: "critical_alarms", label: "Critical alarms", desc: "Notify on all critical severity alarms" },
{ key: "warning_alarms", label: "Warning alarms", desc: "Notify on warning severity alarms" },
{ key: "generator_events", label: "Generator events", desc: "Generator start, stop, and fault events" },
{ key: "maintenance_reminders", label: "Maintenance reminders", desc: "Upcoming scheduled maintenance windows" },
];
return (
<div className="space-y-4 max-w-xl">
<SectionCard title="Alarm Notifications">
<div className="space-y-4">
{toggles.map(({ key, label, desc }) => (
<div key={key} className="flex items-center justify-between gap-4">
<div>
<p className="text-sm font-medium">{label}</p>
<p className="text-xs text-muted-foreground">{desc}</p>
</div>
<Toggle
checked={form[key] as boolean}
onChange={v => setForm(f => ({ ...f, [key]: v }))}
/>
</div>
))}
</div>
</SectionCard>
<SectionCard title="Delivery Channels">
<FieldGroup label="Webhook URL" hint="POST request sent on each new alarm — leave blank to disable">
<TextInput
value={form.webhook_url}
onChange={v => setForm(f => ({ ...f, webhook_url: v }))}
placeholder="https://hooks.example.com/alarm"
type="url"
/>
</FieldGroup>
<FieldGroup label="Email Recipients" hint="Comma-separated email addresses">
<TextInput
value={form.email_recipients}
onChange={v => setForm(f => ({ ...f, email_recipients: v }))}
placeholder="ops@example.com, oncall@example.com"
/>
</FieldGroup>
</SectionCard>
<div>
<SaveButton saving={saving} saved={saved} onClick={save} />
</div>
</div>
);
}
// ── Page Preferences Tab ───────────────────────────────────────────────────────
function PagePrefsTab() {
const [form, setForm] = useState<PagePrefs>({ default_time_range_hours: 6, refresh_interval_seconds: 30 });
const [saving, setSaving] = useState(false);
const [saved, setSaved] = useState(false);
useEffect(() => {
fetchPagePrefs(SITE_ID).then(setForm).catch(() => {});
}, []);
const save = async () => {
setSaving(true);
try {
const updated = await updatePagePrefs(SITE_ID, form);
setForm(updated);
setSaved(true);
setTimeout(() => setSaved(false), 3000);
} catch { /* ignore */ }
finally { setSaving(false); }
};
return (
<div className="space-y-4 max-w-xl">
<SectionCard title="Dashboard Defaults">
<FieldGroup label="Default Time Range" hint="Used by charts on all pages as the initial view">
<select
value={form.default_time_range_hours}
onChange={e => setForm(f => ({ ...f, default_time_range_hours: Number(e.target.value) }))}
className="w-full border border-border rounded-md px-3 py-1.5 text-sm bg-background text-foreground focus:outline-none focus:ring-1 focus:ring-primary"
>
{[1, 3, 6, 12, 24].map(h => <option key={h} value={h}>{h} hour{h !== 1 ? "s" : ""}</option>)}
</select>
</FieldGroup>
<FieldGroup label="Auto-refresh Interval" hint="How often pages poll for new data">
<select
value={form.refresh_interval_seconds}
onChange={e => setForm(f => ({ ...f, refresh_interval_seconds: Number(e.target.value) }))}
className="w-full border border-border rounded-md px-3 py-1.5 text-sm bg-background text-foreground focus:outline-none focus:ring-1 focus:ring-primary"
>
{[10, 15, 30, 60, 120].map(s => <option key={s} value={s}>Every {s} seconds</option>)}
</select>
</FieldGroup>
<div className="pt-2">
<SaveButton saving={saving} saved={saved} onClick={save} />
</div>
</SectionCard>
<div className="rounded-lg border border-border/30 bg-muted/10 px-4 py-3 text-xs text-muted-foreground">
Per-page configuration (visible panels, default overlays, etc.) is coming in a future update.
</div>
</div>
);
}
// ── Integrations Tab ───────────────────────────────────────────────────────────
function IntegrationsTab() {
const [form, setForm] = useState<IntegrationSettings>({ mqtt_host: "mqtt", mqtt_port: 1883 });
const [saving, setSaving] = useState(false);
const [saved, setSaved] = useState(false);
useEffect(() => {
fetchIntegrations(SITE_ID).then(setForm).catch(() => {});
}, []);
const save = async () => {
setSaving(true);
try {
const updated = await updateIntegrations(SITE_ID, form);
setForm(updated);
setSaved(true);
setTimeout(() => setSaved(false), 3000);
} catch { /* ignore */ }
finally { setSaving(false); }
};
const future = [
{ name: "Slack", desc: "Post alarm notifications to a Slack channel" },
{ name: "PagerDuty", desc: "Create incidents for critical alarms" },
{ name: "Email SMTP", desc: "Send alarm emails via custom SMTP server" },
{ name: "Syslog", desc: "Forward alarms to syslog / SIEM" },
];
return (
<div className="space-y-4 max-w-xl">
<SectionCard title="MQTT Broker">
<div className="grid grid-cols-3 gap-3">
<FieldGroup label="Host" hint="Broker hostname or IP">
<TextInput value={form.mqtt_host} onChange={v => setForm(f => ({ ...f, mqtt_host: v }))} />
</FieldGroup>
<FieldGroup label="Port">
<NumberInput value={form.mqtt_port} onChange={v => setForm(f => ({ ...f, mqtt_port: v }))} min={1} max={65535} />
</FieldGroup>
</div>
<div className="rounded-md bg-amber-500/5 border border-amber-500/20 px-3 py-2 text-xs text-amber-400 flex items-start gap-2">
<AlertTriangle className="w-3.5 h-3.5 mt-0.5 shrink-0" />
Changing the MQTT broker requires a backend restart to take effect.
</div>
<div>
<SaveButton saving={saving} saved={saved} onClick={save} />
</div>
</SectionCard>
<SectionCard title="Future Integrations">
<div className="grid gap-2">
{future.map(({ name, desc }) => (
<div key={name} className="flex items-center justify-between rounded-lg border border-border/30 bg-muted/10 px-3 py-2.5">
<div>
<p className="text-sm font-medium">{name}</p>
<p className="text-xs text-muted-foreground">{desc}</p>
</div>
<span className="text-[10px] font-semibold px-2 py-0.5 rounded-full bg-muted text-muted-foreground">
Coming soon
</span>
</div>
))}
</div>
</SectionCard>
</div>
);
}
// ── Page ───────────────────────────────────────────────────────────────────────
export default function SettingsPage() {
return (
<div className="p-6 space-y-6">
<div>
<h1 className="text-xl font-semibold flex items-center gap-2">
<Settings className="w-5 h-5 text-primary" />
Settings
</h1>
<p className="text-sm text-muted-foreground">Site-wide configuration for Singapore DC01</p>
</div>
<Tabs defaultValue="site">
<TabsList className="h-9 mb-6">
<TabsTrigger value="site" className="gap-1.5 text-xs px-3"><Globe className="w-3.5 h-3.5" />Site</TabsTrigger>
<TabsTrigger value="sensors" className="gap-1.5 text-xs px-3"><Database className="w-3.5 h-3.5" />Sensors</TabsTrigger>
<TabsTrigger value="thresholds" className="gap-1.5 text-xs px-3"><Sliders className="w-3.5 h-3.5" />Thresholds</TabsTrigger>
<TabsTrigger value="notifications" className="gap-1.5 text-xs px-3"><Bell className="w-3.5 h-3.5" />Notifications</TabsTrigger>
<TabsTrigger value="page-prefs" className="gap-1.5 text-xs px-3"><Settings className="w-3.5 h-3.5" />Page Prefs</TabsTrigger>
<TabsTrigger value="integrations" className="gap-1.5 text-xs px-3"><Plug className="w-3.5 h-3.5" />Integrations</TabsTrigger>
</TabsList>
<TabsContent value="site"> <SiteTab /> </TabsContent>
<TabsContent value="sensors"> <SensorsTab /> </TabsContent>
<TabsContent value="thresholds"> <ThresholdEditor siteId={SITE_ID} /> </TabsContent>
<TabsContent value="notifications"> <NotificationsTab /> </TabsContent>
<TabsContent value="page-prefs"> <PagePrefsTab /> </TabsContent>
<TabsContent value="integrations"> <IntegrationsTab /> </TabsContent>
</Tabs>
</div>
);
}

BIN
frontend/app/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

126
frontend/app/globals.css Normal file
View file

@ -0,0 +1,126 @@
@import "tailwindcss";
@import "tw-animate-css";
@import "shadcn/tailwind.css";
@custom-variant dark (&:is(.dark *));
@theme inline {
--color-background: var(--background);
--color-foreground: var(--foreground);
--font-sans: var(--font-geist-sans);
--font-mono: var(--font-geist-mono);
--color-sidebar-ring: var(--sidebar-ring);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-accent: var(--sidebar-accent);
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
--color-sidebar-primary: var(--sidebar-primary);
--color-sidebar-foreground: var(--sidebar-foreground);
--color-sidebar: var(--sidebar);
--color-chart-5: var(--chart-5);
--color-chart-4: var(--chart-4);
--color-chart-3: var(--chart-3);
--color-chart-2: var(--chart-2);
--color-chart-1: var(--chart-1);
--color-ring: var(--ring);
--color-input: var(--input);
--color-border: var(--border);
--color-destructive: var(--destructive);
--color-accent-foreground: var(--accent-foreground);
--color-accent: var(--accent);
--color-muted-foreground: var(--muted-foreground);
--color-muted: var(--muted);
--color-secondary-foreground: var(--secondary-foreground);
--color-secondary: var(--secondary);
--color-primary-foreground: var(--primary-foreground);
--color-primary: var(--primary);
--color-popover-foreground: var(--popover-foreground);
--color-popover: var(--popover);
--color-card-foreground: var(--card-foreground);
--color-card: var(--card);
--radius-sm: calc(var(--radius) - 4px);
--radius-md: calc(var(--radius) - 2px);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) + 4px);
--radius-2xl: calc(var(--radius) + 8px);
--radius-3xl: calc(var(--radius) + 12px);
--radius-4xl: calc(var(--radius) + 16px);
}
:root {
--radius: 0.5rem;
--background: oklch(0.97 0.003 247);
--foreground: oklch(0.13 0.042 265);
--card: oklch(1 0 0);
--card-foreground: oklch(0.13 0.042 265);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.13 0.042 265);
--primary: oklch(0.62 0.17 212);
--primary-foreground: oklch(0.98 0.003 248);
--secondary: oklch(0.96 0.007 248);
--secondary-foreground: oklch(0.21 0.042 266);
--muted: oklch(0.96 0.007 248);
--muted-foreground: oklch(0.55 0.046 257);
--accent: oklch(0.96 0.007 248);
--accent-foreground: oklch(0.21 0.042 266);
--destructive: oklch(0.58 0.245 27);
--border: oklch(0.93 0.013 256);
--input: oklch(0.93 0.013 256);
--ring: oklch(0.62 0.17 212);
--chart-1: oklch(0.62 0.17 212);
--chart-2: oklch(0.7 0.15 162);
--chart-3: oklch(0.75 0.18 84);
--chart-4: oklch(0.65 0.22 30);
--chart-5: oklch(0.63 0.27 304);
--sidebar: oklch(0.16 0.04 265);
--sidebar-foreground: oklch(0.92 0.008 248);
--sidebar-primary: oklch(0.62 0.17 212);
--sidebar-primary-foreground: oklch(0.98 0.003 248);
--sidebar-accent: oklch(0.22 0.04 265);
--sidebar-accent-foreground: oklch(0.92 0.008 248);
--sidebar-border: oklch(1 0 0 / 8%);
--sidebar-ring: oklch(0.62 0.17 212);
}
.dark {
--background: oklch(0.11 0.03 265);
--foreground: oklch(0.94 0.005 248);
--card: oklch(0.16 0.04 265);
--card-foreground: oklch(0.94 0.005 248);
--popover: oklch(0.16 0.04 265);
--popover-foreground: oklch(0.94 0.005 248);
--primary: oklch(0.62 0.17 212);
--primary-foreground: oklch(0.98 0.003 248);
--secondary: oklch(0.22 0.04 265);
--secondary-foreground: oklch(0.94 0.005 248);
--muted: oklch(0.22 0.04 265);
--muted-foreground: oklch(0.65 0.04 257);
--accent: oklch(0.22 0.04 265);
--accent-foreground: oklch(0.94 0.005 248);
--destructive: oklch(0.65 0.24 27);
--border: oklch(1 0 0 / 9%);
--input: oklch(1 0 0 / 12%);
--ring: oklch(0.62 0.17 212);
--chart-1: oklch(0.62 0.17 212);
--chart-2: oklch(0.7 0.15 162);
--chart-3: oklch(0.75 0.18 84);
--chart-4: oklch(0.65 0.22 30);
--chart-5: oklch(0.63 0.27 304);
--sidebar: oklch(0.14 0.035 265);
--sidebar-foreground: oklch(0.92 0.008 248);
--sidebar-primary: oklch(0.62 0.17 212);
--sidebar-primary-foreground: oklch(0.98 0.003 248);
--sidebar-accent: oklch(0.22 0.04 265);
--sidebar-accent-foreground: oklch(0.92 0.008 248);
--sidebar-border: oklch(1 0 0 / 8%);
--sidebar-ring: oklch(0.62 0.17 212);
}
@layer base {
* {
@apply border-border outline-ring/50;
}
body {
@apply bg-background text-foreground;
}
}

46
frontend/app/layout.tsx Normal file
View file

@ -0,0 +1,46 @@
import type { Metadata } from "next";
import { Geist, Geist_Mono } from "next/font/google";
import { ClerkProvider } from "@clerk/nextjs";
import { ThemeProvider } from "@/components/theme-provider";
import { TooltipProvider } from "@/components/ui/tooltip";
import "./globals.css";
const geistSans = Geist({
variable: "--font-geist-sans",
subsets: ["latin"],
});
const geistMono = Geist_Mono({
variable: "--font-geist-mono",
subsets: ["latin"],
});
export const metadata: Metadata = {
title: "DemoBMS",
description: "Intelligent Data Center Infrastructure Management",
};
export default function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<ClerkProvider>
<html lang="en" suppressHydrationWarning>
<body className={`${geistSans.variable} ${geistMono.variable} antialiased`}>
<ThemeProvider
attribute="class"
defaultTheme="dark"
enableSystem={false}
disableTransitionOnChange
>
<TooltipProvider delayDuration={0}>
{children}
</TooltipProvider>
</ThemeProvider>
</body>
</html>
</ClerkProvider>
);
}

5
frontend/app/page.tsx Normal file
View file

@ -0,0 +1,5 @@
import { redirect } from "next/navigation";
export default function Home() {
redirect("/dashboard");
}

View file

@ -0,0 +1,21 @@
import { SignIn } from "@clerk/nextjs";
import { Database } from "lucide-react";
export default function SignInPage() {
return (
<div className="flex min-h-screen items-center justify-center bg-background">
<div className="flex flex-col items-center gap-8">
<div className="flex items-center gap-3">
<div className="flex items-center justify-center w-10 h-10 rounded-xl bg-primary">
<Database className="w-5 h-5 text-primary-foreground" />
</div>
<div>
<p className="text-lg font-bold tracking-tight">DemoBMS</p>
<p className="text-xs text-muted-foreground">Infrastructure Management</p>
</div>
</div>
<SignIn />
</div>
</div>
);
}

View file

@ -0,0 +1,21 @@
import { SignUp } from "@clerk/nextjs";
import { Database } from "lucide-react";
export default function SignUpPage() {
return (
<div className="flex min-h-screen items-center justify-center bg-background">
<div className="flex flex-col items-center gap-8">
<div className="flex items-center gap-3">
<div className="flex items-center justify-center w-10 h-10 rounded-xl bg-primary">
<Database className="w-5 h-5 text-primary-foreground" />
</div>
<div>
<p className="text-lg font-bold tracking-tight">DemoBMS</p>
<p className="text-xs text-muted-foreground">Infrastructure Management</p>
</div>
</div>
<SignUp />
</div>
</div>
);
}

23
frontend/components.json Normal file
View file

@ -0,0 +1,23 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "new-york",
"rsc": true,
"tsx": true,
"tailwind": {
"config": "",
"css": "app/globals.css",
"baseColor": "slate",
"cssVariables": true,
"prefix": ""
},
"iconLibrary": "lucide",
"rtl": false,
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
"ui": "@/components/ui",
"lib": "@/lib",
"hooks": "@/hooks"
},
"registries": {}
}

View file

@ -0,0 +1,124 @@
"use client";
import Link from "next/link";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { Skeleton } from "@/components/ui/skeleton";
import { cn } from "@/lib/utils";
import { acknowledgeAlarm, type Alarm } from "@/lib/api";
import { useState } from "react";
import { ChevronRight } from "lucide-react";
interface Props {
alarms: Alarm[];
loading?: boolean;
onAcknowledge?: () => void;
onAlarmClick?: (alarm: Alarm) => void;
}
const severityStyles: Record<string, { badge: string; dot: string }> = {
critical: { badge: "bg-destructive/20 text-destructive border-destructive/30", dot: "bg-destructive" },
warning: { badge: "bg-amber-500/20 text-amber-400 border-amber-500/30", dot: "bg-amber-400" },
info: { badge: "bg-primary/20 text-primary border-primary/30", dot: "bg-primary" },
};
function timeAgo(iso: string) {
const secs = Math.floor((Date.now() - new Date(iso).getTime()) / 1000);
if (secs < 60) return `${secs}s ago`;
if (secs < 3600) return `${Math.floor(secs / 60)}m ago`;
return `${Math.floor(secs / 3600)}h ago`;
}
export function AlarmFeed({ alarms, loading, onAcknowledge, onAlarmClick }: Props) {
const [acking, setAcking] = useState<number | null>(null);
async function handleAck(id: number) {
setAcking(id);
try {
await acknowledgeAlarm(id);
onAcknowledge?.();
} catch { /* ignore */ }
finally { setAcking(null); }
}
const activeCount = alarms.filter((a) => a.state === "active").length;
return (
<Card className="flex flex-col">
<CardHeader className="pb-2">
<div className="flex items-center justify-between">
<CardTitle className="text-sm font-semibold">Active Alarms</CardTitle>
<div className="flex items-center gap-2">
{!loading && (
<Badge variant={activeCount > 0 ? "destructive" : "outline"} className="text-[10px] h-4 px-1.5">
{activeCount > 0 ? `${activeCount} active` : "All clear"}
</Badge>
)}
<Link href="/alarms" className="text-[10px] text-muted-foreground hover:text-primary transition-colors">
View all
</Link>
</div>
</div>
</CardHeader>
<CardContent className="flex-1 space-y-3 overflow-y-auto max-h-72">
{loading ? (
<Skeleton className="h-80 w-full" />
) : alarms.length === 0 ? (
<div className="flex items-center justify-center h-24 text-sm text-muted-foreground">
No active alarms
</div>
) : (
alarms.map((alarm) => {
const style = severityStyles[alarm.severity] ?? severityStyles.info;
const clickable = !!onAlarmClick;
const locationLabel = [alarm.rack_id, alarm.room_id].find(Boolean);
return (
<div
key={alarm.id}
onClick={() => onAlarmClick?.(alarm)}
className={cn(
"flex gap-2.5 group rounded-md px-1 py-1 -mx-1 transition-colors",
clickable && "cursor-pointer hover:bg-muted/40"
)}
>
<div className="mt-1.5 shrink-0">
<span className={cn("block w-2 h-2 rounded-full", style.dot)} />
</div>
<div className="flex-1 min-w-0">
<p className="text-xs leading-snug text-foreground">{alarm.message}</p>
<div className="mt-0.5 flex items-center gap-1.5">
{locationLabel && (
<span className="text-[10px] text-muted-foreground font-medium">{locationLabel}</span>
)}
{locationLabel && <span className="text-[10px] text-muted-foreground/50">·</span>}
<span className="text-[10px] text-muted-foreground">{timeAgo(alarm.triggered_at)}</span>
</div>
</div>
<div className="flex flex-col items-end gap-1 shrink-0">
<Badge variant="outline" className={cn("text-[9px] h-4 px-1 uppercase tracking-wide", style.badge)}>
{alarm.severity}
</Badge>
{alarm.state === "active" && (
<Button
variant="ghost"
size="sm"
className="h-4 px-1 text-[9px] opacity-0 group-hover:opacity-100 transition-opacity"
disabled={acking === alarm.id}
onClick={(e) => { e.stopPropagation(); handleAck(alarm.id); }}
>
Ack
</Button>
)}
{clickable && (
<ChevronRight className="w-3 h-3 text-muted-foreground/40 group-hover:text-muted-foreground transition-colors mt-auto" />
)}
</div>
</div>
);
})
)}
</CardContent>
</Card>
);
}

View file

@ -0,0 +1,26 @@
import { LucideIcon } from "lucide-react";
interface ComingSoonProps {
title: string;
description: string;
icon: LucideIcon;
phase: number;
}
export function ComingSoon({ title, description, icon: Icon, phase }: ComingSoonProps) {
return (
<div className="flex flex-col items-center justify-center h-96 text-center space-y-4">
<div className="flex items-center justify-center w-16 h-16 rounded-2xl bg-muted">
<Icon className="w-8 h-8 text-muted-foreground" />
</div>
<div className="space-y-1">
<h2 className="text-lg font-semibold">{title}</h2>
<p className="text-sm text-muted-foreground max-w-xs">{description}</p>
</div>
<div className="flex items-center gap-2 px-3 py-1.5 rounded-full bg-primary/10 border border-primary/20">
<span className="w-1.5 h-1.5 rounded-full bg-primary animate-pulse" />
<span className="text-xs text-primary font-medium">Planned for Phase {phase}</span>
</div>
</div>
);
}

View file

@ -0,0 +1,473 @@
"use client";
import { useEffect, useState } from "react";
import { fetchCracStatus, fetchCracHistory, type CracStatus, type CracHistoryPoint } from "@/lib/api";
import { Sheet, SheetContent, SheetHeader, SheetTitle } from "@/components/ui/sheet";
import { Skeleton } from "@/components/ui/skeleton";
import { Badge } from "@/components/ui/badge";
import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs";
import { TimeRangePicker } from "@/components/ui/time-range-picker";
import {
LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip,
ResponsiveContainer, ReferenceLine,
} from "recharts";
import { Thermometer, Wind, Zap, Gauge, Settings2, ArrowRight } from "lucide-react";
import { cn } from "@/lib/utils";
interface Props {
siteId: string;
cracId: string | null;
onClose: () => void;
}
function fmt(v: number | null | undefined, dec = 1, unit = "") {
if (v == null) return "—";
return `${v.toFixed(dec)}${unit}`;
}
function formatTime(iso: string) {
try { return new Date(iso).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" }); }
catch { return iso; }
}
function FillBar({
value, max, color, warn, crit,
}: {
value: number | null; max: number; color: string; warn?: number; crit?: number;
}) {
const pct = value != null ? Math.min(100, (value / max) * 100) : 0;
const barColor =
crit && value != null && value >= crit ? "#ef4444" :
warn && value != null && value >= warn ? "#f59e0b" :
color;
return (
<div className="h-1.5 rounded-full bg-muted overflow-hidden w-full">
<div
className="h-full rounded-full transition-all duration-500"
style={{ width: `${pct}%`, backgroundColor: barColor }}
/>
</div>
);
}
function SectionLabel({ icon: Icon, title }: { icon: React.ElementType; title: string }) {
return (
<div className="flex items-center gap-1.5 mt-5 mb-2">
<Icon className="h-3.5 w-3.5 text-muted-foreground" />
<span className="text-[10px] font-semibold text-muted-foreground uppercase tracking-wider">
{title}
</span>
</div>
);
}
function StatRow({ label, value, highlight }: { label: string; value: string; highlight?: boolean }) {
return (
<div className="flex justify-between items-center py-1.5 border-b border-border/40 last:border-0">
<span className="text-xs text-muted-foreground">{label}</span>
<span className={cn("text-sm font-mono font-medium", highlight && "text-amber-400")}>{value}</span>
</div>
);
}
function RefrigerantRow({ label, value, status }: { label: string; value: string; status: "ok" | "warn" | "crit" }) {
return (
<div className="flex justify-between items-center py-2 border-b border-border/40 last:border-0">
<span className="text-xs text-muted-foreground">{label}</span>
<div className="flex items-center gap-2">
<span className="text-sm font-mono font-medium">{value}</span>
<span className={cn(
"text-[10px] font-semibold px-1.5 py-0.5 rounded-full uppercase tracking-wide",
status === "ok" ? "bg-green-500/10 text-green-400" :
status === "warn" ? "bg-amber-500/10 text-amber-400" :
"bg-destructive/10 text-destructive",
)}>
{status === "ok" ? "normal" : status}
</span>
</div>
</div>
);
}
function MiniChart({
data, dataKey, color, label, unit, refLine,
}: {
data: CracHistoryPoint[];
dataKey: keyof CracHistoryPoint;
color: string;
label: string;
unit: string;
refLine?: number;
}) {
const vals = data.map(d => d[dataKey] as number | null).filter(v => v != null) as number[];
const last = vals[vals.length - 1];
return (
<div className="mb-5">
<div className="flex justify-between items-baseline mb-1">
<span className="text-xs text-muted-foreground">{label}</span>
<span className="text-sm font-mono font-medium" style={{ color }}>
{last != null ? `${last.toFixed(1)}${unit}` : "—"}
</span>
</div>
<ResponsiveContainer width="100%" height={60}>
<LineChart data={data} margin={{ top: 2, right: 4, left: -28, bottom: 0 }}>
<XAxis
dataKey="bucket"
tickFormatter={formatTime}
tick={{ fontSize: 9 }}
interval="preserveStartEnd"
/>
<YAxis tick={{ fontSize: 9 }} domain={["auto", "auto"]} />
<CartesianGrid strokeDasharray="3 3" stroke="rgba(255,255,255,0.05)" />
<Tooltip
formatter={(v: unknown) => [`${(v as number).toFixed(1)}${unit}`, label]}
labelFormatter={(l: unknown) => formatTime(String(l))}
contentStyle={{ fontSize: 11, background: "#1e1e2e", border: "1px solid #333" }}
/>
{refLine != null && (
<ReferenceLine y={refLine} stroke="rgba(251,191,36,0.4)" strokeDasharray="4 2" />
)}
<Line
type="monotone"
dataKey={dataKey}
stroke={color}
dot={false}
strokeWidth={1.5}
connectNulls
/>
</LineChart>
</ResponsiveContainer>
</div>
);
}
// ── Overview tab ──────────────────────────────────────────────────────────────
function OverviewTab({ status }: { status: CracStatus }) {
const deltaWarn = (status.delta ?? 0) > 11;
const deltaCrit = (status.delta ?? 0) > 14;
const capWarn = (status.cooling_capacity_pct ?? 0) > 75;
const capCrit = (status.cooling_capacity_pct ?? 0) > 90;
const copWarn = (status.cop ?? 99) < 1.5;
const filterWarn = (status.filter_dp_pa ?? 0) > 80;
const filterCrit = (status.filter_dp_pa ?? 0) > 120;
const compWarn = (status.compressor_load_pct ?? 0) > 95;
return (
<div>
{/* ── Thermal hero ──────────────────────────────────────────── */}
<SectionLabel icon={Thermometer} title="Thermal" />
<div className="rounded-lg bg-muted/20 px-4 py-3 mb-3">
<div className="flex items-center justify-between">
<div className="text-center">
<p className="text-[10px] text-muted-foreground uppercase tracking-wide mb-0.5">Supply</p>
<p className="text-2xl font-bold tabular-nums text-blue-400">
{fmt(status.supply_temp, 1)}°C
</p>
</div>
<div className="flex-1 flex flex-col items-center gap-1 px-4">
<p className={cn(
"text-sm font-bold tabular-nums",
deltaCrit ? "text-destructive" : deltaWarn ? "text-amber-400" : "text-muted-foreground",
)}>
ΔT {fmt(status.delta, 1)}°C
</p>
<div className="flex items-center gap-1 w-full">
<div className="flex-1 h-px bg-muted-foreground/30" />
<ArrowRight className="w-3 h-3 text-muted-foreground/50 shrink-0" />
<div className="flex-1 h-px bg-muted-foreground/30" />
</div>
</div>
<div className="text-center">
<p className="text-[10px] text-muted-foreground uppercase tracking-wide mb-0.5">Return</p>
<p className={cn(
"text-2xl font-bold tabular-nums",
deltaCrit ? "text-destructive" : deltaWarn ? "text-amber-400" : "text-orange-400",
)}>
{fmt(status.return_temp, 1)}°C
</p>
</div>
</div>
</div>
<div className="bg-muted/30 rounded-lg px-3 py-1 mb-3">
<StatRow label="Supply Humidity" value={fmt(status.supply_humidity, 0, "%")} />
<StatRow label="Return Humidity" value={fmt(status.return_humidity, 0, "%")} />
<StatRow label="Airflow" value={status.airflow_cfm != null ? `${Math.round(status.airflow_cfm).toLocaleString()} CFM` : "—"} />
</div>
<div>
<div className="flex justify-between text-[10px] mb-1">
<span className="text-muted-foreground">Filter ΔP</span>
<span className={cn(
"font-mono",
filterCrit ? "text-destructive" : filterWarn ? "text-amber-400" : "text-foreground",
)}>
{fmt(status.filter_dp_pa, 0)} Pa
{!filterWarn && <span className="text-green-400 ml-1.5"></span>}
</span>
</div>
<FillBar value={status.filter_dp_pa} max={150} color="#94a3b8" warn={80} crit={120} />
</div>
{/* ── Cooling capacity ──────────────────────────────────────── */}
<SectionLabel icon={Gauge} title="Cooling Capacity" />
<div className="mb-1.5">
<div className="flex justify-between items-baseline mb-1.5">
<span className={cn(
"text-sm font-bold tabular-nums",
capCrit ? "text-destructive" : capWarn ? "text-amber-400" : "text-foreground",
)}>
{fmt(status.cooling_capacity_kw, 1)} kW
</span>
<span className="text-xs text-muted-foreground">of {status.rated_capacity_kw} kW rated</span>
</div>
<FillBar value={status.cooling_capacity_pct} max={100} color="#34d399" warn={75} crit={90} />
<p className={cn(
"text-[10px] mt-1 text-right",
capCrit ? "text-destructive" : capWarn ? "text-amber-400" : "text-muted-foreground",
)}>
{fmt(status.cooling_capacity_pct, 1)}% utilised
</p>
</div>
<div className="bg-muted/30 rounded-lg px-3 py-1">
<StatRow label="COP" value={fmt(status.cop, 2)} highlight={copWarn} />
<StatRow label="Sensible Heat Ratio" value={fmt(status.sensible_heat_ratio, 2)} />
</div>
{/* ── Compressor ────────────────────────────────────────────── */}
<SectionLabel icon={Settings2} title="Compressor" />
<div className="mb-2">
<div className="flex justify-between text-[10px] mb-1">
<span className="text-muted-foreground">
Load
<span className={cn(
"ml-2 font-semibold px-1.5 py-0.5 rounded-full",
status.compressor_state === 1
? "bg-green-500/10 text-green-400"
: "bg-muted text-muted-foreground",
)}>
{status.compressor_state === 1 ? "● Running" : "○ Off"}
</span>
</span>
<span className={cn("font-mono", compWarn ? "text-amber-400" : "text-foreground")}>
{fmt(status.compressor_load_pct, 1)}%
</span>
</div>
<FillBar value={status.compressor_load_pct} max={100} color="#e879f9" warn={80} crit={95} />
</div>
<div className="bg-muted/30 rounded-lg px-3 py-1">
<StatRow label="Power" value={fmt(status.compressor_power_kw, 2, " kW")} />
<StatRow label="Run Hours" value={status.compressor_run_hours != null ? status.compressor_run_hours.toLocaleString() + " h" : "—"} />
</div>
{/* ── Fan ───────────────────────────────────────────────────── */}
<SectionLabel icon={Wind} title="Fan" />
<div className="mb-2">
<div className="flex justify-between text-[10px] mb-1">
<span className="text-muted-foreground">Speed</span>
<span className="font-mono text-foreground">
{fmt(status.fan_pct, 1)}%
{status.fan_rpm != null ? ` · ${Math.round(status.fan_rpm).toLocaleString()} rpm` : ""}
</span>
</div>
<FillBar value={status.fan_pct} max={100} color="#60a5fa" />
</div>
<div className="bg-muted/30 rounded-lg px-3 py-1">
<StatRow label="Fan Power" value={fmt(status.fan_power_kw, 2, " kW")} />
<StatRow label="Run Hours" value={status.fan_run_hours != null ? status.fan_run_hours.toLocaleString() + " h" : "—"} />
</div>
{/* ── Electrical ────────────────────────────────────────────── */}
<SectionLabel icon={Zap} title="Electrical" />
<div className="bg-muted/30 rounded-lg px-3 py-1">
<StatRow label="Total Unit Power" value={fmt(status.total_unit_power_kw, 2, " kW")} />
<StatRow label="Input Voltage" value={fmt(status.input_voltage_v, 1, " V")} />
<StatRow label="Input Current" value={fmt(status.input_current_a, 1, " A")} />
<StatRow label="Power Factor" value={fmt(status.power_factor, 3)} />
</div>
</div>
);
}
// ── Refrigerant tab ───────────────────────────────────────────────────────────
function RefrigerantTab({ status }: { status: CracStatus }) {
const hiP = status.high_pressure_bar ?? 0;
const loP = status.low_pressure_bar ?? 99;
const sh = status.discharge_superheat_c ?? 0;
const sc = status.liquid_subcooling_c ?? 0;
const load = status.compressor_load_pct ?? 0;
// Normal ranges for R410A-style DX unit
const hiPStatus: "ok" | "warn" | "crit" = hiP > 22 ? "crit" : hiP > 20 ? "warn" : "ok";
const loPStatus: "ok" | "warn" | "crit" = loP < 3 ? "crit" : loP < 4 ? "warn" : "ok";
const shStatus: "ok" | "warn" | "crit" = sh > 16 ? "warn" : sh < 4 ? "warn" : "ok";
const scStatus: "ok" | "warn" | "crit" = sc < 2 ? "warn" : "ok";
const ldStatus: "ok" | "warn" | "crit" = load > 95 ? "warn" : "ok";
const compRunning = status.compressor_state === 1;
return (
<div>
<p className="text-xs text-muted-foreground mt-4 mb-4 leading-relaxed">
Refrigerant circuit data for the DX cooling system. Pressures assume an R410A charge.
Values outside normal range are flagged automatically.
</p>
<div className="rounded-lg bg-muted/20 px-3 mb-4">
<div className="flex justify-between items-center py-3 border-b border-border/30">
<span className="text-xs text-muted-foreground">Compressor State</span>
<span className={cn(
"text-xs font-semibold px-2 py-0.5 rounded-full",
compRunning ? "bg-green-500/10 text-green-400" : "bg-muted text-muted-foreground",
)}>
{compRunning ? "● Running" : "○ Off"}
</span>
</div>
<RefrigerantRow
label="High Side Pressure"
value={fmt(status.high_pressure_bar, 2, " bar")}
status={hiPStatus}
/>
<RefrigerantRow
label="Low Side Pressure"
value={fmt(status.low_pressure_bar, 2, " bar")}
status={loPStatus}
/>
<RefrigerantRow
label="Discharge Superheat"
value={fmt(status.discharge_superheat_c, 1, "°C")}
status={shStatus}
/>
<RefrigerantRow
label="Liquid Subcooling"
value={fmt(status.liquid_subcooling_c, 1, "°C")}
status={scStatus}
/>
<RefrigerantRow
label="Compressor Load"
value={fmt(status.compressor_load_pct, 1, "%")}
status={ldStatus}
/>
<div className="flex justify-between items-center py-2">
<span className="text-xs text-muted-foreground">Compressor Power</span>
<span className="text-sm font-mono font-medium">{fmt(status.compressor_power_kw, 2, " kW")}</span>
</div>
</div>
<div className="rounded-md bg-muted/30 px-3 py-2.5 text-xs text-muted-foreground space-y-1">
<p className="font-semibold text-foreground mb-1.5">Normal Ranges (R410A)</p>
<p>High side pressure: 15 20 bar</p>
<p>Low side pressure: 4 6 bar</p>
<p>Discharge superheat: 5 15°C</p>
<p>Liquid subcooling: 3 8°C</p>
</div>
</div>
);
}
// ── Trends tab ────────────────────────────────────────────────────────────────
function TrendsTab({ history }: { history: CracHistoryPoint[] }) {
if (history.length < 2) {
return (
<p className="text-sm text-muted-foreground mt-6 text-center">
Not enough history yet data accumulates every 5 minutes.
</p>
);
}
return (
<div className="mt-4">
<MiniChart data={history} dataKey="supply_temp" color="#60a5fa" label="Supply Temp" unit="°C" />
<MiniChart data={history} dataKey="return_temp" color="#f97316" label="Return Temp" unit="°C" refLine={36} />
<MiniChart data={history} dataKey="delta_t" color="#a78bfa" label="ΔT" unit="°C" />
<MiniChart data={history} dataKey="capacity_kw" color="#34d399" label="Cooling kW" unit=" kW" />
<MiniChart data={history} dataKey="capacity_pct"color="#fbbf24" label="Utilisation" unit="%" refLine={90} />
<MiniChart data={history} dataKey="cop" color="#38bdf8" label="COP" unit="" refLine={1.5} />
<MiniChart data={history} dataKey="comp_load" color="#e879f9" label="Comp Load" unit="%" refLine={95} />
<MiniChart data={history} dataKey="filter_dp" color="#fb923c" label="Filter ΔP" unit=" Pa" refLine={80} />
<MiniChart data={history} dataKey="fan_pct" color="#94a3b8" label="Fan Speed" unit="%" />
</div>
);
}
// ── Sheet ─────────────────────────────────────────────────────────────────────
export function CracDetailSheet({ siteId, cracId, onClose }: Props) {
const [hours, setHours] = useState(6);
const [status, setStatus] = useState<CracStatus | null>(null);
const [history, setHistory] = useState<CracHistoryPoint[]>([]);
const [loading, setLoading] = useState(false);
useEffect(() => {
if (!cracId) return;
setLoading(true);
Promise.all([
fetchCracStatus(siteId),
fetchCracHistory(siteId, cracId, hours),
]).then(([statuses, hist]) => {
setStatus(statuses.find(c => c.crac_id === cracId) ?? null);
setHistory(hist);
}).finally(() => setLoading(false));
}, [siteId, cracId, hours]);
return (
<Sheet open={cracId != null} onOpenChange={open => { if (!open) onClose(); }}>
<SheetContent className="w-[500px] sm:w-[560px] overflow-y-auto" side="right">
<SheetHeader className="mb-2">
<SheetTitle className="flex items-center justify-between">
<span>{cracId?.toUpperCase() ?? "CRAC Unit"}</span>
{status && (
<Badge
variant={status.state === "online" ? "default" : "destructive"}
className="text-xs"
>
{status.state}
</Badge>
)}
</SheetTitle>
{status && (
<p className="text-xs text-muted-foreground">
{status.room_id ? `Room: ${status.room_id}` : ""}
{status.state === "online" ? " · Mode: Cooling · Setpoint 22°C" : ""}
</p>
)}
</SheetHeader>
{loading && !status ? (
<div className="space-y-3 mt-4">
{Array.from({ length: 8 }).map((_, i) => (
<Skeleton key={i} className="h-8 w-full" />
))}
</div>
) : status ? (
<Tabs defaultValue="overview">
<div className="flex items-center justify-between mt-3 mb-1">
<TabsList>
<TabsTrigger value="overview">Overview</TabsTrigger>
<TabsTrigger value="refrigerant">Refrigerant</TabsTrigger>
<TabsTrigger value="trends">Trends</TabsTrigger>
</TabsList>
<TimeRangePicker value={hours} onChange={setHours} />
</div>
<TabsContent value="overview">
<OverviewTab status={status} />
</TabsContent>
<TabsContent value="refrigerant">
<RefrigerantTab status={status} />
</TabsContent>
<TabsContent value="trends">
<TrendsTab history={history} />
</TabsContent>
</Tabs>
) : (
<p className="text-sm text-muted-foreground mt-4">No data available for {cracId}.</p>
)}
</SheetContent>
</Sheet>
);
}

View file

@ -0,0 +1,489 @@
"use client";
import { useEffect, useState } from "react";
import {
fetchGeneratorStatus, fetchGeneratorHistory,
type GeneratorStatus, type GeneratorHistoryPoint,
} from "@/lib/api";
import { Sheet, SheetContent, SheetHeader, SheetTitle } from "@/components/ui/sheet";
import { Badge } from "@/components/ui/badge";
import { Skeleton } from "@/components/ui/skeleton";
import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs";
import { TimeRangePicker } from "@/components/ui/time-range-picker";
import {
LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip,
ResponsiveContainer, ReferenceLine,
} from "recharts";
import {
Fuel, Zap, Gauge, Thermometer, Wind, Activity, Battery, Settings2,
} from "lucide-react";
import { cn } from "@/lib/utils";
interface Props {
siteId: string;
genId: string | null;
onClose: () => void;
}
function fmt(v: number | null | undefined, dec = 1, unit = "") {
if (v == null) return "—";
return `${v.toFixed(dec)}${unit}`;
}
function formatTime(iso: string) {
try { return new Date(iso).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" }); }
catch { return iso; }
}
const STATE_BADGE: Record<string, string> = {
running: "bg-green-500/15 text-green-400 border-green-500/30",
standby: "bg-blue-500/15 text-blue-400 border-blue-500/30",
test: "bg-amber-500/15 text-amber-400 border-amber-500/30",
fault: "bg-destructive/15 text-destructive border-destructive/30",
unknown: "bg-muted/30 text-muted-foreground border-border",
};
function FillBar({
value, max, color, warn, crit, invert = false,
}: {
value: number | null; max: number; color: string; warn?: number; crit?: number; invert?: boolean;
}) {
const pct = value != null ? Math.min(100, (value / max) * 100) : 0;
const v = value ?? 0;
const barColor =
crit && (invert ? v <= crit : v >= crit) ? "#ef4444" :
warn && (invert ? v <= warn : v >= warn) ? "#f59e0b" :
color;
return (
<div className="h-1.5 rounded-full bg-muted overflow-hidden w-full">
<div className="h-full rounded-full transition-all duration-500"
style={{ width: `${pct}%`, backgroundColor: barColor }} />
</div>
);
}
function SectionLabel({ icon: Icon, title }: { icon: React.ElementType; title: string }) {
return (
<div className="flex items-center gap-1.5 mt-5 mb-2">
<Icon className="h-3.5 w-3.5 text-muted-foreground" />
<span className="text-[10px] font-semibold text-muted-foreground uppercase tracking-wider">{title}</span>
</div>
);
}
function StatRow({
label, value, highlight, status,
}: {
label: string; value: string; highlight?: boolean; status?: "ok" | "warn" | "crit";
}) {
return (
<div className="flex justify-between items-center py-1.5 border-b border-border/40 last:border-0">
<span className="text-xs text-muted-foreground">{label}</span>
<div className="flex items-center gap-2">
<span className={cn(
"text-sm font-mono font-medium",
highlight && "text-amber-400",
status === "crit" && "text-destructive",
status === "warn" && "text-amber-400",
status === "ok" && "text-green-400",
)}>{value}</span>
{status && (
<span className={cn(
"text-[10px] font-semibold px-1.5 py-0.5 rounded-full uppercase tracking-wide",
status === "ok" ? "bg-green-500/10 text-green-400" :
status === "warn" ? "bg-amber-500/10 text-amber-400" :
"bg-destructive/10 text-destructive",
)}>
{status === "ok" ? "normal" : status}
</span>
)}
</div>
</div>
);
}
function MiniChart({
data, dataKey, color, label, unit, refLine,
}: {
data: GeneratorHistoryPoint[];
dataKey: keyof GeneratorHistoryPoint;
color: string;
label: string;
unit: string;
refLine?: number;
}) {
const vals = data.map(d => d[dataKey] as number | null).filter(v => v != null) as number[];
const last = vals[vals.length - 1];
return (
<div className="mb-5">
<div className="flex justify-between items-baseline mb-1">
<span className="text-xs text-muted-foreground">{label}</span>
<span className="text-sm font-mono font-medium" style={{ color }}>
{last != null ? `${last.toFixed(1)}${unit}` : "—"}
</span>
</div>
<ResponsiveContainer width="100%" height={60}>
<LineChart data={data} margin={{ top: 2, right: 4, left: -28, bottom: 0 }}>
<XAxis dataKey="bucket" tickFormatter={formatTime} tick={{ fontSize: 9 }} interval="preserveStartEnd" />
<YAxis tick={{ fontSize: 9 }} domain={["auto", "auto"]} />
<CartesianGrid strokeDasharray="3 3" stroke="rgba(255,255,255,0.05)" />
<Tooltip
formatter={(v: unknown) => [`${(v as number).toFixed(1)}${unit}`, label]}
labelFormatter={(l: unknown) => formatTime(String(l))}
contentStyle={{ fontSize: 11, background: "#1e1e2e", border: "1px solid #333" }}
/>
{refLine != null && <ReferenceLine y={refLine} stroke="rgba(251,191,36,0.4)" strokeDasharray="4 2" />}
<Line type="monotone" dataKey={dataKey} stroke={color} dot={false} strokeWidth={1.5} connectNulls />
</LineChart>
</ResponsiveContainer>
</div>
);
}
// ── Overview tab ──────────────────────────────────────────────────────────────
function OverviewTab({ gen }: { gen: GeneratorStatus }) {
const isRunning = gen.state === "running" || gen.state === "test";
const fuelLow = (gen.fuel_pct ?? 100) < 25;
const fuelCrit = (gen.fuel_pct ?? 100) < 10;
const loadWarn = (gen.load_pct ?? 0) > 75;
const loadCrit = (gen.load_pct ?? 0) > 90;
// Estimated runtime from fuel and consumption rate
const runtimeH = gen.fuel_rate_lph && gen.fuel_rate_lph > 0 && gen.fuel_litres
? gen.fuel_litres / gen.fuel_rate_lph
: gen.fuel_litres && gen.load_kw && gen.load_kw > 0
? gen.fuel_litres / (gen.load_kw * 0.27)
: null;
// Computed electrical values
const outputKva = gen.load_kw && gen.power_factor && gen.power_factor > 0
? gen.load_kw / gen.power_factor : null;
const outputCurrent = gen.load_kw && gen.voltage_v && gen.voltage_v > 0 && gen.power_factor
? (gen.load_kw * 1000) / (gen.voltage_v * 1.732 * gen.power_factor) : null;
return (
<div>
{/* Fuel */}
<SectionLabel icon={Fuel} title="Fuel" />
<div className="rounded-lg bg-muted/20 px-4 py-3 space-y-2">
<div className="flex justify-between items-baseline mb-1">
<span className="text-xs text-muted-foreground">Tank Level</span>
<span className={cn(
"text-2xl font-bold tabular-nums",
fuelCrit ? "text-destructive" : fuelLow ? "text-amber-400" : "text-green-400",
)}>
{fmt(gen.fuel_pct, 1)}%
</span>
</div>
<FillBar value={gen.fuel_pct} max={100} color="#22c55e" warn={25} crit={10} />
<div className="flex justify-between text-xs text-muted-foreground mt-1">
<span>{gen.fuel_litres != null ? `${gen.fuel_litres.toFixed(0)} L remaining` : "—"}</span>
{gen.fuel_rate_lph != null && gen.fuel_rate_lph > 0 && (
<span>{fmt(gen.fuel_rate_lph, 1)} L/hr consumption</span>
)}
</div>
{runtimeH != null && (
<div className={cn(
"text-xs font-semibold text-right",
runtimeH < 4 ? "text-destructive" : runtimeH < 12 ? "text-amber-400" : "text-green-400",
)}>
Est. runtime: {Math.floor(runtimeH)}h {Math.round((runtimeH % 1) * 60)}m
</div>
)}
</div>
{/* Load */}
<SectionLabel icon={Zap} title="Load" />
<div className="rounded-lg bg-muted/20 px-4 py-3 space-y-2">
<div className="flex justify-between items-baseline mb-1">
<span className="text-xs text-muted-foreground">Active Load</span>
<span className={cn(
"text-2xl font-bold tabular-nums",
loadCrit ? "text-destructive" : loadWarn ? "text-amber-400" : "text-foreground",
)}>
{fmt(gen.load_kw, 1)} kW
</span>
</div>
<FillBar value={gen.load_pct} max={100} color="#60a5fa" warn={75} crit={90} />
<div className="flex justify-between text-xs mt-1">
<span className={cn(
"font-medium tabular-nums",
loadCrit ? "text-destructive" : loadWarn ? "text-amber-400" : "text-muted-foreground",
)}>
{fmt(gen.load_pct, 1)}% of 500 kW rated
</span>
{outputKva != null && (
<span className="text-muted-foreground">{outputKva.toFixed(1)} kVA apparent</span>
)}
</div>
</div>
{/* Quick stats */}
<SectionLabel icon={Activity} title="Quick Stats" />
<div className="bg-muted/30 rounded-lg px-3 py-1">
<StatRow label="Output Current" value={outputCurrent != null ? `${outputCurrent.toFixed(1)} A` : "—"} />
<StatRow label="Power Factor" value={fmt(gen.power_factor, 3)} />
<StatRow label="Run Hours" value={gen.run_hours != null ? `${gen.run_hours.toLocaleString()} h` : "—"} />
</div>
</div>
);
}
// ── Engine tab ────────────────────────────────────────────────────────────────
function EngineTab({ gen }: { gen: GeneratorStatus }) {
const coolantWarn = (gen.coolant_temp_c ?? 0) > 85;
const coolantCrit = (gen.coolant_temp_c ?? 0) > 95;
const exhaustWarn = (gen.exhaust_temp_c ?? 0) > 420;
const exhaustCrit = (gen.exhaust_temp_c ?? 0) > 480;
const altTempWarn = (gen.alternator_temp_c ?? 0) > 70;
const altTempCrit = (gen.alternator_temp_c ?? 0) > 85;
const oilLow = (gen.oil_pressure_bar ?? 99) < 2.0;
const oilWarn = (gen.oil_pressure_bar ?? 99) < 3.0;
const battLow = (gen.battery_v ?? 99) < 23.0;
const battWarn = (gen.battery_v ?? 99) < 24.0;
const rpmWarn = gen.engine_rpm != null && gen.engine_rpm > 0 && Math.abs(gen.engine_rpm - 1500) > 20;
return (
<div>
<p className="text-xs text-muted-foreground mt-4 mb-4 leading-relaxed">
Mechanical health of the diesel engine. Normal operating range assumes a 50 Hz, 4-pole synchronous generator at 1500 RPM.
</p>
{/* Temperature gauges */}
<SectionLabel icon={Thermometer} title="Temperatures" />
<div className="space-y-3">
<div>
<div className="flex justify-between text-xs mb-1">
<span className="text-muted-foreground">Coolant</span>
<span className={cn("font-mono", coolantCrit ? "text-destructive" : coolantWarn ? "text-amber-400" : "text-foreground")}>
{fmt(gen.coolant_temp_c, 1)}°C
</span>
</div>
<FillBar value={gen.coolant_temp_c} max={120} color="#34d399" warn={85} crit={95} />
<p className="text-[10px] text-muted-foreground text-right mt-0.5">Normal: 7090°C</p>
</div>
<div>
<div className="flex justify-between text-xs mb-1">
<span className="text-muted-foreground">Exhaust Stack</span>
<span className={cn("font-mono", exhaustCrit ? "text-destructive" : exhaustWarn ? "text-amber-400" : "text-foreground")}>
{fmt(gen.exhaust_temp_c, 0)}°C
</span>
</div>
<FillBar value={gen.exhaust_temp_c} max={600} color="#f97316" warn={420} crit={480} />
<p className="text-[10px] text-muted-foreground text-right mt-0.5">Normal: 200420°C at load</p>
</div>
<div>
<div className="flex justify-between text-xs mb-1">
<span className="text-muted-foreground">Alternator Windings</span>
<span className={cn("font-mono", altTempCrit ? "text-destructive" : altTempWarn ? "text-amber-400" : "text-foreground")}>
{fmt(gen.alternator_temp_c, 1)}°C
</span>
</div>
<FillBar value={gen.alternator_temp_c} max={110} color="#a78bfa" warn={70} crit={85} />
<p className="text-[10px] text-muted-foreground text-right mt-0.5">Normal: 4070°C at load</p>
</div>
</div>
{/* Engine mechanical */}
<SectionLabel icon={Settings2} title="Mechanical" />
<div className="bg-muted/30 rounded-lg px-3 py-1">
<StatRow
label="Engine RPM"
value={gen.engine_rpm != null && gen.engine_rpm > 0 ? `${gen.engine_rpm.toFixed(0)} RPM` : "— (stopped)"}
status={rpmWarn ? "warn" : gen.engine_rpm && gen.engine_rpm > 0 ? "ok" : undefined}
/>
<StatRow
label="Oil Pressure"
value={gen.oil_pressure_bar != null && gen.oil_pressure_bar > 0 ? `${gen.oil_pressure_bar.toFixed(2)} bar` : "— (stopped)"}
status={oilLow ? "crit" : oilWarn ? "warn" : gen.oil_pressure_bar && gen.oil_pressure_bar > 0 ? "ok" : undefined}
/>
<StatRow
label="Run Hours"
value={gen.run_hours != null ? `${gen.run_hours.toLocaleString()} h` : "—"}
/>
</div>
{/* Starter battery */}
<SectionLabel icon={Battery} title="Starter Battery" />
<div className="bg-muted/30 rounded-lg px-3 py-1">
<StatRow
label="Battery Voltage (24 V system)"
value={fmt(gen.battery_v, 2, " V")}
status={battLow ? "crit" : battWarn ? "warn" : "ok"}
/>
<div className="py-1.5">
<FillBar value={gen.battery_v} max={29} color="#22c55e" warn={24} crit={23} invert />
</div>
<p className="text-[10px] text-muted-foreground pb-1.5">
Float charge: 27.2 V · Low threshold: 24 V · Critical: 23 V
</p>
</div>
</div>
);
}
// ── Electrical tab ────────────────────────────────────────────────────────────
function ElectricalTab({ gen }: { gen: GeneratorStatus }) {
const freqWarn = gen.frequency_hz != null && gen.frequency_hz > 0 && Math.abs(gen.frequency_hz - 50) > 0.3;
const freqCrit = gen.frequency_hz != null && gen.frequency_hz > 0 && Math.abs(gen.frequency_hz - 50) > 0.5;
const voltWarn = gen.voltage_v != null && gen.voltage_v > 0 && Math.abs(gen.voltage_v - 415) > 10;
const outputKva = gen.load_kw && gen.power_factor && gen.power_factor > 0
? gen.load_kw / gen.power_factor : null;
const outputKvar = outputKva && gen.load_kw
? Math.sqrt(Math.max(0, outputKva ** 2 - gen.load_kw ** 2)) : null;
const outputCurrent = gen.load_kw && gen.voltage_v && gen.voltage_v > 0 && gen.power_factor
? (gen.load_kw * 1000) / (gen.voltage_v * 1.732 * gen.power_factor) : null;
const phaseCurrentA = outputCurrent ? outputCurrent / 3 : null;
return (
<div>
<p className="text-xs text-muted-foreground mt-4 mb-4 leading-relaxed">
AC output electrical parameters. The generator feeds the ATS which transfers site load during utility failure. Rated 500 kW / 555 kVA at 0.90 PF, 415 V, 50 Hz.
</p>
{/* AC output hero */}
<div className="rounded-lg bg-muted/20 px-4 py-3 grid grid-cols-3 gap-3 mb-4 text-center">
{[
{ label: "Output Voltage", value: gen.voltage_v && gen.voltage_v > 0 ? `${gen.voltage_v.toFixed(0)} V` : "—", warn: voltWarn },
{ label: "Frequency", value: gen.frequency_hz && gen.frequency_hz > 0 ? `${gen.frequency_hz.toFixed(2)} Hz` : "—", warn: freqWarn || freqCrit },
{ label: "Power Factor", value: fmt(gen.power_factor, 3), warn: false },
].map(({ label, value, warn }) => (
<div key={label}>
<p className="text-[10px] text-muted-foreground uppercase tracking-wide mb-0.5">{label}</p>
<p className={cn("text-lg font-bold tabular-nums", warn ? "text-amber-400" : "text-foreground")}>
{value}
</p>
</div>
))}
</div>
<SectionLabel icon={Zap} title="Power Output" />
<div className="bg-muted/30 rounded-lg px-3 py-1">
<StatRow label="Active Power (kW)" value={fmt(gen.load_kw, 1, " kW")} />
<StatRow label="Apparent Power (kVA)" value={outputKva != null ? `${outputKva.toFixed(1)} kVA` : "—"} />
<StatRow label="Reactive Power (kVAR)" value={outputKvar != null ? `${outputKvar.toFixed(1)} kVAR` : "—"} />
<StatRow label="Load %" value={fmt(gen.load_pct, 1, "%")} />
</div>
<SectionLabel icon={Activity} title="Current (3-Phase)" />
<div className="bg-muted/30 rounded-lg px-3 py-1">
<StatRow label="Total Output Current" value={outputCurrent != null ? `${outputCurrent.toFixed(1)} A` : "—"} />
<StatRow label="Per Phase (balanced)" value={phaseCurrentA != null ? `${phaseCurrentA.toFixed(1)} A` : "—"} />
<StatRow label="Rated Current (500 kW)" value="694 A" />
</div>
<SectionLabel icon={Wind} title="Frequency" />
<div className="space-y-1">
<div className="flex justify-between text-xs mb-1">
<span className="text-muted-foreground">Output Frequency</span>
<span className={cn("font-mono", freqCrit ? "text-destructive" : freqWarn ? "text-amber-400" : "text-green-400")}>
{gen.frequency_hz && gen.frequency_hz > 0 ? `${gen.frequency_hz.toFixed(2)} Hz` : "—"}
</span>
</div>
<FillBar value={gen.frequency_hz ?? 0} max={55} color="#34d399" warn={50.3} crit={50.5} />
<p className="text-[10px] text-muted-foreground text-right mt-0.5">
Nominal 50 Hz · Grid tolerance ±0.5 Hz
</p>
</div>
</div>
);
}
// ── Trends tab ────────────────────────────────────────────────────────────────
function TrendsTab({ history }: { history: GeneratorHistoryPoint[] }) {
if (history.length < 2) {
return (
<p className="text-sm text-muted-foreground mt-6 text-center">
Not enough history yet data accumulates every 5 minutes.
</p>
);
}
return (
<div className="mt-4">
<MiniChart data={history} dataKey="load_pct" color="#60a5fa" label="Load %" unit="%" refLine={90} />
<MiniChart data={history} dataKey="fuel_pct" color="#22c55e" label="Fuel Level" unit="%" refLine={25} />
<MiniChart data={history} dataKey="coolant_temp_c" color="#34d399" label="Coolant Temp" unit="°C" refLine={95} />
<MiniChart data={history} dataKey="exhaust_temp_c" color="#f97316" label="Exhaust Temp" unit="°C" refLine={420} />
<MiniChart data={history} dataKey="frequency_hz" color="#a78bfa" label="Frequency" unit=" Hz" refLine={50.5} />
<MiniChart data={history} dataKey="alternator_temp_c" color="#e879f9" label="Alternator Temp" unit="°C" refLine={85} />
</div>
);
}
// ── Sheet ─────────────────────────────────────────────────────────────────────
export function GeneratorDetailSheet({ siteId, genId, onClose }: Props) {
const [hours, setHours] = useState(6);
const [status, setStatus] = useState<GeneratorStatus | null>(null);
const [history, setHistory] = useState<GeneratorHistoryPoint[]>([]);
const [loading, setLoading] = useState(false);
useEffect(() => {
if (!genId) return;
setLoading(true);
Promise.all([
fetchGeneratorStatus(siteId),
fetchGeneratorHistory(siteId, genId, hours),
]).then(([statuses, hist]) => {
setStatus(statuses.find(g => g.gen_id === genId) ?? null);
setHistory(hist);
}).finally(() => setLoading(false));
}, [siteId, genId, hours]);
return (
<Sheet open={genId != null} onOpenChange={open => { if (!open) onClose(); }}>
<SheetContent className="w-[500px] sm:w-[560px] overflow-y-auto" side="right">
<SheetHeader className="mb-2">
<SheetTitle className="flex items-center justify-between">
<span>{genId?.toUpperCase() ?? "Generator"}</span>
{status && (
<Badge
variant="outline"
className={cn("text-xs capitalize border", STATUS_BADGE_CLASS[status.state] ?? STATUS_BADGE_CLASS.unknown)}
>
{status.state}
</Badge>
)}
</SheetTitle>
{status && (
<p className="text-xs text-muted-foreground">
Diesel generator · 500 kW rated · 2,000 L tank
{status.run_hours != null ? ` · ${status.run_hours.toLocaleString()} run hours` : ""}
</p>
)}
</SheetHeader>
{loading && !status ? (
<div className="space-y-3 mt-4">
{Array.from({ length: 8 }).map((_, i) => <Skeleton key={i} className="h-8 w-full" />)}
</div>
) : status ? (
<Tabs defaultValue="overview">
<div className="flex items-center justify-between mt-3 mb-1">
<TabsList>
<TabsTrigger value="overview">Overview</TabsTrigger>
<TabsTrigger value="engine">Engine</TabsTrigger>
<TabsTrigger value="electrical">Electrical</TabsTrigger>
<TabsTrigger value="trends">Trends</TabsTrigger>
</TabsList>
<TimeRangePicker value={hours} onChange={setHours} />
</div>
<TabsContent value="overview"> <OverviewTab gen={status} /></TabsContent>
<TabsContent value="engine"> <EngineTab gen={status} /></TabsContent>
<TabsContent value="electrical"> <ElectricalTab gen={status} /></TabsContent>
<TabsContent value="trends"> <TrendsTab history={history} /></TabsContent>
</Tabs>
) : (
<p className="text-sm text-muted-foreground mt-4">No data available for {genId}.</p>
)}
</SheetContent>
</Sheet>
);
}
const STATUS_BADGE_CLASS: Record<string, string> = STATE_BADGE;

View file

@ -0,0 +1,77 @@
import Link from "next/link";
import { Card, CardContent } from "@/components/ui/card";
import { Skeleton } from "@/components/ui/skeleton";
import { LucideIcon, TrendingUp, TrendingDown, Minus } from "lucide-react";
import { cn } from "@/lib/utils";
interface KpiCardProps {
title: string;
value: string;
icon: LucideIcon;
iconColor: string;
status: "ok" | "warning" | "critical";
hint?: string;
loading?: boolean;
trend?: number | null;
trendLabel?: string;
trendInvert?: boolean;
href?: string; // optional navigation target
}
const statusBorder: Record<string, string> = {
ok: "border-l-4 border-l-green-500",
warning: "border-l-4 border-l-amber-500",
critical: "border-l-4 border-l-destructive",
};
export function KpiCard({ title, value, icon: Icon, iconColor, status, hint, loading, trend, trendLabel, trendInvert, href }: KpiCardProps) {
const hasTrend = trend !== null && trend !== undefined;
const isUp = hasTrend && trend! > 0;
const isDown = hasTrend && trend! < 0;
const isFlat = hasTrend && trend! === 0;
// For temp/alarms: up is bad (red), down is good (green)
// For power: up might be warning, down is fine
const trendGood = trendInvert ? isDown : isUp;
const trendBad = trendInvert ? isUp : false;
const trendColor = isFlat ? "text-muted-foreground"
: trendGood ? "text-green-400"
: trendBad ? "text-destructive"
: "text-amber-400";
const inner = (
<CardContent className="p-5">
<div className="flex items-start justify-between">
<div className="space-y-1 flex-1">
<p className="text-xs font-medium text-muted-foreground uppercase tracking-wider">
{title}
</p>
{loading ? (
<Skeleton className="h-8 w-24" />
) : (
<p className="text-2xl font-bold tracking-tight">{value}</p>
)}
{hasTrend && !loading ? (
<div className={cn("flex items-center gap-1 text-[10px]", trendColor)}>
{isFlat ? <Minus className="w-3 h-3" /> : isUp ? <TrendingUp className="w-3 h-3" /> : <TrendingDown className="w-3 h-3" />}
<span>{trendLabel ?? (trend! > 0 ? `+${trend}` : `${trend}`)}</span>
<span className="text-muted-foreground">vs prev period</span>
</div>
) : (
hint && <p className="text-[10px] text-muted-foreground">{hint}</p>
)}
</div>
<div className="p-2 rounded-md bg-muted/50 shrink-0">
<Icon className={cn("w-5 h-5", iconColor)} />
</div>
</div>
</CardContent>
);
return (
<Card className={cn("relative overflow-hidden", statusBorder[status], href && "cursor-pointer hover:bg-muted/20 transition-colors")}>
{href ? <Link href={href} className="block">{inner}</Link> : inner}
</Card>
);
}

View file

@ -0,0 +1,144 @@
"use client";
import { useState } from "react";
import Link from "next/link";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Skeleton } from "@/components/ui/skeleton";
import { Map as MapIcon, Wind } from "lucide-react";
import { cn } from "@/lib/utils";
import { useThresholds } from "@/lib/threshold-context";
import type { RackCapacity } from "@/lib/api";
type RowLayout = { label: string; racks: string[] };
type RoomLayout = { label: string; crac_id: string; rows: RowLayout[] };
type FloorLayout = Record<string, RoomLayout>;
interface Props {
layout: FloorLayout | null;
racks: RackCapacity[];
loading: boolean;
}
function tempBg(temp: number | null, warn: number, crit: number): string {
if (temp === null) return "oklch(0.22 0.02 265)";
if (temp >= crit + 4) return "oklch(0.55 0.22 25)";
if (temp >= crit) return "oklch(0.65 0.20 45)";
if (temp >= warn) return "oklch(0.72 0.18 84)";
if (temp >= warn - 2) return "oklch(0.78 0.14 140)";
if (temp >= warn - 4) return "oklch(0.68 0.14 162)";
return "oklch(0.60 0.15 212)";
}
export function MiniFloorMap({ layout, racks, loading }: Props) {
const { thresholds } = useThresholds();
const warn = thresholds.temp.warn;
const crit = thresholds.temp.critical;
const rackMap: globalThis.Map<string, RackCapacity> = new globalThis.Map(racks.map(r => [r.rack_id, r] as [string, RackCapacity]));
const roomIds = layout ? Object.keys(layout) : [];
const [activeRoom, setActiveRoom] = useState<string>(() => roomIds[0] ?? "");
const currentRoomId = activeRoom || roomIds[0] || "";
return (
<Card className="flex flex-col">
<CardHeader className="pb-2 shrink-0">
<div className="flex items-center justify-between">
<CardTitle className="text-sm font-semibold flex items-center gap-2">
<MapIcon className="w-4 h-4 text-primary" />
Floor Map Temperature
</CardTitle>
<Link
href="/floor-map"
className="text-[10px] text-primary hover:underline"
>
Full map
</Link>
</div>
{/* Room tabs */}
{roomIds.length > 1 && (
<div className="flex gap-1 mt-2">
{roomIds.map(id => (
<button
key={id}
onClick={() => setActiveRoom(id)}
className={cn(
"px-3 py-1 rounded text-[11px] font-medium transition-colors",
currentRoomId === id
? "bg-primary text-primary-foreground"
: "bg-muted/40 text-muted-foreground hover:bg-muted/60"
)}
>
{layout?.[id]?.label ?? id}
</button>
))}
</div>
)}
</CardHeader>
<CardContent className="flex-1 min-h-0">
{loading ? (
<Skeleton className="h-40 w-full rounded-lg" />
) : !layout || roomIds.length === 0 ? (
<div className="h-40 flex items-center justify-center text-sm text-muted-foreground">
No layout configured
</div>
) : (
<Link href="/floor-map" className="block group">
<div className="space-y-2">
{(layout[currentRoomId]?.rows ?? []).map((row, rowIdx, allRows) => (
<div key={row.label}>
{/* Rack row */}
<div className="flex flex-wrap gap-[3px]">
{row.racks.map(rackId => {
const rack = rackMap.get(rackId);
const bg = tempBg(rack?.temp ?? null, warn, crit);
return (
<div
key={rackId}
title={`${rackId}${rack?.temp != null ? ` · ${rack.temp}°C` : " · offline"}`}
className="rounded-[2px] shrink-0"
style={{ width: 14, height: 20, backgroundColor: bg }}
/>
);
})}
</div>
{/* Cold aisle separator between rows */}
{rowIdx < allRows.length - 1 && (
<div className="flex items-center gap-2 my-1.5 text-[9px] font-semibold uppercase tracking-widest"
style={{ color: "oklch(0.62 0.17 212 / 70%)" }}>
<div className="flex-1 h-px border-t border-dashed" style={{ borderColor: "oklch(0.62 0.17 212 / 25%)" }} />
<Wind className="w-2.5 h-2.5 shrink-0" />
<span>Cold Aisle</span>
<div className="flex-1 h-px border-t border-dashed" style={{ borderColor: "oklch(0.62 0.17 212 / 25%)" }} />
</div>
)}
</div>
))}
{/* CRAC label */}
{layout[currentRoomId]?.crac_id && (
<div className="flex items-center justify-center gap-1.5 rounded-md py-1 text-[10px] font-medium"
style={{ backgroundColor: "oklch(0.62 0.17 212 / 8%)", color: "oklch(0.62 0.17 212)" }}>
<Wind className="w-3 h-3" />
{layout[currentRoomId].crac_id.toUpperCase()}
</div>
)}
</div>
{/* Temp legend */}
<div className="flex items-center gap-1 mt-3 text-[10px] text-muted-foreground">
<span>Cool</span>
{(["oklch(0.60 0.15 212)","oklch(0.68 0.14 162)","oklch(0.78 0.14 140)","oklch(0.72 0.18 84)","oklch(0.65 0.20 45)","oklch(0.55 0.22 25)"] as string[]).map((c, i) => (
<span key={i} className="w-5 h-2.5 rounded-sm inline-block" style={{ backgroundColor: c }} />
))}
<span>Hot</span>
<span className="ml-auto opacity-60 group-hover:opacity-100 transition-opacity">Click to open full map</span>
</div>
</Link>
)}
</CardContent>
</Card>
);
}

View file

@ -0,0 +1,58 @@
"use client";
import { AreaChart, Area, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer } from "recharts";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Skeleton } from "@/components/ui/skeleton";
import type { PowerBucket } from "@/lib/api";
interface Props {
data: PowerBucket[];
loading?: boolean;
}
function formatTime(iso: string) {
return new Date(iso).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
}
export function PowerTrendChart({ data, loading }: Props) {
const chartData = data.map((d) => ({ time: formatTime(d.bucket), power: d.total_kw }));
return (
<Card>
<CardHeader className="pb-2">
<div className="flex items-center justify-between">
<CardTitle className="text-sm font-semibold">Total Power (kW)</CardTitle>
<span className="text-xs text-muted-foreground">Last 60 minutes</span>
</div>
</CardHeader>
<CardContent>
{loading ? (
<Skeleton className="h-64 w-full" />
) : chartData.length === 0 ? (
<div className="h-[200px] flex items-center justify-center text-sm text-muted-foreground">
Waiting for data...
</div>
) : (
<ResponsiveContainer width="100%" height={200}>
<AreaChart data={chartData} margin={{ top: 4, right: 4, left: -20, bottom: 0 }}>
<defs>
<linearGradient id="powerGradient" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor="oklch(0.62 0.17 212)" stopOpacity={0.3} />
<stop offset="95%" stopColor="oklch(0.62 0.17 212)" stopOpacity={0} />
</linearGradient>
</defs>
<CartesianGrid strokeDasharray="3 3" stroke="oklch(1 0 0 / 8%)" />
<XAxis dataKey="time" tick={{ fontSize: 10, fill: "oklch(0.65 0.04 257)" }} tickLine={false} axisLine={false} />
<YAxis tick={{ fontSize: 10, fill: "oklch(0.65 0.04 257)" }} tickLine={false} axisLine={false} />
<Tooltip
contentStyle={{ backgroundColor: "oklch(0.16 0.04 265)", border: "1px solid oklch(1 0 0 / 9%)", borderRadius: "6px", fontSize: "12px" }}
formatter={(value) => [`${value} kW`, "Power"]}
/>
<Area type="monotone" dataKey="power" stroke="oklch(0.62 0.17 212)" strokeWidth={2} fill="url(#powerGradient)" dot={false} activeDot={{ r: 4 }} />
</AreaChart>
</ResponsiveContainer>
)}
</CardContent>
</Card>
);
}

View file

@ -0,0 +1,422 @@
"use client";
import { useEffect, useState } from "react";
import {
fetchRackHistory, fetchRackDevices,
type RackHistory, type Device,
} from "@/lib/api";
import { Sheet, SheetContent, SheetHeader, SheetTitle } from "@/components/ui/sheet";
import { Skeleton } from "@/components/ui/skeleton";
import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs";
import { TimeRangePicker } from "@/components/ui/time-range-picker";
import { Badge } from "@/components/ui/badge";
import {
LineChart, Line, AreaChart, Area,
XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer,
} from "recharts";
import { Thermometer, Zap, Droplets, Bell, Server } from "lucide-react";
import { cn } from "@/lib/utils";
interface Props {
siteId: string;
rackId: string | null;
onClose: () => void;
}
// ── Helpers ───────────────────────────────────────────────────────────────────
function timeAgo(iso: string) {
const diff = Date.now() - new Date(iso).getTime();
const m = Math.floor(diff / 60000);
if (m < 1) return "just now";
if (m < 60) return `${m}m ago`;
return `${Math.floor(m / 60)}h ago`;
}
function formatTime(iso: string) {
return new Date(iso).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
}
// ── Device type styles ────────────────────────────────────────────────────────
const TYPE_STYLES: Record<string, { bg: string; border: string; text: string; label: string }> = {
server: { bg: "bg-blue-500/15", border: "border-blue-500/40", text: "text-blue-300", label: "Server" },
switch: { bg: "bg-green-500/15", border: "border-green-500/40", text: "text-green-300", label: "Switch" },
patch_panel: { bg: "bg-slate-500/15", border: "border-slate-400/40", text: "text-slate-300", label: "Patch Panel" },
pdu: { bg: "bg-amber-500/15", border: "border-amber-500/40", text: "text-amber-300", label: "PDU" },
storage: { bg: "bg-purple-500/15", border: "border-purple-500/40", text: "text-purple-300", label: "Storage" },
firewall: { bg: "bg-red-500/15", border: "border-red-500/40", text: "text-red-300", label: "Firewall" },
kvm: { bg: "bg-teal-500/15", border: "border-teal-500/40", text: "text-teal-300", label: "KVM" },
};
const TYPE_DOT: Record<string, string> = {
server: "bg-blue-400",
switch: "bg-green-400",
patch_panel: "bg-slate-400",
pdu: "bg-amber-400",
storage: "bg-purple-400",
firewall: "bg-red-400",
kvm: "bg-teal-400",
};
// ── U-diagram ─────────────────────────────────────────────────────────────────
const TOTAL_U = 42;
const U_PX = 20; // height per U in pixels
type Segment = { u: number; height: number; device: Device | null };
function buildSegments(devices: Device[]): Segment[] {
const sorted = [...devices].sort((a, b) => a.u_start - b.u_start);
const segs: Segment[] = [];
let u = 1;
let i = 0;
while (u <= TOTAL_U) {
if (i < sorted.length && sorted[i].u_start === u) {
const d = sorted[i];
segs.push({ u, height: d.u_height, device: d });
u += d.u_height;
i++;
} else {
const nextU = i < sorted.length ? sorted[i].u_start : TOTAL_U + 1;
const empty = Math.min(nextU, TOTAL_U + 1) - u;
if (empty > 0) {
segs.push({ u, height: empty, device: null });
u += empty;
} else {
break;
}
}
}
return segs;
}
function RackDiagram({ devices, loading }: { devices: Device[]; loading: boolean }) {
const [selected, setSelected] = useState<Device | null>(null);
if (loading) {
return (
<div className="mt-3 space-y-1 flex-1">
{Array.from({ length: 12 }).map((_, i) => (
<Skeleton key={i} className="h-5 w-full" />
))}
</div>
);
}
const segments = buildSegments(devices);
const totalPower = devices.reduce((s, d) => s + d.power_draw_w, 0);
return (
<div className="mt-3 flex flex-col flex-1 min-h-0 gap-3">
{/* Legend */}
<div className="flex flex-wrap gap-x-3 gap-y-1 shrink-0">
{Object.entries(TYPE_STYLES).map(([type, style]) => (
devices.some(d => d.type === type) ? (
<div key={type} className="flex items-center gap-1">
<span className={cn("w-2 h-2 rounded-sm", TYPE_DOT[type])} />
<span className="text-[10px] text-muted-foreground">{style.label}</span>
</div>
) : null
))}
</div>
{/* Rack diagram */}
<div className="flex flex-col flex-1 min-h-0 border border-border/50 rounded-lg overflow-hidden">
{/* Header */}
<div className="flex bg-muted/40 border-b border-border/50 px-2 py-1 shrink-0">
<span className="w-8 text-[9px] text-muted-foreground text-right pr-1 shrink-0">U</span>
<span className="flex-1 text-[9px] text-muted-foreground pl-2">
{devices[0]?.rack_id.toUpperCase() ?? "Rack"} 42U
</span>
<span className="text-[9px] text-muted-foreground">{totalPower} W total</span>
</div>
{/* Slots */}
<div className="flex-1 overflow-y-auto">
{segments.map((seg) => {
const style = seg.device ? (TYPE_STYLES[seg.device.type] ?? TYPE_STYLES.server) : null;
const isSelected = selected?.device_id === seg.device?.device_id;
return (
<div
key={seg.u}
style={{ height: seg.height * U_PX }}
className={cn(
"flex items-stretch border-b border-border/20 last:border-0",
seg.device && "cursor-pointer",
)}
onClick={() => setSelected(seg.device && isSelected ? null : seg.device)}
>
{/* U number */}
<div className="w-8 flex items-start justify-end pt-1 pr-1.5 shrink-0">
<span className="text-[9px] text-muted-foreground/50 font-mono leading-none">
{seg.u}
</span>
</div>
{/* Device or empty */}
<div className="flex-1 flex items-stretch py-px pr-1">
{seg.device ? (
<div className={cn(
"flex-1 rounded border flex items-center px-2 gap-2 transition-colors",
style!.bg, style!.border,
isSelected && "ring-1 ring-primary/50",
)}>
<span className={cn("text-xs font-medium truncate flex-1", style!.text)}>
{seg.device.name}
</span>
{seg.height >= 2 && (
<span className="text-[9px] text-muted-foreground/60 font-mono shrink-0">
{seg.device.ip !== "-" ? seg.device.ip : ""}
</span>
)}
<span className="text-[9px] text-muted-foreground/60 font-mono shrink-0">
{seg.device.u_height}U
</span>
</div>
) : (
<div className="flex-1 rounded border border-dashed border-border/25 flex items-center px-2">
{seg.height > 1 && (
<span className="text-[9px] text-muted-foreground/25">
{seg.height}U empty
</span>
)}
</div>
)}
</div>
</div>
);
})}
</div>
</div>
{/* Selected device detail */}
{selected && (() => { // shrink-0 via parent gap
const style = TYPE_STYLES[selected.type] ?? TYPE_STYLES.server;
return (
<div className={cn("rounded-lg border p-3 space-y-2", style.bg, style.border)}>
<div className="flex items-start justify-between gap-2">
<div>
<p className={cn("text-sm font-semibold", style.text)}>{selected.name}</p>
<p className="text-[10px] text-muted-foreground mt-0.5">{style.label}</p>
</div>
<span className="text-[10px] font-semibold px-2 py-0.5 rounded-full bg-green-500/10 text-green-400">
Online
</span>
</div>
<div className="grid grid-cols-2 gap-x-4 gap-y-1 text-xs">
<div><span className="text-muted-foreground">Serial: </span><span className="font-mono">{selected.serial}</span></div>
<div><span className="text-muted-foreground">IP: </span><span className="font-mono">{selected.ip !== "-" ? selected.ip : "—"}</span></div>
<div><span className="text-muted-foreground">Position: </span><span>U{selected.u_start}U{selected.u_start + selected.u_height - 1}</span></div>
<div><span className="text-muted-foreground">Power: </span><span>{selected.power_draw_w} W</span></div>
</div>
</div>
);
})()}
</div>
);
}
// ── History tab ───────────────────────────────────────────────────────────────
function HistoryTab({ data, hours, onHoursChange, loading }: {
data: RackHistory | null;
hours: number;
onHoursChange: (h: number) => void;
loading: boolean;
}) {
const chartData = (data?.history ?? []).map(p => ({ ...p, time: formatTime(p.bucket) }));
const latest = chartData[chartData.length - 1];
return (
<div>
<div className="flex justify-end mt-3 mb-4">
<TimeRangePicker value={hours} onChange={onHoursChange} />
</div>
{loading ? (
<div className="space-y-4">
<Skeleton className="h-40 w-full" /><Skeleton className="h-40 w-full" />
</div>
) : (
<div className="space-y-5">
{latest && (
<div className="grid grid-cols-3 gap-3">
<div className="rounded-lg bg-muted/30 p-3 text-center">
<Thermometer className="w-4 h-4 mx-auto mb-1 text-primary" />
<p className="text-lg font-bold">{latest.temperature !== undefined ? `${latest.temperature}°C` : "—"}</p>
<p className="text-[10px] text-muted-foreground">Temperature</p>
</div>
<div className="rounded-lg bg-muted/30 p-3 text-center">
<Zap className="w-4 h-4 mx-auto mb-1 text-amber-400" />
<p className="text-lg font-bold">{latest.power_kw !== undefined ? `${latest.power_kw} kW` : "—"}</p>
<p className="text-[10px] text-muted-foreground">Power</p>
</div>
<div className="rounded-lg bg-muted/30 p-3 text-center">
<Droplets className="w-4 h-4 mx-auto mb-1 text-blue-400" />
<p className="text-lg font-bold">{latest.humidity !== undefined ? `${latest.humidity}%` : "—"}</p>
<p className="text-[10px] text-muted-foreground">Humidity</p>
</div>
</div>
)}
<div>
<p className="text-xs font-medium text-muted-foreground mb-2 flex items-center gap-1">
<Thermometer className="w-3 h-3" /> Temperature (°C)
</p>
<ResponsiveContainer width="100%" height={140}>
<LineChart data={chartData} margin={{ top: 4, right: 4, left: -20, bottom: 0 }}>
<CartesianGrid strokeDasharray="3 3" stroke="oklch(1 0 0 / 8%)" />
<XAxis dataKey="time" tick={{ fontSize: 9, fill: "oklch(0.65 0.04 257)" }} tickLine={false} axisLine={false} />
<YAxis tick={{ fontSize: 9, fill: "oklch(0.65 0.04 257)" }} tickLine={false} axisLine={false} domain={["auto", "auto"]} />
<Tooltip
contentStyle={{ backgroundColor: "oklch(0.16 0.04 265)", border: "1px solid oklch(1 0 0 / 9%)", borderRadius: "6px", fontSize: "11px" }}
formatter={(v) => [`${v}°C`, "Temp"]}
/>
<Line type="monotone" dataKey="temperature" stroke="oklch(0.62 0.17 212)" strokeWidth={2} dot={false} activeDot={{ r: 3 }} />
</LineChart>
</ResponsiveContainer>
</div>
<div>
<p className="text-xs font-medium text-muted-foreground mb-2 flex items-center gap-1">
<Zap className="w-3 h-3" /> Power Draw (kW)
</p>
<ResponsiveContainer width="100%" height={140}>
<AreaChart data={chartData} margin={{ top: 4, right: 4, left: -20, bottom: 0 }}>
<defs>
<linearGradient id="powerGrad" x1="0" y1="0" x2="0" y2="1">
<stop offset="5%" stopColor="oklch(0.78 0.17 84)" stopOpacity={0.3} />
<stop offset="95%" stopColor="oklch(0.78 0.17 84)" stopOpacity={0} />
</linearGradient>
</defs>
<CartesianGrid strokeDasharray="3 3" stroke="oklch(1 0 0 / 8%)" />
<XAxis dataKey="time" tick={{ fontSize: 9, fill: "oklch(0.65 0.04 257)" }} tickLine={false} axisLine={false} />
<YAxis tick={{ fontSize: 9, fill: "oklch(0.65 0.04 257)" }} tickLine={false} axisLine={false} domain={[0, "auto"]} />
<Tooltip
contentStyle={{ backgroundColor: "oklch(0.16 0.04 265)", border: "1px solid oklch(1 0 0 / 9%)", borderRadius: "6px", fontSize: "11px" }}
formatter={(v) => [`${v} kW`, "Power"]}
/>
<Area type="monotone" dataKey="power_kw" stroke="oklch(0.78 0.17 84)" fill="url(#powerGrad)" strokeWidth={2} dot={false} />
</AreaChart>
</ResponsiveContainer>
</div>
</div>
)}
</div>
);
}
// ── Alarms tab ────────────────────────────────────────────────────────────────
const severityColors: Record<string, string> = {
critical: "bg-destructive/15 text-destructive border-destructive/30",
warning: "bg-amber-500/15 text-amber-400 border-amber-500/30",
info: "bg-blue-500/15 text-blue-400 border-blue-500/30",
};
const stateColors: Record<string, string> = {
active: "bg-destructive/10 text-destructive",
acknowledged: "bg-amber-500/10 text-amber-400",
resolved: "bg-green-500/10 text-green-400",
};
function AlarmsTab({ data }: { data: RackHistory | null }) {
return (
<div className="mt-3">
{!data || data.alarms.length === 0 ? (
<p className="text-xs text-muted-foreground text-center py-8">
No alarms on record for this rack.
</p>
) : (
<div className="space-y-2">
{data.alarms.map((alarm) => (
<div
key={alarm.id}
className={cn("rounded-lg border px-3 py-2 text-xs", severityColors[alarm.severity] ?? severityColors.info)}
>
<div className="flex items-center justify-between gap-2">
<span className="font-medium">{alarm.message}</span>
<Badge className={cn("text-[9px] border-0 shrink-0", stateColors[alarm.state] ?? stateColors.active)}>
{alarm.state}
</Badge>
</div>
<p className="mt-0.5 opacity-70">{timeAgo(alarm.triggered_at)}</p>
</div>
))}
</div>
)}
</div>
);
}
// ── Sheet ─────────────────────────────────────────────────────────────────────
export function RackDetailSheet({ siteId, rackId, onClose }: Props) {
const [data, setData] = useState<RackHistory | null>(null);
const [devices, setDevices] = useState<Device[]>([]);
const [hours, setHours] = useState(6);
const [histLoading, setHistLoad] = useState(false);
const [devLoading, setDevLoad] = useState(false);
useEffect(() => {
if (!rackId) { setData(null); setDevices([]); return; }
setHistLoad(true);
setDevLoad(true);
fetchRackHistory(siteId, rackId, hours)
.then(setData).catch(() => setData(null))
.finally(() => setHistLoad(false));
fetchRackDevices(siteId, rackId)
.then(setDevices).catch(() => setDevices([]))
.finally(() => setDevLoad(false));
}, [siteId, rackId, hours]);
const alarmCount = data?.alarms.filter(a => a.state === "active").length ?? 0;
return (
<Sheet open={!!rackId} onOpenChange={open => { if (!open) onClose(); }}>
<SheetContent side="right" className="w-full sm:max-w-lg flex flex-col h-dvh overflow-hidden">
<SheetHeader className="mb-2 shrink-0">
<SheetTitle className="flex items-center gap-2">
<Server className="w-4 h-4 text-primary" />
{rackId?.toUpperCase() ?? ""}
</SheetTitle>
<p className="text-xs text-muted-foreground">Singapore DC01</p>
</SheetHeader>
<Tabs defaultValue="layout" className="flex flex-col flex-1 min-h-0">
<TabsList className="w-full shrink-0">
<TabsTrigger value="layout" className="flex-1">Layout</TabsTrigger>
<TabsTrigger value="history" className="flex-1">History</TabsTrigger>
<TabsTrigger value="alarms" className="flex-1">
Alarms
{alarmCount > 0 && (
<span className="ml-1.5 text-[9px] font-bold text-destructive">{alarmCount}</span>
)}
</TabsTrigger>
</TabsList>
<TabsContent value="layout" className="flex flex-col flex-1 min-h-0 overflow-hidden mt-0">
<RackDiagram devices={devices} loading={devLoading} />
</TabsContent>
<TabsContent value="history" className="flex-1 overflow-y-auto">
<HistoryTab
data={data}
hours={hours}
onHoursChange={setHours}
loading={histLoading}
/>
</TabsContent>
<TabsContent value="alarms" className="flex-1 overflow-y-auto">
<AlarmsTab data={data} />
</TabsContent>
</Tabs>
</SheetContent>
</Sheet>
);
}

View file

@ -0,0 +1,97 @@
import { useRouter } from "next/navigation";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Skeleton } from "@/components/ui/skeleton";
import { Thermometer, Zap, Bell, ChevronRight } from "lucide-react";
import { cn } from "@/lib/utils";
import type { RoomStatus } from "@/lib/api";
interface Props {
rooms: RoomStatus[];
loading?: boolean;
}
const statusConfig: Record<string, { label: string; dot: string; bg: string }> = {
ok: { label: "Healthy", dot: "bg-green-500", bg: "bg-green-500/10 text-green-400" },
warning: { label: "Warning", dot: "bg-amber-500", bg: "bg-amber-500/10 text-amber-400" },
critical: { label: "Critical", dot: "bg-destructive", bg: "bg-destructive/10 text-destructive" },
};
const roomLabels: Record<string, string> = {
"hall-a": "Hall A",
"hall-b": "Hall B",
"hall-c": "Hall C",
};
export function RoomStatusGrid({ rooms, loading }: Props) {
const router = useRouter();
return (
<Card>
<CardHeader className="pb-2">
<CardTitle className="text-sm font-semibold">Room Status Singapore DC01</CardTitle>
</CardHeader>
<CardContent>
{loading ? (
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
{Array.from({ length: 2 }).map((_, i) => (
<Skeleton key={i} className="h-28 w-full rounded-lg" />
))}
</div>
) : rooms.length === 0 ? (
<div className="h-28 flex items-center justify-center text-sm text-muted-foreground">
Waiting for room data...
</div>
) : (
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
{rooms.map((room) => {
const s = statusConfig[room.status];
return (
<button
key={room.room_id}
onClick={() => router.push("/environmental")}
className="rounded-lg border border-border bg-muted/30 p-4 space-y-3 text-left w-full hover:bg-muted/50 hover:border-primary/30 transition-colors group"
>
<div className="flex items-center justify-between">
<span className="text-sm font-medium">
{roomLabels[room.room_id] ?? room.room_id}
</span>
<div className="flex items-center gap-2">
<span className={cn("flex items-center gap-1.5 text-[10px] font-semibold px-2 py-0.5 rounded-full uppercase tracking-wide", s.bg)}>
<span className={cn("w-1.5 h-1.5 rounded-full", s.dot)} />
{s.label}
</span>
<ChevronRight className="w-3.5 h-3.5 text-muted-foreground/40 group-hover:text-primary transition-colors" />
</div>
</div>
<div className="grid grid-cols-3 gap-2 text-xs">
<div className="flex flex-col gap-0.5">
<span className="flex items-center gap-1 text-muted-foreground">
<Thermometer className="w-3 h-3" /> Temp
</span>
<span className="font-semibold">{room.avg_temp.toFixed(1)}°C</span>
</div>
<div className="flex flex-col gap-0.5">
<span className="flex items-center gap-1 text-muted-foreground">
<Zap className="w-3 h-3" /> Power
</span>
<span className="font-semibold">{room.total_kw.toFixed(1)} kW</span>
</div>
<div className="flex flex-col gap-0.5">
<span className="flex items-center gap-1 text-muted-foreground">
<Bell className="w-3 h-3" /> Alarms
</span>
<span className={cn("font-semibold", room.alarm_count > 0 ? "text-destructive" : "")}>
{room.alarm_count === 0 ? "None" : room.alarm_count}
</span>
</div>
</div>
</button>
);
})}
</div>
)}
</CardContent>
</Card>
);
}

View file

@ -0,0 +1,75 @@
"use client";
import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, ReferenceLine } from "recharts";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Skeleton } from "@/components/ui/skeleton";
import type { TempBucket } from "@/lib/api";
interface Props {
data: TempBucket[];
loading?: boolean;
}
type ChartRow = { time: string; [roomId: string]: string | number };
function formatTime(iso: string) {
return new Date(iso).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
}
export function TemperatureTrendChart({ data, loading }: Props) {
// Pivot flat rows [{bucket, room_id, avg_temp}] into [{time, "hall-a": 23.1, "hall-b": 24.2}]
const bucketMap = new Map<string, ChartRow>();
for (const row of data) {
const time = formatTime(row.bucket);
if (!bucketMap.has(time)) bucketMap.set(time, { time });
bucketMap.get(time)![row.room_id] = row.avg_temp;
}
const chartData = Array.from(bucketMap.values());
const roomIds = [...new Set(data.map((d) => d.room_id))].sort();
const LINE_COLORS = ["oklch(0.62 0.17 212)", "oklch(0.7 0.15 162)", "oklch(0.75 0.18 84)"];
return (
<Card>
<CardHeader className="pb-2">
<div className="flex items-center justify-between">
<CardTitle className="text-sm font-semibold">Temperature (°C)</CardTitle>
<div className="flex items-center gap-3 text-xs text-muted-foreground">
{roomIds.map((id, i) => (
<span key={id} className="flex items-center gap-1">
<span className="w-3 h-0.5 inline-block" style={{ backgroundColor: LINE_COLORS[i] }} />
{id}
</span>
))}
</div>
</div>
</CardHeader>
<CardContent>
{loading ? (
<Skeleton className="h-64 w-full" />
) : chartData.length === 0 ? (
<div className="h-[200px] flex items-center justify-center text-sm text-muted-foreground">
Waiting for data...
</div>
) : (
<ResponsiveContainer width="100%" height={200}>
<LineChart data={chartData} margin={{ top: 4, right: 4, left: -20, bottom: 0 }}>
<CartesianGrid strokeDasharray="3 3" stroke="oklch(1 0 0 / 8%)" />
<XAxis dataKey="time" tick={{ fontSize: 10, fill: "oklch(0.65 0.04 257)" }} tickLine={false} axisLine={false} />
<YAxis tick={{ fontSize: 10, fill: "oklch(0.65 0.04 257)" }} tickLine={false} axisLine={false} domain={["auto", "auto"]} />
<Tooltip
contentStyle={{ backgroundColor: "oklch(0.16 0.04 265)", border: "1px solid oklch(1 0 0 / 9%)", borderRadius: "6px", fontSize: "12px" }}
formatter={(value, name) => [`${value}°C`, name]}
/>
<ReferenceLine y={26} stroke="oklch(0.78 0.17 84)" strokeDasharray="4 4" strokeWidth={1}
label={{ value: "Warn", fontSize: 9, fill: "oklch(0.78 0.17 84)", position: "right" }} />
{roomIds.map((id, i) => (
<Line key={id} type="monotone" dataKey={id} stroke={LINE_COLORS[i]} strokeWidth={2} dot={false} activeDot={{ r: 4 }} />
))}
</LineChart>
</ResponsiveContainer>
)}
</CardContent>
</Card>
);
}

View file

@ -0,0 +1,357 @@
"use client";
import { useEffect, useState, useCallback } from "react";
import {
Sheet, SheetContent, SheetHeader, SheetTitle,
} from "@/components/ui/sheet";
import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs";
import {
LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip as RechartTooltip,
ResponsiveContainer, ReferenceLine,
} from "recharts";
import { Battery, Zap, Activity, AlertTriangle, CheckCircle2, TrendingDown } from "lucide-react";
import { cn } from "@/lib/utils";
import { fetchUpsHistory, type UpsAsset, type UpsHistoryPoint } from "@/lib/api";
const SITE_ID = "sg-01";
function fmt(iso: string) {
return new Date(iso).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" });
}
function StatRow({ label, value, color }: { label: string; value: string; color?: string }) {
return (
<div className="flex items-center justify-between py-2 border-b border-border/20 last:border-0">
<span className="text-sm text-muted-foreground">{label}</span>
<span className={cn("text-sm font-semibold tabular-nums", color)}>{value}</span>
</div>
);
}
function GaugeBar({
label, value, max, unit, warnAt, critAt, reverse = false,
}: {
label: string; value: number | null; max: number; unit: string;
warnAt?: number; critAt?: number; reverse?: boolean;
}) {
const v = value ?? 0;
const pct = Math.min(100, (v / max) * 100);
const isWarn = warnAt !== undefined && (reverse ? v < warnAt : v >= warnAt);
const isCrit = critAt !== undefined && (reverse ? v < critAt : v >= critAt);
const barColor = isCrit ? "bg-destructive" : isWarn ? "bg-amber-500" : "bg-green-500";
const textColor = isCrit ? "text-destructive" : isWarn ? "text-amber-400" : "";
return (
<div className="space-y-1.5">
<div className="flex justify-between text-xs">
<span className="text-muted-foreground">{label}</span>
<span className={cn("font-semibold", textColor)}>
{value !== null ? `${value}${unit}` : "—"}
</span>
</div>
<div className="h-2 rounded-full bg-muted overflow-hidden">
<div className={cn("h-full rounded-full transition-all duration-500", barColor)}
style={{ width: `${pct}%` }} />
</div>
</div>
);
}
function MiniChart({
data, dataKey, color, refLines = [], unit, domain,
}: {
data: UpsHistoryPoint[]; dataKey: keyof UpsHistoryPoint; color: string;
refLines?: { y: number; color: string; label: string }[];
unit?: string; domain?: [number, number];
}) {
if (data.length === 0) {
return (
<div className="h-36 flex items-center justify-center text-xs text-muted-foreground">
Waiting for data...
</div>
);
}
return (
<ResponsiveContainer width="100%" height={144}>
<LineChart data={data} margin={{ top: 4, right: 8, left: -20, bottom: 0 }}>
<CartesianGrid strokeDasharray="3 3" stroke="oklch(1 0 0 / 8%)" />
<XAxis dataKey="bucket" tickFormatter={fmt}
tick={{ fontSize: 9, fill: "oklch(0.65 0.04 257)" }} tickLine={false} axisLine={false} />
<YAxis domain={domain} tick={{ fontSize: 9, fill: "oklch(0.65 0.04 257)" }}
tickLine={false} axisLine={false} />
<RechartTooltip
contentStyle={{ backgroundColor: "oklch(0.16 0.04 265)", border: "1px solid oklch(1 0 0 / 9%)", borderRadius: "6px", fontSize: "11px" }}
formatter={(v) => [`${v}${unit ?? ""}`, String(dataKey)]}
labelFormatter={(l) => fmt(String(l))}
/>
{refLines.map((r) => (
<ReferenceLine key={r.y} y={r.y} stroke={r.color} strokeDasharray="4 4" strokeWidth={1}
label={{ value: r.label, fontSize: 8, fill: r.color, position: "insideTopRight" }} />
))}
<Line type="monotone" dataKey={dataKey as string} stroke={color}
dot={false} strokeWidth={2} connectNulls />
</LineChart>
</ResponsiveContainer>
);
}
// ── Overview Tab ──────────────────────────────────────────────────────────────
function OverviewTab({ ups }: { ups: UpsAsset }) {
const onBattery = ups.state === "battery";
const overload = ups.state === "overload";
const charge = ups.charge_pct ?? 0;
const runtime = ups.runtime_min ?? null;
const load = ups.load_pct ?? null;
return (
<div className="space-y-5">
{/* State hero */}
<div className={cn(
"rounded-xl border px-5 py-4 flex items-center gap-4",
overload ? "border-destructive/40 bg-destructive/5" :
onBattery ? "border-amber-500/40 bg-amber-500/5" :
"border-green-500/30 bg-green-500/5",
)}>
<Battery className={cn("w-8 h-8",
overload ? "text-destructive" : onBattery ? "text-amber-400" : "text-green-400"
)} />
<div>
<p className={cn("text-xl font-bold",
overload ? "text-destructive" : onBattery ? "text-amber-400" : "text-green-400"
)}>
{overload ? "Overloaded" : onBattery ? "On Battery" : "Mains Power"}
</p>
<p className="text-xs text-muted-foreground mt-0.5">
{overload
? "Critical — load exceeds safe capacity"
: onBattery
? "Mains power lost — running on battery"
: "Grid power normal — charging battery"}
</p>
</div>
</div>
{/* Gauges */}
<div className="space-y-4">
<GaugeBar label="Battery charge" value={ups.charge_pct} max={100} unit="%" warnAt={80} critAt={50} reverse />
<GaugeBar label="Load" value={load} max={100} unit="%" warnAt={85} critAt={95} />
</div>
{/* Runtime + voltage row */}
<div className="grid grid-cols-2 gap-4">
<div className="rounded-lg bg-muted/20 px-4 py-3 text-center">
<p className="text-[10px] text-muted-foreground uppercase tracking-wide mb-1">Est. Runtime</p>
<p className={cn("text-2xl font-bold tabular-nums",
runtime !== null && runtime < 5 ? "text-destructive" :
runtime !== null && runtime < 15 ? "text-amber-400" : "text-green-400",
)}>
{runtime !== null ? `${Math.round(runtime)}` : "—"}
</p>
<p className="text-[10px] text-muted-foreground">minutes</p>
</div>
<div className="rounded-lg bg-muted/20 px-4 py-3 text-center">
<p className="text-[10px] text-muted-foreground uppercase tracking-wide mb-1">Input Voltage</p>
<p className={cn("text-2xl font-bold tabular-nums",
ups.voltage_v !== null && (ups.voltage_v < 210 || ups.voltage_v > 250) ? "text-amber-400" : "",
)}>
{ups.voltage_v !== null ? `${ups.voltage_v}` : "—"}
</p>
<p className="text-[10px] text-muted-foreground">V AC</p>
</div>
</div>
{/* Quick stats */}
<div className="space-y-0">
<StatRow label="Unit ID" value={ups.ups_id.toUpperCase()} />
<StatRow
label="Battery charge"
value={charge < 50 ? `${charge.toFixed(1)}% — Low` : `${charge.toFixed(1)}%`}
color={charge < 50 ? "text-destructive" : charge < 80 ? "text-amber-400" : "text-green-400"}
/>
<StatRow
label="Runtime remaining"
value={runtime !== null ? `${Math.round(runtime)} min` : "—"}
color={runtime !== null && runtime < 5 ? "text-destructive" : runtime !== null && runtime < 15 ? "text-amber-400" : ""}
/>
<StatRow
label="Load"
value={load !== null ? `${load.toFixed(1)}%` : "—"}
color={load !== null && load >= 95 ? "text-destructive" : load !== null && load >= 85 ? "text-amber-400" : ""}
/>
<StatRow
label="Input voltage"
value={ups.voltage_v !== null ? `${ups.voltage_v} V` : "—"}
color={ups.voltage_v !== null && (ups.voltage_v < 210 || ups.voltage_v > 250) ? "text-amber-400" : ""}
/>
</div>
</div>
);
}
// ── Battery Tab ───────────────────────────────────────────────────────────────
function BatteryTab({ history }: { history: UpsHistoryPoint[] }) {
const charge = history.map((d) => ({ ...d, bucket: d.bucket }));
const runtime = history.map((d) => ({ ...d, bucket: d.bucket }));
return (
<div className="space-y-6">
<div>
<p className="text-xs font-semibold text-muted-foreground uppercase tracking-wider mb-2">
Battery Charge (%)
</p>
<MiniChart
data={charge}
dataKey="charge_pct"
color="oklch(0.65 0.16 145)"
unit="%"
domain={[0, 100]}
refLines={[
{ y: 80, color: "oklch(0.72 0.18 84)", label: "Warn 80%" },
{ y: 50, color: "oklch(0.55 0.22 25)", label: "Crit 50%" },
]}
/>
</div>
<div>
<p className="text-xs font-semibold text-muted-foreground uppercase tracking-wider mb-2">
Estimated Runtime (min)
</p>
<MiniChart
data={runtime}
dataKey="runtime_min"
color="oklch(0.62 0.17 212)"
unit=" min"
refLines={[
{ y: 15, color: "oklch(0.72 0.18 84)", label: "Warn 15m" },
{ y: 5, color: "oklch(0.55 0.22 25)", label: "Crit 5m" },
]}
/>
</div>
</div>
);
}
// ── Load Tab ──────────────────────────────────────────────────────────────────
function LoadTab({ history }: { history: UpsHistoryPoint[] }) {
return (
<div className="space-y-6">
<div>
<p className="text-xs font-semibold text-muted-foreground uppercase tracking-wider mb-2">
Load (%)
</p>
<MiniChart
data={history}
dataKey="load_pct"
color="oklch(0.7 0.15 50)"
unit="%"
domain={[0, 100]}
refLines={[
{ y: 85, color: "oklch(0.72 0.18 84)", label: "Warn 85%" },
{ y: 95, color: "oklch(0.55 0.22 25)", label: "Crit 95%" },
]}
/>
</div>
<div>
<p className="text-xs font-semibold text-muted-foreground uppercase tracking-wider mb-2">
Input Voltage (V)
</p>
<MiniChart
data={history}
dataKey="voltage_v"
color="oklch(0.62 0.17 280)"
unit=" V"
refLines={[
{ y: 210, color: "oklch(0.55 0.22 25)", label: "Low 210V" },
{ y: 250, color: "oklch(0.55 0.22 25)", label: "High 250V" },
]}
/>
</div>
</div>
);
}
// ── Sheet ─────────────────────────────────────────────────────────────────────
interface Props {
ups: UpsAsset | null;
onClose: () => void;
}
export function UpsDetailSheet({ ups, onClose }: Props) {
const [history, setHistory] = useState<UpsHistoryPoint[]>([]);
const [hours, setHours] = useState(6);
const loadHistory = useCallback(async () => {
if (!ups) return;
try {
const h = await fetchUpsHistory(SITE_ID, ups.ups_id, hours);
setHistory(h);
} catch { /* keep stale */ }
}, [ups, hours]);
useEffect(() => {
if (ups) loadHistory();
}, [ups, loadHistory]);
if (!ups) return null;
const overload = ups.state === "overload";
const onBattery = ups.state === "battery";
return (
<Sheet open={!!ups} onOpenChange={(open) => { if (!open) onClose(); }}>
<SheetContent side="right" className="w-full sm:max-w-lg overflow-y-auto">
<SheetHeader className="pb-4 border-b border-border/30">
<div className="flex items-center justify-between">
<SheetTitle className="flex items-center gap-2">
<Battery className="w-5 h-5 text-primary" />
{ups.ups_id.toUpperCase()}
</SheetTitle>
<span className={cn(
"flex items-center gap-1.5 text-[10px] font-semibold px-2 py-0.5 rounded-full uppercase tracking-wide",
overload ? "bg-destructive/10 text-destructive" :
onBattery ? "bg-amber-500/10 text-amber-400" :
"bg-green-500/10 text-green-400",
)}>
{overload || onBattery
? <AlertTriangle className="w-3 h-3" />
: <CheckCircle2 className="w-3 h-3" />}
{overload ? "Overloaded" : onBattery ? "On Battery" : "Mains"}
</span>
</div>
</SheetHeader>
<div className="pt-4">
<Tabs defaultValue="overview">
<div className="flex items-center justify-between mb-4 gap-3">
<TabsList className="h-8">
<TabsTrigger value="overview" className="text-xs px-3">Overview</TabsTrigger>
<TabsTrigger value="battery" className="text-xs px-3">Battery</TabsTrigger>
<TabsTrigger value="load" className="text-xs px-3">Load & Voltage</TabsTrigger>
</TabsList>
<select
value={hours}
onChange={(e) => setHours(Number(e.target.value))}
className="text-xs bg-muted border border-border rounded px-2 py-1 text-foreground"
>
{[1, 3, 6, 12, 24].map((h) => <option key={h} value={h}>{h}h</option>)}
</select>
</div>
<TabsContent value="overview" className="mt-0">
<OverviewTab ups={ups} />
</TabsContent>
<TabsContent value="battery" className="mt-0">
<BatteryTab history={history} />
</TabsContent>
<TabsContent value="load" className="mt-0">
<LoadTab history={history} />
</TabsContent>
</Tabs>
</div>
</SheetContent>
</Sheet>
);
}

View file

@ -0,0 +1,47 @@
"use client";
import React from "react";
import { ErrorCard } from "@/components/ui/error-card";
interface Props {
children: React.ReactNode;
fallback?: React.ReactNode;
}
interface State {
hasError: boolean;
message: string;
}
export class ErrorBoundary extends React.Component<Props, State> {
constructor(props: Props) {
super(props);
this.state = { hasError: false, message: "" };
}
static getDerivedStateFromError(error: Error): State {
return { hasError: true, message: error.message };
}
componentDidCatch(error: Error, info: React.ErrorInfo) {
console.error("[ErrorBoundary]", error, info);
}
handleRetry = () => {
this.setState({ hasError: false, message: "" });
};
render() {
if (this.state.hasError) {
return (
this.props.fallback ?? (
<ErrorCard
message={this.state.message || "An unexpected error occurred."}
onRetry={this.handleRetry}
/>
)
);
}
return this.props.children;
}
}

View file

@ -0,0 +1,18 @@
import { cn } from "@/lib/utils";
interface PageShellProps {
children: React.ReactNode;
className?: string;
}
/**
* Standard page wrapper enforces consistent vertical spacing across all pages.
* Every (dashboard) page should wrap its content in this component.
*/
export function PageShell({ children, className }: PageShellProps) {
return (
<div className={cn("space-y-6", className)}>
{children}
</div>
);
}

View file

@ -0,0 +1,211 @@
"use client";
import Link from "next/link";
import { usePathname } from "next/navigation";
import {
LayoutDashboard,
Thermometer,
Zap,
Wind,
Server,
Bell,
BarChart3,
Settings,
Database,
ChevronLeft,
ChevronRight,
Map,
Gauge,
Fuel,
Droplets,
Flame,
Leaf,
Network,
Wrench,
} from "lucide-react";
import { cn } from "@/lib/utils";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { useState } from "react";
interface NavItem {
href: string;
label: string;
icon: React.ElementType;
}
interface NavGroup {
label: string;
items: NavItem[];
}
const navGroups: NavGroup[] = [
{
label: "Overview",
items: [
{ href: "/dashboard", label: "Dashboard", icon: LayoutDashboard },
{ href: "/floor-map", label: "Floor Map", icon: Map },
],
},
{
label: "Infrastructure",
items: [
{ href: "/power", label: "Power", icon: Zap },
{ href: "/generator", label: "Generator", icon: Fuel },
{ href: "/cooling", label: "Cooling", icon: Wind },
{ href: "/environmental", label: "Environmental", icon: Thermometer },
{ href: "/network", label: "Network", icon: Network },
],
},
{
label: "Safety",
items: [
{ href: "/leak", label: "Leak Detection", icon: Droplets },
{ href: "/fire", label: "Fire & Safety", icon: Flame },
],
},
{
label: "Operations",
items: [
{ href: "/assets", label: "Assets", icon: Server },
{ href: "/alarms", label: "Alarms", icon: Bell },
{ href: "/capacity", label: "Capacity", icon: Gauge },
],
},
{
label: "Management",
items: [
{ href: "/reports", label: "Reports", icon: BarChart3 },
{ href: "/energy", label: "Energy & CO₂", icon: Leaf },
{ href: "/maintenance", label: "Maintenance", icon: Wrench },
],
},
];
export function Sidebar() {
const pathname = usePathname();
const [collapsed, setCollapsed] = useState(false);
return (
<aside
className={cn(
"flex flex-col h-screen border-r border-sidebar-border bg-sidebar text-sidebar-foreground transition-all duration-300 ease-in-out shrink-0",
collapsed ? "w-16" : "w-60"
)}
>
{/* Logo */}
<div className={cn(
"flex items-center gap-3 px-4 py-5 border-b border-sidebar-border shrink-0",
collapsed && "justify-center px-2"
)}>
<div className="flex items-center justify-center w-8 h-8 rounded-lg bg-primary shrink-0">
<Database className="w-4 h-4 text-primary-foreground" />
</div>
{!collapsed && (
<div className="flex flex-col leading-tight">
<span className="text-sm font-bold tracking-tight text-sidebar-foreground">DemoBMS</span>
<span className="text-[10px] text-sidebar-foreground/50 uppercase tracking-widest">Infrastructure</span>
</div>
)}
</div>
{/* Main nav */}
<nav className="flex-1 px-2 py-3 overflow-y-auto space-y-4">
{navGroups.map((group) => (
<div key={group.label}>
{/* Section header — hidden when collapsed */}
{!collapsed && (
<p className="px-3 mb-1 text-[10px] font-semibold uppercase tracking-widest text-sidebar-foreground/35 select-none">
{group.label}
</p>
)}
{collapsed && (
<div className="my-1 mx-2 border-t border-sidebar-border/60" />
)}
<div className="space-y-0.5">
{group.items.map(({ href, label, icon: Icon }) => {
const active = pathname === href || pathname.startsWith(href + "/");
return (
<Tooltip key={href} delayDuration={0}>
<TooltipTrigger asChild>
<Link
href={href}
className={cn(
"flex items-center gap-3 rounded-md px-3 py-2.5 text-sm font-medium transition-colors",
"hover:bg-sidebar-accent hover:text-sidebar-accent-foreground",
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-1 focus-visible:ring-offset-background",
active
? "bg-sidebar-accent text-primary"
: "text-sidebar-foreground/70",
collapsed && "justify-center px-2"
)}
>
<Icon className={cn("w-4 h-4 shrink-0", active && "text-primary")} />
{!collapsed && <span className="flex-1">{label}</span>}
</Link>
</TooltipTrigger>
{collapsed && (
<TooltipContent side="right">{label}</TooltipContent>
)}
</Tooltip>
);
})}
</div>
</div>
))}
</nav>
{/* Bottom section: collapse toggle + settings */}
<div className="px-2 pb-4 border-t border-sidebar-border pt-3 space-y-0.5 shrink-0">
{/* Collapse toggle */}
<Tooltip delayDuration={0}>
<TooltipTrigger asChild>
<button
onClick={() => setCollapsed(!collapsed)}
className={cn(
"flex items-center gap-3 w-full rounded-md px-3 py-2.5 text-sm font-medium transition-colors",
"text-sidebar-foreground/50 hover:bg-sidebar-accent hover:text-sidebar-accent-foreground",
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-1 focus-visible:ring-offset-background",
collapsed && "justify-center px-2"
)}
aria-label={collapsed ? "Expand sidebar" : "Collapse sidebar"}
>
{collapsed ? (
<ChevronRight className="w-4 h-4 shrink-0" />
) : (
<>
<ChevronLeft className="w-4 h-4 shrink-0" />
<span>Collapse</span>
</>
)}
</button>
</TooltipTrigger>
{collapsed && (
<TooltipContent side="right">Expand sidebar</TooltipContent>
)}
</Tooltip>
{/* Settings */}
<Tooltip delayDuration={0}>
<TooltipTrigger asChild>
<Link
href="/settings"
className={cn(
"flex items-center gap-3 rounded-md px-3 py-2.5 text-sm font-medium transition-colors",
"hover:bg-sidebar-accent hover:text-sidebar-accent-foreground",
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-1 focus-visible:ring-offset-background",
pathname === "/settings" ? "bg-sidebar-accent text-primary" : "text-sidebar-foreground/70",
collapsed && "justify-center px-2"
)}
>
<Settings className="w-4 h-4 shrink-0" />
{!collapsed && <span>Settings</span>}
</Link>
</TooltipTrigger>
{collapsed && (
<TooltipContent side="right">Settings</TooltipContent>
)}
</Tooltip>
</div>
</aside>
);
}

View file

@ -0,0 +1,100 @@
"use client";
import { Bell, Menu, Moon, Sun } from "lucide-react";
import { SimulatorPanel } from "@/components/simulator/SimulatorPanel";
import { usePathname } from "next/navigation";
import Link from "next/link";
import { UserButton } from "@clerk/nextjs";
import { useTheme } from "next-themes";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import { useAlarmCount } from "@/lib/alarm-context";
import { Sheet, SheetContent, SheetTrigger } from "@/components/ui/sheet";
import { Sidebar } from "./sidebar";
const pageTitles: Record<string, string> = {
"/dashboard": "Overview",
"/floor-map": "Floor Map",
"/power": "Power Management",
"/generator": "Generator & Transfer",
"/cooling": "Cooling & Optimisation",
"/environmental": "Environmental Monitoring",
"/leak": "Leak Detection",
"/fire": "Fire & Safety",
"/network": "Network Infrastructure",
"/assets": "Asset Management",
"/alarms": "Alarms & Events",
"/capacity": "Capacity Planning",
"/reports": "Reports",
"/energy": "Energy & Sustainability",
"/maintenance": "Maintenance Windows",
"/settings": "Settings",
};
export function Topbar() {
const pathname = usePathname();
const pageTitle = pageTitles[pathname] ?? "DemoBMS";
const { active: activeAlarms } = useAlarmCount();
const { theme, setTheme } = useTheme();
return (
<header className="sticky top-0 z-30 flex items-center justify-between h-14 px-4 border-b border-border bg-background/80 backdrop-blur-sm shrink-0">
{/* Left: mobile menu + page title */}
<div className="flex items-center gap-3">
<Sheet>
<SheetTrigger asChild>
<Button variant="ghost" size="icon" className="md:hidden" aria-label="Open menu">
<Menu className="w-4 h-4" />
</Button>
</SheetTrigger>
<SheetContent side="left" className="p-0 w-60">
<Sidebar />
</SheetContent>
</Sheet>
<h1 className="text-sm font-semibold text-foreground">{pageTitle}</h1>
</div>
{/* Right: alarm bell + user */}
<div className="flex items-center gap-2">
{/* Alarm bell — single canonical alarm indicator */}
<Button variant="ghost" size="icon" className="relative h-8 w-8" asChild>
<Link href="/alarms" aria-label={`Alarms${activeAlarms > 0 ? `${activeAlarms} active` : ""}`}>
<Bell className="w-4 h-4" />
{activeAlarms > 0 && (
<Badge
variant="destructive"
className="absolute -top-0.5 -right-0.5 h-4 min-w-4 px-1 text-[9px] leading-none"
>
{activeAlarms > 99 ? "99+" : activeAlarms}
</Badge>
)}
</Link>
</Button>
{/* Scenario simulator */}
<SimulatorPanel />
{/* Theme toggle */}
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
onClick={() => setTheme(theme === "dark" ? "light" : "dark")}
aria-label="Toggle theme"
>
{theme === "dark" ? <Sun className="w-4 h-4" /> : <Moon className="w-4 h-4" />}
</Button>
{/* Clerk user button */}
<UserButton
appearance={{
elements: {
avatarBox: "w-7 h-7",
},
}}
/>
</div>
</header>
);
}

View file

@ -0,0 +1,289 @@
"use client"
import React, { useEffect, useState } from "react"
import {
Sheet,
SheetContent,
SheetHeader,
SheetTitle,
} from "@/components/ui/sheet"
import { fetchSensor, type SensorDevice } from "@/lib/api"
import { cn } from "@/lib/utils"
import { Cpu, Radio, WifiOff } from "lucide-react"
import { Skeleton } from "@/components/ui/skeleton"
// ── Props ─────────────────────────────────────────────────────────────────────
interface Props {
sensorId: number | null
onClose: () => void
}
// ── Label maps ────────────────────────────────────────────────────────────────
const DEVICE_TYPE_LABELS: Record<string, string> = {
ups: "UPS",
generator: "Generator",
crac: "CRAC Unit",
chiller: "Chiller",
ats: "Transfer Switch (ATS)",
rack: "Rack PDU",
network_switch: "Network Switch",
leak: "Leak Sensor",
fire_zone: "Fire / VESDA Zone",
custom: "Custom",
}
const PROTOCOL_LABELS: Record<string, string> = {
mqtt: "MQTT",
modbus_tcp: "Modbus TCP",
modbus_rtu: "Modbus RTU",
snmp: "SNMP",
bacnet: "BACnet",
http: "HTTP Poll",
}
// ── Helpers ───────────────────────────────────────────────────────────────────
function formatTime(iso: string): string {
try {
return new Date(iso).toLocaleTimeString("en-GB", {
hour: "2-digit",
minute: "2-digit",
second: "2-digit",
})
} catch {
return iso
}
}
function formatDate(iso: string): string {
try {
return new Date(iso).toLocaleString("en-GB", {
dateStyle: "medium",
timeStyle: "short",
})
} catch {
return iso
}
}
// ── Sub-components ────────────────────────────────────────────────────────────
function SectionHeading({ children }: { children: React.ReactNode }) {
return (
<h3 className="text-xs font-semibold uppercase tracking-wider text-muted-foreground mb-2">
{children}
</h3>
)
}
function InfoRow({ label, value }: { label: string; value: React.ReactNode }) {
return (
<div className="flex justify-between gap-4 py-1.5 border-b border-border/50 last:border-0">
<span className="text-xs text-muted-foreground shrink-0">{label}</span>
<span className="text-xs text-foreground text-right break-all">{value ?? "—"}</span>
</div>
)
}
// ── Loading skeleton ──────────────────────────────────────────────────────────
function LoadingSkeleton() {
return (
<div className="flex flex-col gap-4 px-6 pb-6">
<Skeleton className="h-6 w-48" />
<Skeleton className="h-4 w-24" />
<div className="flex flex-col gap-2 mt-2">
{Array.from({ length: 5 }).map((_, i) => (
<Skeleton key={i} className="h-4 w-full" />
))}
</div>
<div className="flex flex-col gap-2 mt-4">
{Array.from({ length: 3 }).map((_, i) => (
<Skeleton key={i} className="h-4 w-full" />
))}
</div>
</div>
)
}
// ── Badge ─────────────────────────────────────────────────────────────────────
function Badge({
children,
variant = "default",
}: {
children: React.ReactNode
variant?: "default" | "success" | "muted"
}) {
return (
<span
className={cn(
"inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium",
variant === "success" && "bg-emerald-500/15 text-emerald-600 dark:text-emerald-400",
variant === "muted" && "bg-muted text-muted-foreground",
variant === "default" && "bg-primary/10 text-primary",
)}
>
{children}
</span>
)
}
// ── Main component ────────────────────────────────────────────────────────────
export function SensorDetailSheet({ sensorId, onClose }: Props) {
const open = sensorId !== null
const [sensor, setSensor] = useState<SensorDevice | null>(null)
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
// ── Fetch when sensorId changes to a non-null value ───────────────────────
useEffect(() => {
if (sensorId === null) {
setSensor(null)
setError(null)
return
}
let cancelled = false
setLoading(true)
setError(null)
fetchSensor(sensorId)
.then(s => { if (!cancelled) setSensor(s) })
.catch(err => { if (!cancelled) setError(err instanceof Error ? err.message : "Failed to load sensor") })
.finally(() => { if (!cancelled) setLoading(false) })
return () => { cancelled = true }
}, [sensorId])
// ── Protocol config rows ──────────────────────────────────────────────────
function renderProtocolConfig(config: Record<string, unknown>) {
const entries = Object.entries(config)
if (entries.length === 0) {
return <p className="text-xs text-muted-foreground">No config stored.</p>
}
return (
<div className="rounded-md border border-border overflow-hidden">
{entries.map(([k, v]) => (
<div key={k} className="flex justify-between gap-4 px-3 py-1.5 border-b border-border/50 last:border-0 bg-muted/20">
<span className="text-xs text-muted-foreground font-mono shrink-0">{k}</span>
<span className="text-xs text-foreground font-mono text-right break-all">{String(v)}</span>
</div>
))}
</div>
)
}
// ── Recent readings table ─────────────────────────────────────────────────
function renderRecentReadings(readings: NonNullable<SensorDevice["recent_readings"]>) {
if (readings.length === 0) {
return (
<div className="flex items-center gap-2 rounded-md border border-border bg-muted/30 px-3 py-3 text-xs text-muted-foreground">
<WifiOff className="size-3.5 shrink-0" />
<span>No readings in the last 10 minutes check connection</span>
</div>
)
}
return (
<div className="rounded-md border border-border overflow-hidden">
<table className="w-full text-xs">
<thead>
<tr className="border-b border-border bg-muted/40">
<th className="px-3 py-2 text-left font-medium text-muted-foreground">Sensor Type</th>
<th className="px-3 py-2 text-right font-medium text-muted-foreground">Value</th>
<th className="px-3 py-2 text-right font-medium text-muted-foreground">Recorded At</th>
</tr>
</thead>
<tbody>
{readings.map((r, i) => (
<tr
key={i}
className="border-b border-border/50 last:border-0 hover:bg-muted/20 transition-colors"
>
<td className="px-3 py-1.5 font-mono text-foreground">{r.sensor_type}</td>
<td className="px-3 py-1.5 text-right tabular-nums text-foreground">
{r.value}
{r.unit && (
<span className="ml-1 text-muted-foreground">{r.unit}</span>
)}
</td>
<td className="px-3 py-1.5 text-right tabular-nums text-muted-foreground">
{formatTime(r.recorded_at)}
</td>
</tr>
))}
</tbody>
</table>
</div>
)
}
// ── Render ────────────────────────────────────────────────────────────────
return (
<Sheet open={open} onOpenChange={v => { if (!v) onClose() }}>
<SheetContent side="right" className="w-full sm:max-w-md flex flex-col overflow-y-auto">
<SheetHeader className="px-6 pt-6 pb-2">
<SheetTitle className="flex items-center gap-2">
<Cpu className="size-4 text-muted-foreground" />
{loading
? <Skeleton className="h-5 w-40" />
: (sensor?.name ?? "Sensor Detail")
}
</SheetTitle>
{/* Header badges */}
{sensor && !loading && (
<div className="flex flex-wrap gap-2 mt-1">
<Badge variant="default">
{DEVICE_TYPE_LABELS[sensor.device_type] ?? sensor.device_type}
</Badge>
<Badge variant={sensor.enabled ? "success" : "muted"}>
{sensor.enabled ? "Enabled" : "Disabled"}
</Badge>
<Badge variant="muted">
<Radio className="size-2.5 mr-1" />
{PROTOCOL_LABELS[sensor.protocol] ?? sensor.protocol}
</Badge>
</div>
)}
</SheetHeader>
<div className="flex flex-col flex-1 px-6 pb-6 gap-6 overflow-y-auto">
{loading && <LoadingSkeleton />}
{!loading && error && (
<p className="text-xs text-destructive">{error}</p>
)}
{!loading && sensor && (
<>
{/* ── Device Info ── */}
<section>
<SectionHeading>Device Info</SectionHeading>
<div className="rounded-md border border-border px-3">
<InfoRow label="Device ID" value={<span className="font-mono">{sensor.device_id}</span>} />
<InfoRow label="Type" value={DEVICE_TYPE_LABELS[sensor.device_type] ?? sensor.device_type} />
<InfoRow label="Room" value={sensor.room_id ?? "—"} />
<InfoRow label="Protocol" value={PROTOCOL_LABELS[sensor.protocol] ?? sensor.protocol} />
<InfoRow label="Created" value={formatDate(sensor.created_at)} />
</div>
</section>
{/* ── Protocol Config ── */}
<section>
<SectionHeading>Protocol Config</SectionHeading>
{renderProtocolConfig(sensor.protocol_config ?? {})}
</section>
{/* ── Recent Readings ── */}
<section>
<SectionHeading>Recent Readings (last 10 mins)</SectionHeading>
{renderRecentReadings(sensor.recent_readings ?? [])}
</section>
</>
)}
</div>
</SheetContent>
</Sheet>
)
}

View file

@ -0,0 +1,574 @@
"use client"
import React, { useEffect, useState } from "react"
import { Loader2, Info } from "lucide-react"
import { Button } from "@/components/ui/button"
import {
Sheet,
SheetContent,
SheetHeader,
SheetTitle,
} from "@/components/ui/sheet"
import { cn } from "@/lib/utils"
import {
type SensorDevice,
type SensorCreate,
type DeviceType,
type Protocol,
createSensor,
updateSensor,
} from "@/lib/api"
// ── Option maps ───────────────────────────────────────────────────────────────
const DEVICE_TYPE_OPTIONS: { value: DeviceType; label: string }[] = [
{ value: "ups", label: "UPS" },
{ value: "generator", label: "Generator" },
{ value: "crac", label: "CRAC Unit" },
{ value: "chiller", label: "Chiller" },
{ value: "ats", label: "Transfer Switch (ATS)" },
{ value: "rack", label: "Rack PDU" },
{ value: "network_switch", label: "Network Switch" },
{ value: "leak", label: "Leak Sensor" },
{ value: "fire_zone", label: "Fire / VESDA Zone" },
{ value: "custom", label: "Custom" },
]
const PROTOCOL_OPTIONS: { value: Protocol; label: string }[] = [
{ value: "mqtt", label: "MQTT" },
{ value: "modbus_tcp", label: "Modbus TCP" },
{ value: "modbus_rtu", label: "Modbus RTU" },
{ value: "snmp", label: "SNMP" },
{ value: "bacnet", label: "BACnet" },
{ value: "http", label: "HTTP Poll" },
]
// ── Props ─────────────────────────────────────────────────────────────────────
interface Props {
siteId: string
sensor: SensorDevice | null // null = add mode
open: boolean
onClose: () => void
onSaved: (s: SensorDevice) => void
}
// ── Shared input / select class ───────────────────────────────────────────────
const inputCls =
"border border-border rounded-md px-3 py-1.5 text-sm bg-background text-foreground w-full focus:outline-none focus:ring-1 focus:ring-primary"
// ── Protocol-specific config state types ──────────────────────────────────────
type MqttConfig = { topic: string }
type ModbusTcpConfig = { host: string; port: number; unit_id: number }
type ModbusRtuConfig = { serial_port: string; baud_rate: number; unit_id: number }
type SnmpConfig = { host: string; community: string; version: "v1" | "v2c" | "v3" }
type BacnetConfig = { host: string; device_id: number }
type HttpConfig = { url: string; poll_interval_s: number; json_path: string }
type ProtocolConfig =
| MqttConfig
| ModbusTcpConfig
| ModbusRtuConfig
| SnmpConfig
| BacnetConfig
| HttpConfig
function defaultProtocolConfig(protocol: Protocol): ProtocolConfig {
switch (protocol) {
case "mqtt": return { topic: "" }
case "modbus_tcp": return { host: "", port: 502, unit_id: 1 }
case "modbus_rtu": return { serial_port: "", baud_rate: 9600, unit_id: 1 }
case "snmp": return { host: "", community: "public", version: "v2c" }
case "bacnet": return { host: "", device_id: 0 }
case "http": return { url: "", poll_interval_s: 30, json_path: "$.value" }
}
}
function configFromSensor(sensor: SensorDevice): ProtocolConfig {
const raw = sensor.protocol_config ?? {}
switch (sensor.protocol) {
case "mqtt":
return { topic: (raw.topic as string) ?? "" }
case "modbus_tcp":
return {
host: (raw.host as string) ?? "",
port: (raw.port as number) ?? 502,
unit_id: (raw.unit_id as number) ?? 1,
}
case "modbus_rtu":
return {
serial_port: (raw.serial_port as string) ?? "",
baud_rate: (raw.baud_rate as number) ?? 9600,
unit_id: (raw.unit_id as number) ?? 1,
}
case "snmp":
return {
host: (raw.host as string) ?? "",
community: (raw.community as string) ?? "public",
version: (raw.version as "v1" | "v2c" | "v3") ?? "v2c",
}
case "bacnet":
return {
host: (raw.host as string) ?? "",
device_id: (raw.device_id as number) ?? 0,
}
case "http":
return {
url: (raw.url as string) ?? "",
poll_interval_s: (raw.poll_interval_s as number) ?? 30,
json_path: (raw.json_path as string) ?? "$.value",
}
}
}
// ── Protocol-specific field editors ──────────────────────────────────────────
interface ConfigEditorProps<T> {
config: T
onChange: (next: T) => void
}
function MqttEditor({ config, onChange }: ConfigEditorProps<MqttConfig>) {
return (
<Field label="MQTT Topic">
<input
className={inputCls}
value={config.topic}
placeholder="sensors/site/device/metric"
onChange={e => onChange({ ...config, topic: e.target.value })}
/>
</Field>
)
}
function ModbusTcpEditor({ config, onChange }: ConfigEditorProps<ModbusTcpConfig>) {
return (
<>
<Field label="Host">
<input
className={inputCls}
value={config.host}
placeholder="192.168.1.10"
onChange={e => onChange({ ...config, host: e.target.value })}
/>
</Field>
<Field label="Port">
<input
type="number"
className={inputCls}
value={config.port}
onChange={e => onChange({ ...config, port: Number(e.target.value) })}
/>
</Field>
<Field label="Unit ID">
<input
type="number"
className={inputCls}
value={config.unit_id}
onChange={e => onChange({ ...config, unit_id: Number(e.target.value) })}
/>
</Field>
</>
)
}
function ModbusRtuEditor({ config, onChange }: ConfigEditorProps<ModbusRtuConfig>) {
return (
<>
<Field label="Serial Port">
<input
className={inputCls}
value={config.serial_port}
placeholder="/dev/ttyUSB0"
onChange={e => onChange({ ...config, serial_port: e.target.value })}
/>
</Field>
<Field label="Baud Rate">
<input
type="number"
className={inputCls}
value={config.baud_rate}
onChange={e => onChange({ ...config, baud_rate: Number(e.target.value) })}
/>
</Field>
<Field label="Unit ID">
<input
type="number"
className={inputCls}
value={config.unit_id}
onChange={e => onChange({ ...config, unit_id: Number(e.target.value) })}
/>
</Field>
</>
)
}
function SnmpEditor({ config, onChange }: ConfigEditorProps<SnmpConfig>) {
return (
<>
<Field label="Host">
<input
className={inputCls}
value={config.host}
placeholder="192.168.1.20"
onChange={e => onChange({ ...config, host: e.target.value })}
/>
</Field>
<Field label="Community">
<input
className={inputCls}
value={config.community}
placeholder="public"
onChange={e => onChange({ ...config, community: e.target.value })}
/>
</Field>
<Field label="Version">
<select
className={inputCls}
value={config.version}
onChange={e => onChange({ ...config, version: e.target.value as "v1" | "v2c" | "v3" })}
>
<option value="v1">v1</option>
<option value="v2c">v2c</option>
<option value="v3">v3</option>
</select>
</Field>
</>
)
}
function BacnetEditor({ config, onChange }: ConfigEditorProps<BacnetConfig>) {
return (
<>
<Field label="Host">
<input
className={inputCls}
value={config.host}
placeholder="192.168.1.30"
onChange={e => onChange({ ...config, host: e.target.value })}
/>
</Field>
<Field label="Device ID">
<input
type="number"
className={inputCls}
value={config.device_id}
onChange={e => onChange({ ...config, device_id: Number(e.target.value) })}
/>
</Field>
</>
)
}
function HttpEditor({ config, onChange }: ConfigEditorProps<HttpConfig>) {
return (
<>
<Field label="URL">
<input
className={inputCls}
value={config.url}
placeholder="http://device/api/status"
onChange={e => onChange({ ...config, url: e.target.value })}
/>
</Field>
<Field label="Poll Interval (seconds)">
<input
type="number"
className={inputCls}
value={config.poll_interval_s}
onChange={e => onChange({ ...config, poll_interval_s: Number(e.target.value) })}
/>
</Field>
<Field label="JSON Path">
<input
className={inputCls}
value={config.json_path}
placeholder="$.value"
onChange={e => onChange({ ...config, json_path: e.target.value })}
/>
</Field>
</>
)
}
// ── Reusable field wrapper ────────────────────────────────────────────────────
function Field({ label, children }: { label: string; children: React.ReactNode }) {
return (
<div className="flex flex-col gap-1">
<span className="text-xs text-muted-foreground">{label}</span>
{children}
</div>
)
}
// ── Toggle switch ─────────────────────────────────────────────────────────────
function Toggle({
checked,
onChange,
}: {
checked: boolean
onChange: (v: boolean) => void
}) {
return (
<button
type="button"
role="switch"
aria-checked={checked}
onClick={() => onChange(!checked)}
className={cn(
"relative inline-flex h-5 w-9 shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors focus:outline-none focus:ring-2 focus:ring-primary focus:ring-offset-2",
checked ? "bg-primary" : "bg-muted"
)}
>
<span
className={cn(
"pointer-events-none inline-block h-4 w-4 rounded-full bg-white shadow-sm transition-transform",
checked ? "translate-x-4" : "translate-x-0"
)}
/>
</button>
)
}
// ── Main component ────────────────────────────────────────────────────────────
export function SensorSheet({ siteId, sensor, open, onClose, onSaved }: Props) {
const isEdit = sensor !== null
// ── Form state ────────────────────────────────────────────────────────────
const [deviceId, setDeviceId] = useState("")
const [name, setName] = useState("")
const [deviceType, setDeviceType] = useState<DeviceType>("ups")
const [room, setRoom] = useState("")
const [enabled, setEnabled] = useState(true)
const [protocol, setProtocol] = useState<Protocol>("mqtt")
const [protoConfig, setProtoConfig] = useState<ProtocolConfig>(defaultProtocolConfig("mqtt"))
const [saving, setSaving] = useState(false)
const [error, setError] = useState<string | null>(null)
// ── Populate form when sheet opens / sensor changes ───────────────────────
useEffect(() => {
if (!open) return
setError(null)
setSaving(false)
if (sensor) {
setDeviceId(sensor.device_id)
setName(sensor.name)
setDeviceType(sensor.device_type)
setRoom(sensor.room_id ?? "")
setEnabled(sensor.enabled)
setProtocol(sensor.protocol)
setProtoConfig(configFromSensor(sensor))
} else {
setDeviceId("")
setName("")
setDeviceType("ups")
setRoom("")
setEnabled(true)
setProtocol("mqtt")
setProtoConfig(defaultProtocolConfig("mqtt"))
}
}, [open, sensor])
// ── Protocol change — reset config to defaults ────────────────────────────
function handleProtocolChange(p: Protocol) {
setProtocol(p)
setProtoConfig(defaultProtocolConfig(p))
}
// ── Submit ────────────────────────────────────────────────────────────────
async function handleSubmit(e: React.FormEvent) {
e.preventDefault()
setError(null)
setSaving(true)
try {
const body: SensorCreate = {
device_id: deviceId.trim(),
name: name.trim(),
device_type: deviceType,
room_id: room.trim() || null,
protocol,
protocol_config: protoConfig as Record<string, unknown>,
enabled,
}
const saved = isEdit
? await updateSensor(sensor!.id, body)
: await createSensor(siteId, body)
onSaved(saved)
} catch (err) {
setError(err instanceof Error ? err.message : "Save failed")
} finally {
setSaving(false)
}
}
// ── Protocol config editor ────────────────────────────────────────────────
function renderProtoFields() {
switch (protocol) {
case "mqtt":
return (
<MqttEditor
config={protoConfig as MqttConfig}
onChange={c => setProtoConfig(c)}
/>
)
case "modbus_tcp":
return (
<ModbusTcpEditor
config={protoConfig as ModbusTcpConfig}
onChange={c => setProtoConfig(c)}
/>
)
case "modbus_rtu":
return (
<ModbusRtuEditor
config={protoConfig as ModbusRtuConfig}
onChange={c => setProtoConfig(c)}
/>
)
case "snmp":
return (
<SnmpEditor
config={protoConfig as SnmpConfig}
onChange={c => setProtoConfig(c)}
/>
)
case "bacnet":
return (
<BacnetEditor
config={protoConfig as BacnetConfig}
onChange={c => setProtoConfig(c)}
/>
)
case "http":
return (
<HttpEditor
config={protoConfig as HttpConfig}
onChange={c => setProtoConfig(c)}
/>
)
}
}
// ── Render ────────────────────────────────────────────────────────────────
return (
<Sheet open={open} onOpenChange={v => { if (!v) onClose() }}>
<SheetContent side="right" className="w-full sm:max-w-md flex flex-col overflow-y-auto">
<SheetHeader className="px-6 pt-6 pb-2">
<SheetTitle>{isEdit ? "Edit Sensor" : "Add Sensor"}</SheetTitle>
</SheetHeader>
<form onSubmit={handleSubmit} className="flex flex-col flex-1 px-6 pb-6 gap-5">
{/* ── Device ID ── */}
<Field label="Device ID *">
<input
className={cn(inputCls, isEdit && "opacity-60 cursor-not-allowed")}
value={deviceId}
required
disabled={isEdit}
placeholder="ups-01"
onChange={e => setDeviceId(e.target.value)}
/>
</Field>
{/* ── Name ── */}
<Field label="Name *">
<input
className={inputCls}
value={name}
required
placeholder="Main UPS — Hall A"
onChange={e => setName(e.target.value)}
/>
</Field>
{/* ── Device Type ── */}
<Field label="Device Type">
<select
className={inputCls}
value={deviceType}
onChange={e => setDeviceType(e.target.value as DeviceType)}
>
{DEVICE_TYPE_OPTIONS.map(o => (
<option key={o.value} value={o.value}>{o.label}</option>
))}
</select>
</Field>
{/* ── Room ── */}
<Field label="Room (optional)">
<input
className={inputCls}
value={room}
placeholder="hall-a"
onChange={e => setRoom(e.target.value)}
/>
</Field>
{/* ── Enabled toggle ── */}
<div className="flex items-center justify-between">
<span className="text-xs text-muted-foreground">Enabled</span>
<Toggle checked={enabled} onChange={setEnabled} />
</div>
{/* ── Divider ── */}
<hr className="border-border" />
{/* ── Protocol ── */}
<Field label="Protocol">
<select
className={inputCls}
value={protocol}
onChange={e => handleProtocolChange(e.target.value as Protocol)}
>
{PROTOCOL_OPTIONS.map(o => (
<option key={o.value} value={o.value}>{o.label}</option>
))}
</select>
</Field>
{/* ── Protocol-specific fields ── */}
{renderProtoFields()}
{/* ── Non-MQTT collector notice ── */}
{protocol !== "mqtt" && (
<div className="flex gap-2 rounded-md border border-border bg-muted/40 p-3 text-xs text-muted-foreground">
<Info className="mt-0.5 size-3.5 shrink-0 text-primary" />
<span>
Protocol config stored active polling not yet implemented.
Data will appear once the collector is enabled.
</span>
</div>
)}
{/* ── Error ── */}
{error && (
<p className="text-xs text-destructive">{error}</p>
)}
{/* ── Actions ── */}
<div className="mt-auto flex gap-2 pt-2">
<Button
type="button"
variant="outline"
className="flex-1"
onClick={onClose}
disabled={saving}
>
Cancel
</Button>
<Button type="submit" className="flex-1" disabled={saving}>
{saving && <Loader2 className="animate-spin" />}
{saving ? "Saving…" : isEdit ? "Save Changes" : "Add Sensor"}
</Button>
</div>
</form>
</SheetContent>
</Sheet>
)
}

View file

@ -0,0 +1,243 @@
"use client";
import { useEffect, useState, useCallback } from "react";
import { Plus, Pencil, Trash2, Search, Eye, ToggleLeft, ToggleRight, RefreshCw } from "lucide-react";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
import { fetchSensors, updateSensor, deleteSensor, type SensorDevice, type DeviceType, type Protocol } from "@/lib/api";
const SITE_ID = "sg-01";
export const DEVICE_TYPE_LABELS: Record<string, string> = {
ups: "UPS",
generator: "Generator",
crac: "CRAC Unit",
chiller: "Chiller",
ats: "Transfer Switch",
rack: "Rack PDU",
network_switch: "Network Switch",
leak: "Leak Sensor",
fire_zone: "Fire / VESDA",
custom: "Custom",
};
export const PROTOCOL_LABELS: Record<string, string> = {
mqtt: "MQTT",
modbus_tcp: "Modbus TCP",
modbus_rtu: "Modbus RTU",
snmp: "SNMP",
bacnet: "BACnet",
http: "HTTP Poll",
};
const TYPE_COLORS: Record<string, string> = {
ups: "bg-blue-500/10 text-blue-400",
generator: "bg-amber-500/10 text-amber-400",
crac: "bg-cyan-500/10 text-cyan-400",
chiller: "bg-sky-500/10 text-sky-400",
ats: "bg-purple-500/10 text-purple-400",
rack: "bg-green-500/10 text-green-400",
network_switch: "bg-indigo-500/10 text-indigo-400",
leak: "bg-teal-500/10 text-teal-400",
fire_zone: "bg-red-500/10 text-red-400",
custom: "bg-muted text-muted-foreground",
};
const PROTOCOL_COLORS: Record<string, string> = {
mqtt: "bg-green-500/10 text-green-400",
modbus_tcp: "bg-orange-500/10 text-orange-400",
modbus_rtu: "bg-orange-500/10 text-orange-400",
snmp: "bg-violet-500/10 text-violet-400",
bacnet: "bg-pink-500/10 text-pink-400",
http: "bg-yellow-500/10 text-yellow-400",
};
interface Props {
onAdd: () => void;
onEdit: (s: SensorDevice) => void;
onDetail: (id: number) => void;
}
export function SensorTable({ onAdd, onEdit, onDetail }: Props) {
const [sensors, setSensors] = useState<SensorDevice[]>([]);
const [loading, setLoading] = useState(true);
const [search, setSearch] = useState("");
const [typeFilter, setTypeFilter] = useState<string>("all");
const [confirmDel, setConfirmDel] = useState<number | null>(null);
const [toggling, setToggling] = useState<number | null>(null);
const load = useCallback(async () => {
try {
const data = await fetchSensors(SITE_ID);
setSensors(data);
} catch { /* keep stale */ }
finally { setLoading(false); }
}, []);
useEffect(() => { load(); }, [load]);
const handleToggle = async (s: SensorDevice) => {
setToggling(s.id);
try {
const updated = await updateSensor(s.id, { enabled: !s.enabled });
setSensors(prev => prev.map(x => x.id === s.id ? updated : x));
} catch { /* ignore */ }
finally { setToggling(null); }
};
const handleDelete = async (id: number) => {
try {
await deleteSensor(id);
setSensors(prev => prev.filter(x => x.id !== id));
} catch { /* ignore */ }
finally { setConfirmDel(null); }
};
const filtered = sensors.filter(s => {
const matchType = typeFilter === "all" || s.device_type === typeFilter;
const q = search.toLowerCase();
const matchSearch = !q || s.device_id.toLowerCase().includes(q) || s.name.toLowerCase().includes(q) || (s.room_id ?? "").toLowerCase().includes(q);
return matchType && matchSearch;
});
const typeOptions = [...new Set(sensors.map(s => s.device_type))].sort();
return (
<div className="space-y-4">
{/* Toolbar */}
<div className="flex items-center gap-3 flex-wrap">
<div className="relative flex-1 min-w-48">
<Search className="absolute left-2.5 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-muted-foreground" />
<input
value={search}
onChange={e => setSearch(e.target.value)}
placeholder="Search by name or ID..."
className="w-full pl-8 pr-3 py-1.5 text-sm bg-muted/30 border border-border rounded-md focus:outline-none focus:ring-1 focus:ring-primary"
/>
</div>
<select
value={typeFilter}
onChange={e => setTypeFilter(e.target.value)}
className="text-sm bg-muted/30 border border-border rounded-md px-2 py-1.5 text-foreground focus:outline-none"
>
<option value="all">All types</option>
{typeOptions.map(t => (
<option key={t} value={t}>{DEVICE_TYPE_LABELS[t] ?? t}</option>
))}
</select>
<Button size="sm" variant="ghost" onClick={load} className="gap-1.5">
<RefreshCw className="w-3.5 h-3.5" /> Refresh
</Button>
<Button size="sm" onClick={onAdd} className="gap-1.5">
<Plus className="w-3.5 h-3.5" /> Add Sensor
</Button>
</div>
<div className="text-xs text-muted-foreground">
{filtered.length} of {sensors.length} devices
</div>
{/* Table */}
{loading ? (
<div className="space-y-2">
{Array.from({ length: 6 }).map((_, i) => (
<div key={i} className="h-10 rounded bg-muted/30 animate-pulse" />
))}
</div>
) : filtered.length === 0 ? (
<div className="text-center py-12 text-sm text-muted-foreground">
No sensors found{search ? ` matching "${search}"` : ""}
</div>
) : (
<div className="overflow-x-auto rounded-lg border border-border/40">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-border/30 bg-muted/10">
<th className="text-left px-3 py-2 text-xs font-medium text-muted-foreground">Device ID</th>
<th className="text-left px-3 py-2 text-xs font-medium text-muted-foreground">Name</th>
<th className="text-left px-3 py-2 text-xs font-medium text-muted-foreground">Type</th>
<th className="text-left px-3 py-2 text-xs font-medium text-muted-foreground">Room</th>
<th className="text-left px-3 py-2 text-xs font-medium text-muted-foreground">Protocol</th>
<th className="text-center px-3 py-2 text-xs font-medium text-muted-foreground">Enabled</th>
<th className="text-right px-3 py-2 text-xs font-medium text-muted-foreground">Actions</th>
</tr>
</thead>
<tbody>
{filtered.map(s => (
<tr key={s.id} className="border-b border-border/10 hover:bg-muted/10 transition-colors">
<td className="px-3 py-2 font-mono text-xs text-muted-foreground">{s.device_id}</td>
<td className="px-3 py-2 font-medium">{s.name}</td>
<td className="px-3 py-2">
<span className={cn("text-[10px] font-semibold px-2 py-0.5 rounded-full", TYPE_COLORS[s.device_type] ?? "bg-muted text-muted-foreground")}>
{DEVICE_TYPE_LABELS[s.device_type] ?? s.device_type}
</span>
</td>
<td className="px-3 py-2 text-xs text-muted-foreground">{s.room_id ?? "—"}</td>
<td className="px-3 py-2">
<span className={cn("text-[10px] font-semibold px-2 py-0.5 rounded-full", PROTOCOL_COLORS[s.protocol] ?? "bg-muted text-muted-foreground")}>
{PROTOCOL_LABELS[s.protocol] ?? s.protocol}
</span>
</td>
<td className="px-3 py-2 text-center">
<button
onClick={() => handleToggle(s)}
disabled={toggling === s.id}
className={cn("transition-opacity", toggling === s.id && "opacity-50")}
>
{s.enabled
? <ToggleRight className="w-5 h-5 text-green-400" />
: <ToggleLeft className="w-5 h-5 text-muted-foreground" />
}
</button>
</td>
<td className="px-3 py-2">
<div className="flex items-center justify-end gap-1">
<button
onClick={() => onDetail(s.id)}
className="p-1 rounded hover:bg-muted transition-colors text-muted-foreground hover:text-foreground"
title="View details"
>
<Eye className="w-3.5 h-3.5" />
</button>
<button
onClick={() => onEdit(s)}
className="p-1 rounded hover:bg-muted transition-colors text-muted-foreground hover:text-foreground"
title="Edit"
>
<Pencil className="w-3.5 h-3.5" />
</button>
{confirmDel === s.id ? (
<div className="flex items-center gap-1 ml-1">
<button
onClick={() => handleDelete(s.id)}
className="text-[10px] px-1.5 py-0.5 rounded bg-destructive/10 text-destructive hover:bg-destructive/20 font-medium"
>
Confirm
</button>
<button
onClick={() => setConfirmDel(null)}
className="text-[10px] px-1.5 py-0.5 rounded bg-muted text-muted-foreground hover:bg-muted/80"
>
Cancel
</button>
</div>
) : (
<button
onClick={() => setConfirmDel(s.id)}
className="p-1 rounded hover:bg-muted transition-colors text-muted-foreground hover:text-destructive"
title="Delete"
>
<Trash2 className="w-3.5 h-3.5" />
</button>
)}
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
);
}

View file

@ -0,0 +1,719 @@
"use client"
import React, { useCallback, useEffect, useRef, useState } from "react"
import {
AlertTriangle,
ChevronDown,
ChevronRight,
Plus,
RotateCcw,
Trash2,
} from "lucide-react"
import { Button } from "@/components/ui/button"
import { cn } from "@/lib/utils"
import {
AlarmThreshold,
ThresholdUpdate,
ThresholdCreate,
fetchThresholds,
updateThreshold,
createThreshold,
deleteThreshold,
resetThresholds,
} from "@/lib/api"
// ── Labels ────────────────────────────────────────────────────────────────────
const SENSOR_TYPE_LABELS: Record<string, string> = {
temperature: "Temperature (°C)",
humidity: "Humidity (%)",
power_kw: "Rack Power (kW)",
pdu_imbalance: "Phase Imbalance (%)",
ups_charge: "UPS Battery Charge (%)",
ups_load: "UPS Load (%)",
ups_runtime: "UPS Runtime (min)",
gen_fuel_pct: "Generator Fuel (%)",
gen_load_pct: "Generator Load (%)",
gen_coolant_c: "Generator Coolant (°C)",
gen_oil_press: "Generator Oil Pressure (bar)",
cooling_cap_pct: "CRAC Capacity (%)",
cooling_cop: "CRAC COP",
cooling_comp_load: "Compressor Load (%)",
cooling_high_press: "High-Side Pressure (bar)",
cooling_low_press: "Low-Side Pressure (bar)",
cooling_superheat: "Discharge Superheat (°C)",
cooling_filter_dp: "Filter Delta-P (Pa)",
cooling_return: "Return Air Temp (°C)",
net_pkt_loss_pct: "Packet Loss (%)",
net_temp_c: "Switch Temperature (°C)",
ats_ua_v: "Utility A Voltage (V)",
chiller_cop: "Chiller COP",
}
// ── Groups ────────────────────────────────────────────────────────────────────
type GroupIcon = "Thermometer" | "Zap" | "Battery" | "Fuel" | "Wind" | "Network"
interface Group {
label: string
icon: GroupIcon
types: string[]
}
const GROUPS: Group[] = [
{ label: "Temperature & Humidity", icon: "Thermometer", types: ["temperature", "humidity"] },
{ label: "Rack Power", icon: "Zap", types: ["power_kw", "pdu_imbalance"] },
{ label: "UPS", icon: "Battery", types: ["ups_charge", "ups_load", "ups_runtime"] },
{ label: "Generator", icon: "Fuel", types: ["gen_fuel_pct", "gen_load_pct", "gen_coolant_c", "gen_oil_press"] },
{ label: "Cooling / CRAC", icon: "Wind", types: ["cooling_cap_pct", "cooling_cop", "cooling_comp_load", "cooling_high_press", "cooling_low_press", "cooling_superheat", "cooling_filter_dp", "cooling_return"] },
{ label: "Network", icon: "Network", types: ["net_pkt_loss_pct", "net_temp_c", "ats_ua_v", "chiller_cop"] },
]
// ── Group icon renderer ───────────────────────────────────────────────────────
function GroupIconEl({ icon }: { icon: GroupIcon }) {
const cls = "size-4 shrink-0"
switch (icon) {
case "Thermometer":
return (
<svg className={cls} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2}>
<path d="M14 14.76V3.5a2.5 2.5 0 0 0-5 0v11.26a4.5 4.5 0 1 0 5 0z" />
</svg>
)
case "Zap":
return (
<svg className={cls} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2}>
<polygon points="13 2 3 14 12 14 11 22 21 10 12 10 13 2" />
</svg>
)
case "Battery":
return (
<svg className={cls} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2}>
<rect x="1" y="6" width="18" height="12" rx="2" ry="2" />
<line x1="23" y1="13" x2="23" y2="11" />
</svg>
)
case "Fuel":
return (
<svg className={cls} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2}>
<path d="M3 22V8l6-6h6l6 6v14H3z" />
<rect x="8" y="13" width="8" height="5" />
<path d="M8 5h8" />
</svg>
)
case "Wind":
return (
<svg className={cls} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2}>
<path d="M9.59 4.59A2 2 0 1 1 11 8H2m10.59 11.41A2 2 0 1 0 14 16H2m15.73-8.27A2.5 2.5 0 1 1 19.5 12H2" />
</svg>
)
case "Network":
return (
<svg className={cls} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={2}>
<rect x="9" y="2" width="6" height="4" rx="1" />
<rect x="1" y="18" width="6" height="4" rx="1" />
<rect x="17" y="18" width="6" height="4" rx="1" />
<path d="M12 6v4M4 18v-4h16v4M12 10h8v4M12 10H4v4" />
</svg>
)
}
}
// ── Status toast ──────────────────────────────────────────────────────────────
interface Toast {
id: number
message: string
type: "success" | "error"
}
// ── Save indicator per row ────────────────────────────────────────────────────
type SaveState = "idle" | "saving" | "saved" | "error"
// ── Row component ─────────────────────────────────────────────────────────────
interface RowProps {
threshold: AlarmThreshold
onUpdate: (id: number, patch: ThresholdUpdate) => Promise<void>
onDelete: (id: number) => void
}
function ThresholdRow({ threshold, onUpdate, onDelete }: RowProps) {
const [localValue, setLocalValue] = useState(String(threshold.threshold_value))
const [saveState, setSaveState] = useState<SaveState>("idle")
const saveTimer = useRef<ReturnType<typeof setTimeout> | null>(null)
// Keep local value in sync if parent updates (e.g. after reset)
useEffect(() => {
setLocalValue(String(threshold.threshold_value))
}, [threshold.threshold_value])
const signalSave = async (patch: ThresholdUpdate) => {
setSaveState("saving")
try {
await onUpdate(threshold.id, patch)
setSaveState("saved")
} catch {
setSaveState("error")
} finally {
if (saveTimer.current) clearTimeout(saveTimer.current)
saveTimer.current = setTimeout(() => setSaveState("idle"), 1800)
}
}
const handleValueBlur = () => {
const parsed = parseFloat(localValue)
if (isNaN(parsed)) {
setLocalValue(String(threshold.threshold_value))
return
}
if (parsed !== threshold.threshold_value) {
signalSave({ threshold_value: parsed })
}
}
const handleSeverityToggle = () => {
const next = threshold.severity === "warning" ? "critical" : "warning"
signalSave({ severity: next })
}
const handleEnabledToggle = () => {
signalSave({ enabled: !threshold.enabled })
}
const directionBadge =
threshold.direction === "above" ? (
<span className="inline-flex items-center gap-0.5 rounded px-1.5 py-0.5 text-xs font-semibold bg-orange-100 text-orange-700 dark:bg-orange-900/40 dark:text-orange-300">
Above
</span>
) : (
<span className="inline-flex items-center gap-0.5 rounded px-1.5 py-0.5 text-xs font-semibold bg-blue-100 text-blue-700 dark:bg-blue-900/40 dark:text-blue-300">
Below
</span>
)
const severityBadge =
threshold.severity === "critical" ? (
<button
onClick={handleSeverityToggle}
title="Click to toggle severity"
className="inline-flex items-center gap-0.5 rounded px-1.5 py-0.5 text-xs font-semibold bg-red-100 text-red-700 dark:bg-red-900/40 dark:text-red-300 hover:opacity-80 transition-opacity cursor-pointer"
>
Critical
</button>
) : (
<button
onClick={handleSeverityToggle}
title="Click to toggle severity"
className="inline-flex items-center gap-0.5 rounded px-1.5 py-0.5 text-xs font-semibold bg-amber-100 text-amber-700 dark:bg-amber-900/40 dark:text-amber-300 hover:opacity-80 transition-opacity cursor-pointer"
>
Warning
</button>
)
const saveIndicator =
saveState === "saving" ? (
<span className="text-xs text-muted-foreground animate-pulse">Saving</span>
) : saveState === "saved" ? (
<span className="text-xs text-emerald-600 dark:text-emerald-400">Saved</span>
) : saveState === "error" ? (
<span className="text-xs text-red-500">Error</span>
) : null
return (
<tr
className={cn(
"border-b border-border/50 last:border-0 transition-colors",
!threshold.enabled && "opacity-50"
)}
>
{/* Sensor type */}
<td className="py-2 pl-4 pr-3 text-sm font-medium text-foreground whitespace-nowrap">
{SENSOR_TYPE_LABELS[threshold.sensor_type] ?? threshold.sensor_type}
</td>
{/* Direction */}
<td className="py-2 px-3 text-sm">
{directionBadge}
</td>
{/* Severity */}
<td className="py-2 px-3 text-sm">
{severityBadge}
</td>
{/* Value */}
<td className="py-2 px-3">
<div className="flex items-center gap-2">
<input
type="number"
step="any"
value={localValue}
onChange={(e) => setLocalValue(e.target.value)}
onBlur={handleValueBlur}
disabled={threshold.locked}
className={cn(
"w-24 rounded-md border border-input bg-background px-2 py-1 text-sm text-right tabular-nums",
"focus:outline-none focus:ring-2 focus:ring-ring/50",
"disabled:cursor-not-allowed disabled:opacity-50"
)}
/>
<span className="w-12 text-xs">{saveIndicator}</span>
</div>
</td>
{/* Enabled toggle */}
<td className="py-2 px-3 text-center">
<button
onClick={handleEnabledToggle}
role="switch"
aria-checked={threshold.enabled}
title={threshold.enabled ? "Disable threshold" : "Enable threshold"}
className={cn(
"relative inline-flex h-5 w-9 shrink-0 cursor-pointer rounded-full border-2 border-transparent transition-colors",
"focus:outline-none focus:ring-2 focus:ring-ring/50",
threshold.enabled
? "bg-primary"
: "bg-muted"
)}
>
<span
className={cn(
"pointer-events-none inline-block h-4 w-4 transform rounded-full bg-white shadow ring-0 transition-transform",
threshold.enabled ? "translate-x-4" : "translate-x-0"
)}
/>
</button>
</td>
{/* Delete */}
<td className="py-2 pl-3 pr-4 text-right">
{!threshold.locked && (
<button
onClick={() => onDelete(threshold.id)}
title="Delete threshold"
className="text-muted-foreground hover:text-destructive transition-colors"
>
<Trash2 className="size-4" />
</button>
)}
</td>
</tr>
)
}
// ── Group section ─────────────────────────────────────────────────────────────
interface GroupSectionProps {
group: Group
thresholds: AlarmThreshold[]
onUpdate: (id: number, patch: ThresholdUpdate) => Promise<void>
onDelete: (id: number) => void
}
function GroupSection({ group, thresholds, onUpdate, onDelete }: GroupSectionProps) {
const [expanded, setExpanded] = useState(true)
const rows = thresholds.filter((t) => group.types.includes(t.sensor_type))
return (
<div className="rounded-lg border border-border bg-card overflow-hidden">
{/* Header */}
<button
onClick={() => setExpanded((v) => !v)}
className="w-full flex items-center justify-between px-4 py-3 bg-muted/40 hover:bg-muted/60 transition-colors text-left"
>
<div className="flex items-center gap-2 font-semibold text-sm text-foreground">
<GroupIconEl icon={group.icon} />
{group.label}
<span className="ml-1 text-xs font-normal text-muted-foreground">
({rows.length} rule{rows.length !== 1 ? "s" : ""})
</span>
</div>
{expanded ? (
<ChevronDown className="size-4 text-muted-foreground" />
) : (
<ChevronRight className="size-4 text-muted-foreground" />
)}
</button>
{/* Table */}
{expanded && (
<div className="overflow-x-auto">
{rows.length === 0 ? (
<p className="px-4 py-4 text-sm text-muted-foreground italic">
No rules configured for this group.
</p>
) : (
<table className="w-full text-sm">
<thead>
<tr className="border-b border-border/50 text-xs text-muted-foreground">
<th className="py-2 pl-4 pr-3 text-left font-medium">Sensor Type</th>
<th className="py-2 px-3 text-left font-medium">Direction</th>
<th className="py-2 px-3 text-left font-medium">Severity</th>
<th className="py-2 px-3 text-left font-medium">Value</th>
<th className="py-2 px-3 text-center font-medium">Enabled</th>
<th className="py-2 pl-3 pr-4 text-right font-medium">Delete</th>
</tr>
</thead>
<tbody>
{rows.map((t) => (
<ThresholdRow
key={t.id}
threshold={t}
onUpdate={onUpdate}
onDelete={onDelete}
/>
))}
</tbody>
</table>
)}
</div>
)}
</div>
)
}
// ── Add custom rule form ──────────────────────────────────────────────────────
interface AddRuleFormProps {
siteId: string
onCreated: (t: AlarmThreshold) => void
onError: (msg: string) => void
}
const EMPTY_FORM: ThresholdCreate = {
sensor_type: "",
threshold_value: 0,
direction: "above",
severity: "warning",
message_template: "",
}
function AddRuleForm({ siteId, onCreated, onError }: AddRuleFormProps) {
const [form, setForm] = useState<ThresholdCreate>(EMPTY_FORM)
const [busy, setBusy] = useState(false)
const set = <K extends keyof ThresholdCreate>(k: K, v: ThresholdCreate[K]) =>
setForm((f) => ({ ...f, [k]: v }))
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault()
if (!form.sensor_type.trim()) return
setBusy(true)
try {
const created = await createThreshold(siteId, form)
onCreated(created)
setForm(EMPTY_FORM)
} catch {
onError("Failed to create threshold rule.")
} finally {
setBusy(false)
}
}
return (
<div className="rounded-lg border border-dashed border-border bg-card p-4">
<h3 className="flex items-center gap-2 text-sm font-semibold text-foreground mb-3">
<Plus className="size-4" />
Add Custom Rule
</h3>
<form onSubmit={handleSubmit}>
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3">
{/* Sensor type */}
<div className="flex flex-col gap-1">
<label className="text-xs font-medium text-muted-foreground">Sensor Type</label>
<input
type="text"
placeholder="e.g. temperature"
value={form.sensor_type}
onChange={(e) => set("sensor_type", e.target.value)}
className="rounded-md border border-input bg-background px-2 py-1.5 text-sm focus:outline-none focus:ring-2 focus:ring-ring/50"
required
/>
</div>
{/* Direction */}
<div className="flex flex-col gap-1">
<label className="text-xs font-medium text-muted-foreground">Direction</label>
<select
value={form.direction}
onChange={(e) => set("direction", e.target.value)}
className="rounded-md border border-input bg-background px-2 py-1.5 text-sm focus:outline-none focus:ring-2 focus:ring-ring/50"
>
<option value="above">Above</option>
<option value="below">Below</option>
</select>
</div>
{/* Threshold value */}
<div className="flex flex-col gap-1">
<label className="text-xs font-medium text-muted-foreground">Threshold Value</label>
<input
type="number"
step="any"
value={form.threshold_value}
onChange={(e) => set("threshold_value", parseFloat(e.target.value) || 0)}
className="rounded-md border border-input bg-background px-2 py-1.5 text-sm focus:outline-none focus:ring-2 focus:ring-ring/50"
required
/>
</div>
{/* Severity */}
<div className="flex flex-col gap-1">
<label className="text-xs font-medium text-muted-foreground">Severity</label>
<select
value={form.severity}
onChange={(e) => set("severity", e.target.value)}
className="rounded-md border border-input bg-background px-2 py-1.5 text-sm focus:outline-none focus:ring-2 focus:ring-ring/50"
>
<option value="warning">Warning</option>
<option value="critical">Critical</option>
</select>
</div>
{/* Message template */}
<div className="flex flex-col gap-1 sm:col-span-2 lg:col-span-2">
<label className="text-xs font-medium text-muted-foreground">
Message Template{" "}
<span className="font-normal opacity-70">
use <code className="text-xs">{"{sensor_id}"}</code> and <code className="text-xs">{"{value:.1f}"}</code>
</span>
</label>
<input
type="text"
placeholder="{sensor_id} value {value:.1f} exceeded threshold"
value={form.message_template}
onChange={(e) => set("message_template", e.target.value)}
className="rounded-md border border-input bg-background px-2 py-1.5 text-sm focus:outline-none focus:ring-2 focus:ring-ring/50"
/>
</div>
</div>
<div className="mt-3 flex justify-end">
<Button type="submit" size="sm" disabled={busy || !form.sensor_type.trim()}>
<Plus className="size-4" />
{busy ? "Adding…" : "Add Rule"}
</Button>
</div>
</form>
</div>
)
}
// ── Main component ────────────────────────────────────────────────────────────
interface Props {
siteId: string
}
export function ThresholdEditor({ siteId }: Props) {
const [thresholds, setThresholds] = useState<AlarmThreshold[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [confirmReset, setConfirmReset] = useState(false)
const [resetting, setResetting] = useState(false)
const [toasts, setToasts] = useState<Toast[]>([])
const toastId = useRef(0)
// ── Toast helpers ───────────────────────────────────────────────────────────
const pushToast = useCallback((message: string, type: Toast["type"]) => {
const id = ++toastId.current
setToasts((prev) => [...prev, { id, message, type }])
setTimeout(() => setToasts((prev) => prev.filter((t) => t.id !== id)), 3000)
}, [])
// ── Data loading ────────────────────────────────────────────────────────────
const load = useCallback(async () => {
setLoading(true)
setError(null)
try {
const data = await fetchThresholds(siteId)
setThresholds(data)
} catch {
setError("Failed to load alarm thresholds.")
} finally {
setLoading(false)
}
}, [siteId])
useEffect(() => { load() }, [load])
// ── Update handler ──────────────────────────────────────────────────────────
const handleUpdate = useCallback(async (id: number, patch: ThresholdUpdate) => {
// Optimistic update
setThresholds((prev) =>
prev.map((t) =>
t.id === id
? {
...t,
...(patch.threshold_value !== undefined && { threshold_value: patch.threshold_value }),
...(patch.severity !== undefined && { severity: patch.severity as AlarmThreshold["severity"] }),
...(patch.enabled !== undefined && { enabled: patch.enabled }),
}
: t
)
)
await updateThreshold(id, patch)
}, [])
// ── Delete handler ──────────────────────────────────────────────────────────
const handleDelete = useCallback(async (id: number) => {
setThresholds((prev) => prev.filter((t) => t.id !== id))
try {
await deleteThreshold(id)
pushToast("Threshold deleted.", "success")
} catch {
pushToast("Failed to delete threshold.", "error")
// Reload to restore
load()
}
}, [load, pushToast])
// ── Create handler ──────────────────────────────────────────────────────────
const handleCreated = useCallback((t: AlarmThreshold) => {
setThresholds((prev) => [...prev, t])
pushToast("Rule added successfully.", "success")
}, [pushToast])
// ── Reset handler ───────────────────────────────────────────────────────────
const handleReset = async () => {
setResetting(true)
try {
await resetThresholds(siteId)
setConfirmReset(false)
pushToast("Thresholds reset to defaults.", "success")
await load()
} catch {
pushToast("Failed to reset thresholds.", "error")
} finally {
setResetting(false)
}
}
// ── Render ──────────────────────────────────────────────────────────────────
return (
<div className="relative space-y-4">
{/* Toast container */}
{toasts.length > 0 && (
<div className="fixed bottom-6 right-6 z-50 flex flex-col gap-2 pointer-events-none">
{toasts.map((toast) => (
<div
key={toast.id}
className={cn(
"rounded-lg px-4 py-2.5 text-sm font-medium shadow-lg animate-in fade-in slide-in-from-bottom-2",
toast.type === "success"
? "bg-emerald-600 text-white"
: "bg-destructive text-white"
)}
>
{toast.message}
</div>
))}
</div>
)}
{/* Header */}
<div className="flex items-center justify-between gap-4">
<div>
<h2 className="text-lg font-semibold text-foreground flex items-center gap-2">
<AlertTriangle className="size-5 text-amber-500" />
Alarm Thresholds
</h2>
<p className="text-sm text-muted-foreground mt-0.5">
Configure threshold values that trigger alarms. Click a severity badge to toggle it; blur the value field to save.
</p>
</div>
{/* Reset controls */}
<div className="flex items-center gap-2 shrink-0">
{confirmReset ? (
<>
<span className="text-sm text-muted-foreground">Are you sure?</span>
<Button
size="sm"
variant="destructive"
onClick={handleReset}
disabled={resetting}
>
<RotateCcw className="size-4" />
{resetting ? "Resetting…" : "Confirm Reset"}
</Button>
<Button
size="sm"
variant="outline"
onClick={() => setConfirmReset(false)}
disabled={resetting}
>
Cancel
</Button>
</>
) : (
<Button
size="sm"
variant="outline"
onClick={() => setConfirmReset(true)}
className="border-destructive/40 text-destructive hover:bg-destructive/10 hover:text-destructive"
>
<RotateCcw className="size-4" />
Reset to Defaults
</Button>
)}
</div>
</div>
{/* Loading / Error */}
{loading && (
<div className="flex items-center justify-center py-16 text-sm text-muted-foreground">
<svg className="animate-spin size-5 mr-2 text-muted-foreground" viewBox="0 0 24 24" fill="none">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v4a4 4 0 00-4 4H4z" />
</svg>
Loading thresholds
</div>
)}
{!loading && error && (
<div className="rounded-lg border border-destructive/30 bg-destructive/10 px-4 py-3 text-sm text-destructive flex items-center gap-2">
<AlertTriangle className="size-4 shrink-0" />
{error}
<Button size="xs" variant="outline" onClick={load} className="ml-auto">
Retry
</Button>
</div>
)}
{/* Groups */}
{!loading && !error && (
<div className="space-y-3">
{GROUPS.map((group) => (
<GroupSection
key={group.label}
group={group}
thresholds={thresholds}
onUpdate={handleUpdate}
onDelete={handleDelete}
/>
))}
</div>
)}
{/* Add custom rule */}
{!loading && !error && (
<AddRuleForm
siteId={siteId}
onCreated={handleCreated}
onError={(msg) => pushToast(msg, "error")}
/>
)}
</div>
)
}

View file

@ -0,0 +1,281 @@
"use client";
import { useState, useEffect, useCallback } from "react";
import { FlaskConical, Play, RotateCcw, ChevronDown, Clock, Zap } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Sheet, SheetContent, SheetHeader, SheetTitle, SheetTrigger } from "@/components/ui/sheet";
import { Badge } from "@/components/ui/badge";
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
import { fetchScenarios, triggerScenario, type ScenarioInfo } from "@/lib/api";
// ── Small select for target override ─────────────────────────────────────────
function TargetSelect({
targets,
value,
onChange,
}: {
targets: string[];
value: string;
onChange: (v: string) => void;
}) {
if (targets.length <= 1) return null;
return (
<div className="relative inline-flex items-center">
<select
value={value}
onChange={(e) => onChange(e.target.value)}
className="appearance-none text-xs bg-muted border border-border rounded px-2 py-1 pr-6 text-foreground cursor-pointer focus:outline-none focus:ring-1 focus:ring-ring"
>
{targets.map((t) => (
<option key={t} value={t}>{t}</option>
))}
</select>
<ChevronDown className="pointer-events-none absolute right-1.5 top-1/2 -translate-y-1/2 w-3 h-3 text-muted-foreground" />
</div>
);
}
// ── Single scenario card ──────────────────────────────────────────────────────
function ScenarioCard({
scenario,
active,
onRun,
}: {
scenario: ScenarioInfo;
active: boolean;
onRun: (name: string, target?: string) => void;
}) {
const [target, setTarget] = useState(scenario.default_target ?? "");
return (
<div
className={`rounded-lg border px-4 py-3 flex flex-col gap-2 transition-colors ${
active
? "border-amber-500/60 bg-amber-500/5"
: "border-border bg-card"
}`}
>
{/* Header row */}
<div className="flex items-start justify-between gap-2">
<div className="flex items-center gap-2 flex-wrap">
<span className="text-sm font-semibold text-foreground leading-tight">
{scenario.label}
</span>
{scenario.compound && (
<Badge variant="secondary" className="text-[10px] px-1.5 py-0">
compound
</Badge>
)}
{active && (
<Badge className="text-[10px] px-1.5 py-0 bg-amber-500 text-white animate-pulse">
running
</Badge>
)}
</div>
<div className="flex items-center gap-2 shrink-0">
{!scenario.compound && (
<TargetSelect
targets={scenario.targets}
value={target}
onChange={setTarget}
/>
)}
<Button
size="sm"
variant={active ? "secondary" : "default"}
className="h-7 px-2.5 text-xs gap-1"
onClick={() => onRun(scenario.name, scenario.compound ? undefined : target || undefined)}
>
<Play className="w-3 h-3" />
Run
</Button>
</div>
</div>
{/* Description */}
<p className="text-xs text-muted-foreground leading-relaxed">
{scenario.description}
</p>
{/* Footer */}
<div className="flex items-center gap-1 text-[11px] text-muted-foreground">
<Clock className="w-3 h-3" />
{scenario.duration}
</div>
</div>
);
}
// ── Main panel ────────────────────────────────────────────────────────────────
export function SimulatorPanel() {
const [open, setOpen] = useState(false);
const [scenarios, setScenarios] = useState<ScenarioInfo[]>([]);
const [loading, setLoading] = useState(false);
const [activeScenario, setActiveScenario] = useState<string | null>(null);
const [status, setStatus] = useState<{ message: string; ok: boolean } | null>(null);
// Load scenario list once when panel first opens
useEffect(() => {
if (!open || scenarios.length > 0) return;
setLoading(true);
fetchScenarios()
.then(setScenarios)
.catch(() => setStatus({ message: "Failed to load scenarios", ok: false }))
.finally(() => setLoading(false));
}, [open, scenarios.length]);
const showStatus = useCallback((message: string, ok: boolean) => {
setStatus({ message, ok });
setTimeout(() => setStatus(null), 3000);
}, []);
const handleRun = useCallback(
async (name: string, target?: string) => {
try {
await triggerScenario(name, target);
setActiveScenario(name);
showStatus(
`${name}${target ? `${target}` : ""} triggered`,
true
);
} catch {
showStatus("Failed to trigger scenario", false);
}
},
[showStatus]
);
const handleReset = useCallback(async () => {
try {
await triggerScenario("RESET");
setActiveScenario(null);
showStatus("All scenarios reset", true);
} catch {
showStatus("Failed to reset", false);
}
}, [showStatus]);
const compound = scenarios.filter((s) => s.compound);
const single = scenarios.filter((s) => !s.compound);
return (
<Sheet open={open} onOpenChange={setOpen}>
<Tooltip>
<TooltipTrigger asChild>
<SheetTrigger asChild>
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
aria-label="Scenario simulator"
>
<FlaskConical className="w-4 h-4" />
</Button>
</SheetTrigger>
</TooltipTrigger>
<TooltipContent side="bottom">Scenario Simulator</TooltipContent>
</Tooltip>
<SheetContent
side="right"
className="w-[420px] sm:w-[480px] flex flex-col gap-0 p-0"
>
{/* Header */}
<SheetHeader className="px-5 py-4 border-b border-border shrink-0">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<FlaskConical className="w-4 h-4 text-muted-foreground" />
<SheetTitle className="text-base">Scenario Simulator</SheetTitle>
<Badge variant="outline" className="text-[10px] px-1.5 py-0">
demo only
</Badge>
</div>
</div>
<p className="text-xs text-muted-foreground mt-1">
Inject realistic fault scenarios into the live simulator. Changes are reflected immediately across all dashboard pages.
</p>
</SheetHeader>
{/* Reset bar */}
<div className="px-5 py-3 border-b border-border shrink-0 flex items-center justify-between gap-3">
<Button
variant="destructive"
size="sm"
className="gap-1.5"
onClick={handleReset}
>
<RotateCcw className="w-3.5 h-3.5" />
Reset All
</Button>
{status && (
<span
className={`text-xs transition-opacity ${
status.ok ? "text-emerald-500" : "text-destructive"
}`}
>
{status.message}
</span>
)}
</div>
{/* Scrollable scenario list */}
<div className="flex-1 overflow-y-auto px-5 py-4 space-y-5">
{loading && (
<p className="text-xs text-muted-foreground text-center py-8">
Loading scenarios
</p>
)}
{!loading && compound.length > 0 && (
<section className="space-y-2">
<div className="flex items-center gap-2">
<Zap className="w-3.5 h-3.5 text-amber-500" />
<h3 className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">
Compound Scenarios
</h3>
</div>
<p className="text-[11px] text-muted-foreground -mt-1">
Multi-device, time-sequenced chains fires automatically across the site.
</p>
<div className="space-y-2">
{compound.map((s) => (
<ScenarioCard
key={s.name}
scenario={s}
active={activeScenario === s.name}
onRun={handleRun}
/>
))}
</div>
</section>
)}
{!loading && single.length > 0 && (
<section className="space-y-2">
<div className="flex items-center gap-2">
<Play className="w-3.5 h-3.5 text-muted-foreground" />
<h3 className="text-xs font-semibold uppercase tracking-wider text-muted-foreground">
Single Fault Scenarios
</h3>
</div>
<div className="space-y-2">
{single.map((s) => (
<ScenarioCard
key={s.name}
scenario={s}
active={activeScenario === s.name}
onRun={handleRun}
/>
))}
</div>
</section>
)}
</div>
</SheetContent>
</Sheet>
);
}

View file

@ -0,0 +1,11 @@
"use client";
import * as React from "react";
import { ThemeProvider as NextThemesProvider } from "next-themes";
export function ThemeProvider({
children,
...props
}: React.ComponentProps<typeof NextThemesProvider>) {
return <NextThemesProvider {...props}>{children}</NextThemesProvider>;
}

View file

@ -0,0 +1,48 @@
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { Slot } from "radix-ui"
import { cn } from "@/lib/utils"
const badgeVariants = cva(
"inline-flex w-fit shrink-0 items-center justify-center gap-1 overflow-hidden rounded-full border border-transparent px-2 py-0.5 text-xs font-medium whitespace-nowrap transition-[color,box-shadow] focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&>svg]:pointer-events-none [&>svg]:size-3",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground [a&]:hover:bg-primary/90",
secondary:
"bg-secondary text-secondary-foreground [a&]:hover:bg-secondary/90",
destructive:
"bg-destructive text-white focus-visible:ring-destructive/20 dark:bg-destructive/60 dark:focus-visible:ring-destructive/40 [a&]:hover:bg-destructive/90",
outline:
"border-border text-foreground [a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
ghost: "[a&]:hover:bg-accent [a&]:hover:text-accent-foreground",
link: "text-primary underline-offset-4 [a&]:hover:underline",
},
},
defaultVariants: {
variant: "default",
},
}
)
function Badge({
className,
variant = "default",
asChild = false,
...props
}: React.ComponentProps<"span"> &
VariantProps<typeof badgeVariants> & { asChild?: boolean }) {
const Comp = asChild ? Slot.Root : "span"
return (
<Comp
data-slot="badge"
data-variant={variant}
className={cn(badgeVariants({ variant }), className)}
{...props}
/>
)
}
export { Badge, badgeVariants }

View file

@ -0,0 +1,64 @@
import * as React from "react"
import { cva, type VariantProps } from "class-variance-authority"
import { Slot } from "radix-ui"
import { cn } from "@/lib/utils"
const buttonVariants = cva(
"inline-flex shrink-0 items-center justify-center gap-2 rounded-md text-sm font-medium whitespace-nowrap transition-all outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:pointer-events-none disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground hover:bg-primary/90",
destructive:
"bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:bg-destructive/60 dark:focus-visible:ring-destructive/40",
outline:
"border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:border-input dark:bg-input/30 dark:hover:bg-input/50",
secondary:
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
ghost:
"hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default: "h-9 px-4 py-2 has-[>svg]:px-3",
xs: "h-6 gap-1 rounded-md px-2 text-xs has-[>svg]:px-1.5 [&_svg:not([class*='size-'])]:size-3",
sm: "h-8 gap-1.5 rounded-md px-3 has-[>svg]:px-2.5",
lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
icon: "size-9",
"icon-xs": "size-6 rounded-md [&_svg:not([class*='size-'])]:size-3",
"icon-sm": "size-8",
"icon-lg": "size-10",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
function Button({
className,
variant = "default",
size = "default",
asChild = false,
...props
}: React.ComponentProps<"button"> &
VariantProps<typeof buttonVariants> & {
asChild?: boolean
}) {
const Comp = asChild ? Slot.Root : "button"
return (
<Comp
data-slot="button"
data-variant={variant}
data-size={size}
className={cn(buttonVariants({ variant, size, className }))}
{...props}
/>
)
}
export { Button, buttonVariants }

View file

@ -0,0 +1,92 @@
import * as React from "react"
import { cn } from "@/lib/utils"
function Card({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card"
className={cn(
"flex flex-col gap-6 rounded-xl border bg-card py-6 text-card-foreground shadow-sm",
className
)}
{...props}
/>
)
}
function CardHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-header"
className={cn(
"@container/card-header grid auto-rows-min grid-rows-[auto_auto] items-start gap-2 px-6 has-data-[slot=card-action]:grid-cols-[1fr_auto] [.border-b]:pb-6",
className
)}
{...props}
/>
)
}
function CardTitle({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-title"
className={cn("leading-none font-semibold", className)}
{...props}
/>
)
}
function CardDescription({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-description"
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
)
}
function CardAction({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-action"
className={cn(
"col-start-2 row-span-2 row-start-1 self-start justify-self-end",
className
)}
{...props}
/>
)
}
function CardContent({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-content"
className={cn("px-6", className)}
{...props}
/>
)
}
function CardFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="card-footer"
className={cn("flex items-center px-6 [.border-t]:pt-6", className)}
{...props}
/>
)
}
export {
Card,
CardHeader,
CardFooter,
CardTitle,
CardAction,
CardDescription,
CardContent,
}

View file

@ -0,0 +1,257 @@
"use client"
import * as React from "react"
import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react"
import { DropdownMenu as DropdownMenuPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
function DropdownMenu({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Root>) {
return <DropdownMenuPrimitive.Root data-slot="dropdown-menu" {...props} />
}
function DropdownMenuPortal({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Portal>) {
return (
<DropdownMenuPrimitive.Portal data-slot="dropdown-menu-portal" {...props} />
)
}
function DropdownMenuTrigger({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Trigger>) {
return (
<DropdownMenuPrimitive.Trigger
data-slot="dropdown-menu-trigger"
{...props}
/>
)
}
function DropdownMenuContent({
className,
sideOffset = 4,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Content>) {
return (
<DropdownMenuPrimitive.Portal>
<DropdownMenuPrimitive.Content
data-slot="dropdown-menu-content"
sideOffset={sideOffset}
className={cn(
"z-50 max-h-(--radix-dropdown-menu-content-available-height) min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border bg-popover p-1 text-popover-foreground shadow-md data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95",
className
)}
{...props}
/>
</DropdownMenuPrimitive.Portal>
)
}
function DropdownMenuGroup({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Group>) {
return (
<DropdownMenuPrimitive.Group data-slot="dropdown-menu-group" {...props} />
)
}
function DropdownMenuItem({
className,
inset,
variant = "default",
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Item> & {
inset?: boolean
variant?: "default" | "destructive"
}) {
return (
<DropdownMenuPrimitive.Item
data-slot="dropdown-menu-item"
data-inset={inset}
data-variant={variant}
className={cn(
"relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 data-[variant=destructive]:focus:text-destructive dark:data-[variant=destructive]:focus:bg-destructive/20 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 [&_svg:not([class*='text-'])]:text-muted-foreground data-[variant=destructive]:*:[svg]:text-destructive!",
className
)}
{...props}
/>
)
}
function DropdownMenuCheckboxItem({
className,
children,
checked,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.CheckboxItem>) {
return (
<DropdownMenuPrimitive.CheckboxItem
data-slot="dropdown-menu-checkbox-item"
className={cn(
"relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
checked={checked}
{...props}
>
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<CheckIcon className="size-4" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.CheckboxItem>
)
}
function DropdownMenuRadioGroup({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioGroup>) {
return (
<DropdownMenuPrimitive.RadioGroup
data-slot="dropdown-menu-radio-group"
{...props}
/>
)
}
function DropdownMenuRadioItem({
className,
children,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioItem>) {
return (
<DropdownMenuPrimitive.RadioItem
data-slot="dropdown-menu-radio-item"
className={cn(
"relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
>
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
<DropdownMenuPrimitive.ItemIndicator>
<CircleIcon className="size-2 fill-current" />
</DropdownMenuPrimitive.ItemIndicator>
</span>
{children}
</DropdownMenuPrimitive.RadioItem>
)
}
function DropdownMenuLabel({
className,
inset,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Label> & {
inset?: boolean
}) {
return (
<DropdownMenuPrimitive.Label
data-slot="dropdown-menu-label"
data-inset={inset}
className={cn(
"px-2 py-1.5 text-sm font-medium data-[inset]:pl-8",
className
)}
{...props}
/>
)
}
function DropdownMenuSeparator({
className,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Separator>) {
return (
<DropdownMenuPrimitive.Separator
data-slot="dropdown-menu-separator"
className={cn("-mx-1 my-1 h-px bg-border", className)}
{...props}
/>
)
}
function DropdownMenuShortcut({
className,
...props
}: React.ComponentProps<"span">) {
return (
<span
data-slot="dropdown-menu-shortcut"
className={cn(
"ml-auto text-xs tracking-widest text-muted-foreground",
className
)}
{...props}
/>
)
}
function DropdownMenuSub({
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.Sub>) {
return <DropdownMenuPrimitive.Sub data-slot="dropdown-menu-sub" {...props} />
}
function DropdownMenuSubTrigger({
className,
inset,
children,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubTrigger> & {
inset?: boolean
}) {
return (
<DropdownMenuPrimitive.SubTrigger
data-slot="dropdown-menu-sub-trigger"
data-inset={inset}
className={cn(
"flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none focus:bg-accent focus:text-accent-foreground data-[inset]:pl-8 data-[state=open]:bg-accent data-[state=open]:text-accent-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 [&_svg:not([class*='text-'])]:text-muted-foreground",
className
)}
{...props}
>
{children}
<ChevronRightIcon className="ml-auto size-4" />
</DropdownMenuPrimitive.SubTrigger>
)
}
function DropdownMenuSubContent({
className,
...props
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubContent>) {
return (
<DropdownMenuPrimitive.SubContent
data-slot="dropdown-menu-sub-content"
className={cn(
"z-50 min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-hidden rounded-md border bg-popover p-1 text-popover-foreground shadow-lg data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95 data-[state=open]:animate-in data-[state=open]:fade-in-0 data-[state=open]:zoom-in-95",
className
)}
{...props}
/>
)
}
export {
DropdownMenu,
DropdownMenuPortal,
DropdownMenuTrigger,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuLabel,
DropdownMenuItem,
DropdownMenuCheckboxItem,
DropdownMenuRadioGroup,
DropdownMenuRadioItem,
DropdownMenuSeparator,
DropdownMenuShortcut,
DropdownMenuSub,
DropdownMenuSubTrigger,
DropdownMenuSubContent,
}

View file

@ -0,0 +1,45 @@
import { Inbox } from "lucide-react";
import { Card, CardContent } from "@/components/ui/card";
import { cn } from "@/lib/utils";
interface EmptyCardProps {
message?: string;
description?: string;
icon?: React.ReactNode;
className?: string;
/** Render as a compact inline row instead of a full card */
inline?: boolean;
}
export function EmptyCard({
message = "No data available",
description,
icon,
className,
inline = false,
}: EmptyCardProps) {
if (inline) {
return (
<div className={cn("flex items-center gap-2 text-sm text-muted-foreground", className)}>
{icon ?? <Inbox className="w-4 h-4 shrink-0" />}
<span>{message}</span>
</div>
);
}
return (
<Card className={cn("border-dashed", className)}>
<CardContent className="flex flex-col items-center justify-center gap-3 py-10 text-center">
<div className="text-muted-foreground/40">
{icon ?? <Inbox className="w-8 h-8" />}
</div>
<div>
<p className="text-sm font-medium text-muted-foreground">{message}</p>
{description && (
<p className="text-xs text-muted-foreground/60 mt-1">{description}</p>
)}
</div>
</CardContent>
</Card>
);
}

View file

@ -0,0 +1,54 @@
import { AlertTriangle, RefreshCw } from "lucide-react";
import { Card, CardContent } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
interface ErrorCardProps {
message?: string;
onRetry?: () => void;
className?: string;
/** Render as a compact inline row instead of a full card */
inline?: boolean;
}
export function ErrorCard({
message = "Failed to load data.",
onRetry,
className,
inline = false,
}: ErrorCardProps) {
if (inline) {
return (
<div className={cn("flex items-center gap-2 text-sm text-muted-foreground", className)}>
<AlertTriangle className="w-4 h-4 text-amber-400 shrink-0" />
<span>{message}</span>
{onRetry && (
<button
onClick={onRetry}
className="text-xs underline underline-offset-2 hover:text-foreground transition-colors"
>
Retry
</button>
)}
</div>
);
}
return (
<Card className={cn("border-destructive/30 bg-destructive/5", className)}>
<CardContent className="flex flex-col items-center justify-center gap-3 py-10 text-center">
<AlertTriangle className="w-8 h-8 text-destructive/70" />
<div>
<p className="text-sm font-medium text-foreground">Something went wrong</p>
<p className="text-xs text-muted-foreground mt-1">{message}</p>
</div>
{onRetry && (
<Button variant="outline" size="sm" onClick={onRetry} className="gap-2 mt-1">
<RefreshCw className="w-3.5 h-3.5" />
Try again
</Button>
)}
</CardContent>
</Card>
);
}

View file

@ -0,0 +1,28 @@
"use client"
import * as React from "react"
import { Separator as SeparatorPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
function Separator({
className,
orientation = "horizontal",
decorative = true,
...props
}: React.ComponentProps<typeof SeparatorPrimitive.Root>) {
return (
<SeparatorPrimitive.Root
data-slot="separator"
decorative={decorative}
orientation={orientation}
className={cn(
"shrink-0 bg-border data-[orientation=horizontal]:h-px data-[orientation=horizontal]:w-full data-[orientation=vertical]:h-full data-[orientation=vertical]:w-px",
className
)}
{...props}
/>
)
}
export { Separator }

View file

@ -0,0 +1,143 @@
"use client"
import * as React from "react"
import { XIcon } from "lucide-react"
import { Dialog as SheetPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
function Sheet({ ...props }: React.ComponentProps<typeof SheetPrimitive.Root>) {
return <SheetPrimitive.Root data-slot="sheet" {...props} />
}
function SheetTrigger({
...props
}: React.ComponentProps<typeof SheetPrimitive.Trigger>) {
return <SheetPrimitive.Trigger data-slot="sheet-trigger" {...props} />
}
function SheetClose({
...props
}: React.ComponentProps<typeof SheetPrimitive.Close>) {
return <SheetPrimitive.Close data-slot="sheet-close" {...props} />
}
function SheetPortal({
...props
}: React.ComponentProps<typeof SheetPrimitive.Portal>) {
return <SheetPrimitive.Portal data-slot="sheet-portal" {...props} />
}
function SheetOverlay({
className,
...props
}: React.ComponentProps<typeof SheetPrimitive.Overlay>) {
return (
<SheetPrimitive.Overlay
data-slot="sheet-overlay"
className={cn(
"fixed inset-0 z-50 bg-black/50 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:animate-in data-[state=open]:fade-in-0",
className
)}
{...props}
/>
)
}
function SheetContent({
className,
children,
side = "right",
showCloseButton = true,
...props
}: React.ComponentProps<typeof SheetPrimitive.Content> & {
side?: "top" | "right" | "bottom" | "left"
showCloseButton?: boolean
}) {
return (
<SheetPortal>
<SheetOverlay />
<SheetPrimitive.Content
data-slot="sheet-content"
className={cn(
"fixed z-50 flex flex-col gap-4 bg-background shadow-lg transition ease-in-out data-[state=closed]:animate-out data-[state=closed]:duration-300 data-[state=open]:animate-in data-[state=open]:duration-500",
side === "right" &&
"inset-y-0 right-0 h-full w-3/4 border-l data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right sm:max-w-sm",
side === "left" &&
"inset-y-0 left-0 h-full w-3/4 border-r data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left sm:max-w-sm",
side === "top" &&
"inset-x-0 top-0 h-auto border-b data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top",
side === "bottom" &&
"inset-x-0 bottom-0 h-auto border-t data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom",
className
)}
{...props}
>
{children}
{showCloseButton && (
<SheetPrimitive.Close className="absolute top-4 right-4 rounded-xs opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:ring-2 focus:ring-ring focus:ring-offset-2 focus:outline-hidden disabled:pointer-events-none data-[state=open]:bg-secondary">
<XIcon className="size-4" />
<span className="sr-only">Close</span>
</SheetPrimitive.Close>
)}
</SheetPrimitive.Content>
</SheetPortal>
)
}
function SheetHeader({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sheet-header"
className={cn("flex flex-col gap-1.5 p-4", className)}
{...props}
/>
)
}
function SheetFooter({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="sheet-footer"
className={cn("mt-auto flex flex-col gap-2 p-4", className)}
{...props}
/>
)
}
function SheetTitle({
className,
...props
}: React.ComponentProps<typeof SheetPrimitive.Title>) {
return (
<SheetPrimitive.Title
data-slot="sheet-title"
className={cn("font-semibold text-foreground", className)}
{...props}
/>
)
}
function SheetDescription({
className,
...props
}: React.ComponentProps<typeof SheetPrimitive.Description>) {
return (
<SheetPrimitive.Description
data-slot="sheet-description"
className={cn("text-sm text-muted-foreground", className)}
{...props}
/>
)
}
export {
Sheet,
SheetTrigger,
SheetClose,
SheetContent,
SheetHeader,
SheetFooter,
SheetTitle,
SheetDescription,
}

View file

@ -0,0 +1,13 @@
import { cn } from "@/lib/utils"
function Skeleton({ className, ...props }: React.ComponentProps<"div">) {
return (
<div
data-slot="skeleton"
className={cn("animate-pulse rounded-md bg-accent", className)}
{...props}
/>
)
}
export { Skeleton }

View file

@ -0,0 +1,48 @@
"use client";
import * as React from "react";
import { Tabs as TabsPrimitive } from "radix-ui";
import { cn } from "@/lib/utils";
const Tabs = TabsPrimitive.Root;
function TabsList({ className, ...props }: React.ComponentProps<typeof TabsPrimitive.List>) {
return (
<TabsPrimitive.List
className={cn(
"inline-flex h-9 items-center justify-center rounded-lg bg-muted p-1 text-muted-foreground",
className
)}
{...props}
/>
);
}
function TabsTrigger({ className, ...props }: React.ComponentProps<typeof TabsPrimitive.Trigger>) {
return (
<TabsPrimitive.Trigger
className={cn(
"inline-flex items-center justify-center whitespace-nowrap rounded-md px-3 py-1 text-sm font-medium ring-offset-background transition-all",
"focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
"disabled:pointer-events-none disabled:opacity-50",
"data-[state=active]:bg-background data-[state=active]:text-foreground data-[state=active]:shadow",
className
)}
{...props}
/>
);
}
function TabsContent({ className, ...props }: React.ComponentProps<typeof TabsPrimitive.Content>) {
return (
<TabsPrimitive.Content
className={cn(
"mt-2 ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2",
className
)}
{...props}
/>
);
}
export { Tabs, TabsList, TabsTrigger, TabsContent };

View file

@ -0,0 +1,37 @@
"use client";
import { cn } from "@/lib/utils";
const OPTIONS = [
{ label: "1h", value: 1 },
{ label: "6h", value: 6 },
{ label: "24h", value: 24 },
{ label: "7d", value: 168 },
];
interface TimeRangePickerProps {
value: number;
onChange: (hours: number) => void;
options?: { label: string; value: number }[];
}
export function TimeRangePicker({ value, onChange, options = OPTIONS }: TimeRangePickerProps) {
return (
<div className="flex items-center gap-0.5 rounded-md border border-border bg-muted/40 p-0.5">
{options.map((opt) => (
<button
key={opt.value}
onClick={() => onChange(opt.value)}
className={cn(
"px-2 py-0.5 text-[11px] font-medium rounded transition-colors",
value === opt.value
? "bg-background text-foreground shadow-sm"
: "text-muted-foreground hover:text-foreground"
)}
>
{opt.label}
</button>
))}
</div>
);
}

View file

@ -0,0 +1,57 @@
"use client"
import * as React from "react"
import { Tooltip as TooltipPrimitive } from "radix-ui"
import { cn } from "@/lib/utils"
function TooltipProvider({
delayDuration = 0,
...props
}: React.ComponentProps<typeof TooltipPrimitive.Provider>) {
return (
<TooltipPrimitive.Provider
data-slot="tooltip-provider"
delayDuration={delayDuration}
{...props}
/>
)
}
function Tooltip({
...props
}: React.ComponentProps<typeof TooltipPrimitive.Root>) {
return <TooltipPrimitive.Root data-slot="tooltip" {...props} />
}
function TooltipTrigger({
...props
}: React.ComponentProps<typeof TooltipPrimitive.Trigger>) {
return <TooltipPrimitive.Trigger data-slot="tooltip-trigger" {...props} />
}
function TooltipContent({
className,
sideOffset = 0,
children,
...props
}: React.ComponentProps<typeof TooltipPrimitive.Content>) {
return (
<TooltipPrimitive.Portal>
<TooltipPrimitive.Content
data-slot="tooltip-content"
sideOffset={sideOffset}
className={cn(
"z-50 w-fit origin-(--radix-tooltip-content-transform-origin) animate-in rounded-md bg-foreground px-3 py-1.5 text-xs text-balance text-background fade-in-0 zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95",
className
)}
{...props}
>
{children}
<TooltipPrimitive.Arrow className="z-50 size-2.5 translate-y-[calc(-50%_-_2px)] rotate-45 rounded-[2px] bg-foreground fill-foreground" />
</TooltipPrimitive.Content>
</TooltipPrimitive.Portal>
)
}
export { Tooltip, TooltipTrigger, TooltipContent, TooltipProvider }

Some files were not shown because too many files have changed in this diff Show more