first commit
This commit is contained in:
commit
4b98219bf7
144 changed files with 31561 additions and 0 deletions
42
.gitignore
vendored
Normal file
42
.gitignore
vendored
Normal 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
149
ACTION_PLAN.md
Normal 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 (4–6 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 (1–9) 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 1–9, 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 | ~2–3 h |
|
||||
| 3 — Mini floor map | New dashboard component | No | ~2–3 h |
|
||||
| 4 — Floor map zoom/pan + CRAC shading | react-zoom-pan-pinch + overlay logic | No | ~3–4 h |
|
||||
| 5 — Particle count | New simulator bot + API endpoint + env panel | Yes | ~3–4 h total |
|
||||
271
IMPROVEMENTS.md
Normal file
271
IMPROVEMENTS.md
Normal 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.04–0.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
112
README.md
Normal 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
367
UI_UX_PLAN.md
Normal 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:** ~1–2 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 1–25 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 60–120s, 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:** Low–Medium
|
||||
|
||||
### 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:** Low–Medium
|
||||
|
||||
### 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:** ~1–2 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) | 1–2 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 | 1–2 days |
|
||||
| **Total** | | | **~11–13 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 2–9 are independent of each other and can be parallelised once Phase 1 is complete.
|
||||
17
backend/Dockerfile
Normal file
17
backend/Dockerfile
Normal 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
0
backend/__init__.py
Normal file
0
backend/api/__init__.py
Normal file
0
backend/api/__init__.py
Normal file
0
backend/api/routes/__init__.py
Normal file
0
backend/api/routes/__init__.py
Normal file
82
backend/api/routes/alarms.py
Normal file
82
backend/api/routes/alarms.py
Normal 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()}
|
||||
344
backend/api/routes/assets.py
Normal file
344
backend/api/routes/assets.py
Normal 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
|
||||
110
backend/api/routes/capacity.py
Normal file
110
backend/api/routes/capacity.py
Normal 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,
|
||||
}
|
||||
131
backend/api/routes/cooling.py
Normal file
131
backend/api/routes/cooling.py
Normal 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
440
backend/api/routes/env.py
Normal 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
|
||||
75
backend/api/routes/fire.py
Normal file
75
backend/api/routes/fire.py
Normal 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
|
||||
33
backend/api/routes/floor_layout.py
Normal file
33
backend/api/routes/floor_layout.py
Normal 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}
|
||||
138
backend/api/routes/generator.py
Normal file
138
backend/api/routes/generator.py
Normal 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())
|
||||
13
backend/api/routes/health.py
Normal file
13
backend/api/routes/health.py
Normal 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(),
|
||||
}
|
||||
57
backend/api/routes/leak.py
Normal file
57
backend/api/routes/leak.py
Normal 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
|
||||
77
backend/api/routes/maintenance.py
Normal file
77
backend/api/routes/maintenance.py
Normal 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"]
|
||||
]
|
||||
69
backend/api/routes/network.py
Normal file
69
backend/api/routes/network.py
Normal 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
460
backend/api/routes/power.py
Normal 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",
|
||||
}
|
||||
229
backend/api/routes/readings.py
Normal file
229
backend/api/routes/readings.py
Normal 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
|
||||
356
backend/api/routes/reports.py
Normal file
356
backend/api/routes/reports.py
Normal 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.4–1.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"},
|
||||
)
|
||||
248
backend/api/routes/scenarios.py
Normal file
248
backend/api/routes/scenarios.py
Normal 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 ~85–95% 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}
|
||||
465
backend/api/routes/settings.py
Normal file
465
backend/api/routes/settings.py
Normal 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)
|
||||
36
backend/api/routes/sites.py
Normal file
36
backend/api/routes/sites.py
Normal 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
16
backend/api/routes/ws.py
Normal 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
0
backend/core/__init__.py
Normal file
27
backend/core/config.py
Normal file
27
backend/core/config.py
Normal 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
130
backend/core/database.py
Normal 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
73
backend/main.py
Normal 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"])
|
||||
0
backend/models/__init__.py
Normal file
0
backend/models/__init__.py
Normal file
11
backend/requirements.txt
Normal file
11
backend/requirements.txt
Normal 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
|
||||
0
backend/services/__init__.py
Normal file
0
backend/services/__init__.py
Normal file
152
backend/services/alarm_engine.py
Normal file
152
backend/services/alarm_engine.py
Normal 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"),
|
||||
]
|
||||
]
|
||||
328
backend/services/mqtt_subscriber.py
Normal file
328
backend/services/mqtt_subscriber.py
Normal 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
234
backend/services/seed.py
Normal 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)
|
||||
35
backend/services/ws_manager.py
Normal file
35
backend/services/ws_manager.py
Normal 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
107
docker-compose.yml
Normal 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
41
frontend/.gitignore
vendored
Normal 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
36
frontend/Dockerfile
Normal 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
36
frontend/README.md
Normal 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.
|
||||
753
frontend/app/(dashboard)/alarms/page.tsx
Normal file
753
frontend/app/(dashboard)/alarms/page.tsx
Normal 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 & 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>
|
||||
);
|
||||
}
|
||||
703
frontend/app/(dashboard)/assets/page.tsx
Normal file
703
frontend/app/(dashboard)/assets/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
596
frontend/app/(dashboard)/capacity/page.tsx
Normal file
596
frontend/app/(dashboard)/capacity/page.tsx
Normal 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 & 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>
|
||||
);
|
||||
}
|
||||
610
frontend/app/(dashboard)/cooling/page.tsx
Normal file
610
frontend/app/(dashboard)/cooling/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
246
frontend/app/(dashboard)/dashboard/page.tsx
Normal file
246
frontend/app/(dashboard)/dashboard/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
330
frontend/app/(dashboard)/energy/page.tsx
Normal file
330
frontend/app/(dashboard)/energy/page.tsx
Normal 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 & 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: < 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 ?? "—"} tCO₂e</p>
|
||||
<p className="text-[10px] text-muted-foreground">
|
||||
30-day estimate · {energy?.kwh_total.toFixed(0) ?? "—"} kWh × {GRID_EF_KG_CO2_KWH} kgCO₂e/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} kgCO₂e/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 < 1.4</p>
|
||||
<p className="text-muted-foreground/50 text-[10px] pt-1">
|
||||
CO₂e and WUE estimates are indicative. Actual values depend on metered chilled water and cooling tower data.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
846
frontend/app/(dashboard)/environmental/page.tsx
Normal file
846
frontend/app/(dashboard)/environmental/page.tsx
Normal 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 | 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: 30–65% | 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 & 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 (18–27°C / 20–80% RH)
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
// ── ASHRAE A1 Compliance Table ────────────────────────────────────────────────
|
||||
|
||||
// ASHRAE A1: 15–32°C, 20–80% 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 (15–32°C, 20–80% 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: 15–32°C dry bulb, 20–80% 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>
|
||||
);
|
||||
}
|
||||
285
frontend/app/(dashboard)/fire/page.tsx
Normal file
285
frontend/app/(dashboard)/fire/page.tsx
Normal 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); // 0–5 %/m mapped to 0–100%
|
||||
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 & 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>
|
||||
);
|
||||
}
|
||||
963
frontend/app/(dashboard)/floor-map/page.tsx
Normal file
963
frontend/app/(dashboard)/floor-map/page.tsx
Normal 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: "1–2" },
|
||||
{ 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 | Critical: {thresholds.temp.critical}°C</span>}
|
||||
{overlay === "power" && <span className="ml-auto">Warn: 75% | Critical: 90%</span>}
|
||||
</>
|
||||
)}
|
||||
<span className="text-muted-foreground/50">Click any rack to drill down</span>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
412
frontend/app/(dashboard)/generator/page.tsx
Normal file
412
frontend/app/(dashboard)/generator/page.tsx
Normal 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 & 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>
|
||||
);
|
||||
}
|
||||
33
frontend/app/(dashboard)/layout.tsx
Normal file
33
frontend/app/(dashboard)/layout.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
244
frontend/app/(dashboard)/leak/page.tsx
Normal file
244
frontend/app/(dashboard)/leak/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
440
frontend/app/(dashboard)/maintenance/page.tsx
Normal file
440
frontend/app/(dashboard)/maintenance/page.tsx
Normal 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 & 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 "New Window" 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>
|
||||
);
|
||||
}
|
||||
256
frontend/app/(dashboard)/network/page.tsx
Normal file
256
frontend/app/(dashboard)/network/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
858
frontend/app/(dashboard)/power/page.tsx
Normal file
858
frontend/app/(dashboard)/power/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
426
frontend/app/(dashboard)/reports/page.tsx
Normal file
426
frontend/app/(dashboard)/reports/page.tsx
Normal 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 & 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 <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 < 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>
|
||||
);
|
||||
}
|
||||
468
frontend/app/(dashboard)/settings/page.tsx
Normal file
468
frontend/app/(dashboard)/settings/page.tsx
Normal 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
BIN
frontend/app/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 25 KiB |
126
frontend/app/globals.css
Normal file
126
frontend/app/globals.css
Normal 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
46
frontend/app/layout.tsx
Normal 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
5
frontend/app/page.tsx
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
import { redirect } from "next/navigation";
|
||||
|
||||
export default function Home() {
|
||||
redirect("/dashboard");
|
||||
}
|
||||
21
frontend/app/sign-in/[[...sign-in]]/page.tsx
Normal file
21
frontend/app/sign-in/[[...sign-in]]/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
21
frontend/app/sign-up/[[...sign-up]]/page.tsx
Normal file
21
frontend/app/sign-up/[[...sign-up]]/page.tsx
Normal 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
23
frontend/components.json
Normal 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": {}
|
||||
}
|
||||
124
frontend/components/dashboard/alarm-feed.tsx
Normal file
124
frontend/components/dashboard/alarm-feed.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
26
frontend/components/dashboard/coming-soon.tsx
Normal file
26
frontend/components/dashboard/coming-soon.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
473
frontend/components/dashboard/crac-detail-sheet.tsx
Normal file
473
frontend/components/dashboard/crac-detail-sheet.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
489
frontend/components/dashboard/generator-detail-sheet.tsx
Normal file
489
frontend/components/dashboard/generator-detail-sheet.tsx
Normal 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: 70–90°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: 200–420°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: 40–70°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;
|
||||
77
frontend/components/dashboard/kpi-card.tsx
Normal file
77
frontend/components/dashboard/kpi-card.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
144
frontend/components/dashboard/mini-floor-map.tsx
Normal file
144
frontend/components/dashboard/mini-floor-map.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
58
frontend/components/dashboard/power-trend-chart.tsx
Normal file
58
frontend/components/dashboard/power-trend-chart.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
422
frontend/components/dashboard/rack-detail-sheet.tsx
Normal file
422
frontend/components/dashboard/rack-detail-sheet.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
97
frontend/components/dashboard/room-status-grid.tsx
Normal file
97
frontend/components/dashboard/room-status-grid.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
75
frontend/components/dashboard/temperature-trend-chart.tsx
Normal file
75
frontend/components/dashboard/temperature-trend-chart.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
357
frontend/components/dashboard/ups-detail-sheet.tsx
Normal file
357
frontend/components/dashboard/ups-detail-sheet.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
47
frontend/components/error-boundary.tsx
Normal file
47
frontend/components/error-boundary.tsx
Normal 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;
|
||||
}
|
||||
}
|
||||
18
frontend/components/layout/page-shell.tsx
Normal file
18
frontend/components/layout/page-shell.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
211
frontend/components/layout/sidebar.tsx
Normal file
211
frontend/components/layout/sidebar.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
100
frontend/components/layout/topbar.tsx
Normal file
100
frontend/components/layout/topbar.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
289
frontend/components/settings/SensorDetailSheet.tsx
Normal file
289
frontend/components/settings/SensorDetailSheet.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
574
frontend/components/settings/SensorSheet.tsx
Normal file
574
frontend/components/settings/SensorSheet.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
243
frontend/components/settings/SensorTable.tsx
Normal file
243
frontend/components/settings/SensorTable.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
719
frontend/components/settings/ThresholdEditor.tsx
Normal file
719
frontend/components/settings/ThresholdEditor.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
281
frontend/components/simulator/SimulatorPanel.tsx
Normal file
281
frontend/components/simulator/SimulatorPanel.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
11
frontend/components/theme-provider.tsx
Normal file
11
frontend/components/theme-provider.tsx
Normal 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>;
|
||||
}
|
||||
48
frontend/components/ui/badge.tsx
Normal file
48
frontend/components/ui/badge.tsx
Normal 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 }
|
||||
64
frontend/components/ui/button.tsx
Normal file
64
frontend/components/ui/button.tsx
Normal 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 }
|
||||
92
frontend/components/ui/card.tsx
Normal file
92
frontend/components/ui/card.tsx
Normal 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,
|
||||
}
|
||||
257
frontend/components/ui/dropdown-menu.tsx
Normal file
257
frontend/components/ui/dropdown-menu.tsx
Normal 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,
|
||||
}
|
||||
45
frontend/components/ui/empty-card.tsx
Normal file
45
frontend/components/ui/empty-card.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
54
frontend/components/ui/error-card.tsx
Normal file
54
frontend/components/ui/error-card.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
28
frontend/components/ui/separator.tsx
Normal file
28
frontend/components/ui/separator.tsx
Normal 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 }
|
||||
143
frontend/components/ui/sheet.tsx
Normal file
143
frontend/components/ui/sheet.tsx
Normal 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,
|
||||
}
|
||||
13
frontend/components/ui/skeleton.tsx
Normal file
13
frontend/components/ui/skeleton.tsx
Normal 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 }
|
||||
48
frontend/components/ui/tabs.tsx
Normal file
48
frontend/components/ui/tabs.tsx
Normal 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 };
|
||||
37
frontend/components/ui/time-range-picker.tsx
Normal file
37
frontend/components/ui/time-range-picker.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
57
frontend/components/ui/tooltip.tsx
Normal file
57
frontend/components/ui/tooltip.tsx
Normal 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
Loading…
Add table
Add a link
Reference in a new issue