diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..0bb802d --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,38 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + +jobs: + backend: + runs-on: ubuntu-latest + defaults: + run: + working-directory: backend + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: "3.13" + cache: pip + cache-dependency-path: backend/requirements*.txt + - run: pip install -r requirements-dev.txt + - run: python -m pytest -q + + frontend: + runs-on: ubuntu-latest + defaults: + run: + working-directory: frontend + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: 22 + cache: npm + cache-dependency-path: frontend/package-lock.json + - run: npm ci + - run: npm test + - run: npm run build diff --git a/.gitignore b/.gitignore index ba0d1a1..350de40 100644 --- a/.gitignore +++ b/.gitignore @@ -27,3 +27,6 @@ dist/ Thumbs.db .idea/ .vscode/ + +# Tests +.pytest_cache/ diff --git a/README.md b/README.md index d79f624..c48e24b 100644 --- a/README.md +++ b/README.md @@ -1,105 +1,146 @@ -# Power Dashboard - Electrical Power Monitoring System +# Power Dashboard -A full-stack, real-time Electrical Power Monitoring System (EPMS) dashboard designed for industrial data center and facility power distribution environments. The system continuously polls simulated power meters, stores time-series readings, evaluates alarm conditions against ISA 18.2 thresholds, and delivers live data to a web-based SCADA-style interface via WebSocket. +Real time electrical power monitoring for a data center, with alarm management that follows ANSI/ISA-18.2. -**Live Demo:** [https://power-dashboard-vd3w.onrender.com](https://power-dashboard-vd3w.onrender.com) +### Live demo: [power-dashboard-vd3w.onrender.com](https://power-dashboard-vd3w.onrender.com) -> **Note:** The free tier backend may take 30-50 seconds to wake up on first load. +The demo runs on **simulated data**: no real facility, customer, or equipment is connected. It is hosted on a free tier that sleeps when idle, so the first visit after a quiet period shows a short "waking up" screen for about 30 to 60 seconds. After that everything updates live every 2 seconds. + +![Dashboard during a simulated utility outage](screenshots/dashboard.webp) --- -## Screenshots +## What it shows -### Dashboard Overview -![Dashboard](screenshots/dashboard.webp) +- **A simulated 2N data center.** Utility service and a standby generator feed an automatic transfer switch, then the main switchboard. Two UPS systems (A and B side) each feed two PDUs, and the cooling plant runs from its own panel. Values roll up from the IT load through transformer and UPS losses, so the numbers add up the way a real site does. +- **Equipment that behaves like equipment.** During a utility outage the UPS systems carry the IT load on battery, the generator cranks and comes up to speed, the ATS transfers, the frequency dips when the block load lands, cooling restarts, and the ATS retransfers once utility power has been stable. +- **ISA 18.2 alarm handling** with acknowledge, shelve, return to normal, designed suppression, an alarm journal, and a master alarm database. See [Alarm management](#alarm-management-isa-182). +- **A live one-line diagram** that follows the power path, showing which conductors are carrying load, energized, or dead. +- **Meter detail and trends** for every device, with 1 hour, 24 hour, and 7 day history and CSV export. +- **Try it yourself.** Open *About this demo* and trigger a utility outage, voltage sag, PDU overload, or power factor drop. Events also happen on their own every few minutes. -### One-Line Diagram -![One-Line Diagram](screenshots/one-line.webp) +--- -### Meter Detail -![Meters](screenshots/meters.webp) +## Alarm management (ISA 18.2) -### Alarm Management -![Alarms](screenshots/alarms.webp) +Every alarm point runs the ISA 18.2 alarm state model: -### Trending -![Trending](screenshots/trending.webp) +| State | Meaning | Shown as | +|---|---|---| +| Normal | Process normal, nothing to acknowledge | Not in the alarm list | +| Unacknowledged | Process abnormal, no operator has acknowledged it | Solid priority color, flashing | +| Acknowledged | Process still abnormal, operator is responding | Steady outline | +| Returned to normal, unacknowledged | Process recovered before anyone acknowledged | Dashed outline, flashing, still needs an acknowledgement | +| Shelved | Removed from the list by the operator for a limited time with a logged reason | Shelved tab with a countdown | +| Suppressed by design | Hidden by a designed rule based on plant state | Suppressed tab with the rule shown | -### Settings -![Settings](screenshots/settings.webp) +``` +Normal --condition--> Unacknowledged --acknowledge--> Acknowledged --clears--> Normal + Unacknowledged --clears--> Returned to normal, unack --acknowledge--> Normal +``` + +What else is implemented: + +- **Rationalized master alarm database.** Each alarm has one cause, a priority, a documented consequence, and an operator response. The full table is on the *Alarm database* tab. +- **Priorities by consequence and time to respond:** Critical (under 5 min), High (under 15 min), Medium (within the shift), Low (when practical). +- **Chatter control:** deadbands (hysteresis) on analog alarms and on/off delays counted in scans. +- **Time limited shelving** (5, 15, or 60 minutes, 60 maximum). When the timer runs out the alarm returns to service and re-annunciates if the condition is still present. +- **Designed suppression:** the utility low voltage alarm is suppressed while the utility failure alarm is active, generator frequency alarms are suppressed while the generator is not running, and the cooling plant power factor alarm is suppressed while its panel is de-energized. This prevents the nuisance alarms a simple threshold system would raise during an outage. +- **Alarm journal:** every alarm, acknowledgement, return to normal, shelve, unshelve, and suppression event is recorded with time, value, and operator. +- **Performance KPIs:** annunciated alarm rate over the last 10 minutes compared with the ISA 18.2 targets (about 1 per 10 minutes acceptable, 2 manageable, 10 or more is a flood), plus counts by priority and state. The demo deliberately runs events faster than a real site, so the rate often shows "Above target". + +![Alarm list with an expanded alarm showing consequence and operator response](screenshots/alarms.webp) --- -## Demo +## Screenshots + +| One-line diagram during an outage | Alarm journal | +|---|---| +| ![One-line diagram](screenshots/one-line.webp) | ![Alarm journal](screenshots/journal.webp) | +| **Meter detail** | **Trends** | +| ![Meter detail](screenshots/meters.webp) | ![Trends](screenshots/trending.webp) | -> Start the backend and frontend locally using the instructions below. The dashboard will populate with live simulated data within 5 seconds of startup. +One-line diagram on a phone --- -## Architecture +## Simulated equipment + +| ID | Equipment | Rating | +|---|---|---| +| UTIL-1 | Utility service, 480 V | 1600 A | +| GEN-1 | Standby diesel generator | 750 kW | +| ATS-1 | Automatic transfer switch, open transition | 1600 A | +| MSB-1 | Main switchboard, 480 V | 1600 A | +| UPS-A, UPS-B | Double conversion UPS, A and B side | 400 kVA each | +| PDU-A1, PDU-A2, PDU-B1, PDU-B2 | PDU, 480 to 208/120 V | 150 kVA each | +| MECH-1 | Mechanical panel (CRAH units, pumps, fans) | 400 A | -### Backend -- **FastAPI** serves REST endpoints and a WebSocket broadcast channel. -- **SQLite** (WAL mode) stores meter readings and alarm records. -- **Simulator** generates realistic 3-phase power readings using sine-wave load profiles, Gaussian noise, and periodic fault injection (voltage sag, current spike, PF drop, frequency deviation). -- **Alarm Engine** evaluates every reading against ISA 18.2-aligned thresholds and writes prioritized alarm records to the database. -- **Collector** runs an async polling loop every 5 seconds, inserting readings and broadcasting to all connected WebSocket clients. +Total load is about 450 kW with roughly 290 kW of IT load (PUE around 1.6). IT load follows a gentle daily profile, cooling load follows a simulated outdoor temperature, and every value drifts with slow random walks plus measurement noise. -### Frontend -- **React 18** single-page application built with **Vite**. -- **Recharts** for trend charts and sparklines. -- **Lucide React** for iconography. -- **React Router v6** for client-side navigation. -- WebSocket hook with automatic reconnection delivers sub-5-second live updates to all views. +Demo scenarios: -### Data Flow +| Scenario | What happens | +|---|---| +| Utility outage | UPS on battery, generator start, ATS transfer, generator frequency dip, cooling restart, retransfer after 30 s of stable utility, generator cooldown | +| Utility voltage sag | Utility drops to about 85 percent for a few seconds, UPS systems ride through on battery | +| PDU overload | One PDU ramps past 80 and 90 percent load, which can also push its UPS past the 2N limit, then rebalances | +| Low power factor | The cooling plant capacitor bank drops out and power factor falls below 0.85 | + +--- + +## Architecture ``` -Simulator - | - v (every 5s) -Collector Loop - |---> SQLite (readings table) - |---> Alarm Engine ---> SQLite (alarms table) - |---> WebSocket Broadcast - | - v - React Frontend - (readings state) - | - Dashboard / One-Line / Meters / Alarms / Trending + Simulator or Modbus TCP meters + | + v every 2 s + Collector scan loop ------------> SQLite (history every 10 s, alarm journal) + | + +--> Alarm manager (ISA 18.2 state machine) + | + v + WebSocket /ws/live ------------> React front end + REST /api/* <------------ (acknowledge, shelve, history, demo events) ``` ---- +- **Backend:** FastAPI. A single collector owns the scan loop, the latest readings, the alarm manager, and the set of WebSocket clients. On startup in simulation mode it generates 24 hours of history (with past events and simulated operator acknowledgements) in well under a second, so trends and the journal are never empty. +- **WebSocket feed:** on connect the server sends a `snapshot` (meters, readings, alarms, summary, demo info). After that it pushes `readings` every scan and `alarms` whenever the alarm list changes or an operator acts. Clients can send `{"type": "ping"}` and get a `pong`. +- **Front end:** React and Vite with hash based routes (`/#/alarms`), so any page can be refreshed or shared on a static host with no rewrite rules. The live feed is a single WebSocket with automatic reconnection and a stale connection watchdog. Chart heavy pages are loaded on demand, so the first load is about 72 kB of gzipped JavaScript. +- **Cold starts:** the static front end never sleeps. While the API wakes up it shows a progress screen and pings `/api/health` (which is also what wakes the server), then switches to the live view as soon as the first snapshot arrives. If the connection drops later, the page keeps the last data, dims it, and shows "Reconnecting". -## Tech Stack +### Stack -| Layer | Technology | Version | -|-------------|-----------------------------------|-----------| -| API Server | FastAPI | 0.111.0 | -| ASGI Runner | Uvicorn (with standard extras) | 0.29.0 | -| Database | SQLite (via stdlib sqlite3) | built-in | -| Frontend | React | 18.3.1 | -| Build Tool | Vite | 5.2.0 | -| Charts | Recharts | 2.12.7 | -| Icons | Lucide React | 0.379.0 | -| Routing | React Router DOM | 6.23.1 | +| Layer | Technology | +|---|---| +| API and WebSocket | Python 3.13, FastAPI 0.111, Uvicorn | +| Storage | SQLite (WAL mode) | +| Field protocol | Modbus TCP via pymodbus (optional) | +| Front end | React 18, Vite 5, React Router 6 | +| Charts and icons | Recharts (trend pages), inline SVG, Lucide | +| Tests | pytest, FastAPI TestClient, Vitest | +| Hosting | Render (static site plus web service), GitHub Actions CI | --- -## Installation +## Run it locally + +Requirements: Python 3.11 or newer and Node 18 or newer. -### Backend +**Backend** (terminal 1): ```bash cd backend +python -m venv .venv +source .venv/bin/activate # Windows: .venv\Scripts\activate pip install -r requirements.txt uvicorn main:app --reload --port 8000 ``` -The API will be available at `http://localhost:8000`. Interactive API docs are at `http://localhost:8000/docs`. +API docs are at http://localhost:8000/docs. -### Frontend +**Front end** (terminal 2): ```bash cd frontend @@ -107,60 +148,82 @@ npm install npm run dev ``` -The dashboard will be available at `http://localhost:5173`. +Open http://localhost:5173. The Vite dev server proxies `/api` and `/ws` to the backend, so no configuration is needed. ---- +**Tests:** -## Environment Variables +```bash +cd backend && pip install -r requirements-dev.txt && python -m pytest -q +cd frontend && npm test +``` -### Frontend +The backend suite covers the simulator (physical consistency, the outage sequence, every scenario), the alarm state machine (every ISA 18.2 transition, deadband, delays, shelving, suppression, KPIs), the WebSocket feed (snapshot, streaming, ping, live alarm updates after operator actions), the REST API, and Modbus decoding. The front end suite covers the feed reducer and URL handling. -| Variable | Description | Default | -|----------|-------------|---------| -| `VITE_API_URL` | Base URL of the backend API (used for HTTP requests and WebSocket connections) | `http://localhost:8000` | +--- -Create a `frontend/.env` file (or copy `frontend/.env.example`) and set `VITE_API_URL` to your deployed backend URL before running `npm run build`. For example, when deploying to Render: +## Configuration -``` -VITE_API_URL=https://power-dashboard-api.onrender.com -``` +Backend settings are environment variables (see `backend/config.py`): -The WebSocket URL is derived automatically from `VITE_API_URL` by replacing `http://` with `ws://` (or `https://` with `wss://`). +| Variable | Default | Purpose | +|---|---|---| +| `DATA_SOURCE` | `simulator` | `simulator` for the demo, `modbus` for real meters | +| `MODBUS_CONFIG` | `modbus_config.json` | Device and register map when `DATA_SOURCE=modbus` | +| `POLL_INTERVAL` | `2` | Scan interval in seconds | +| `HISTORY_INTERVAL` | `10` | Seconds between stored history rows | +| `RETENTION_DAYS` | `7` | Days of history and journal kept | +| `BACKFILL_HOURS` | `24` | Simulated history generated at startup | +| `SIM_EVENT_MIN`, `SIM_EVENT_MAX` | `150`, `360` | Seconds between automatic demo events | +| `SIM_SEED` | random | Fixed seed for repeatable simulation | +| `DEMO_EVENTS_ENABLED` | `true` | Allow visitors to trigger events from the UI | +| `CORS_ORIGINS` | `*` | Comma separated allowed origins | +| `DB_PATH` | `power_dashboard.db` | SQLite file | ---- +Front end: `VITE_API_URL` is the API base URL used at build time. It is optional. A production build without it points at the live demo API, `https://power-dashboard-api.onrender.com`, and the dev server without it proxies to the local backend. +### Real hardware (Modbus TCP) -## Features +```bash +cd backend +pip install -r requirements-modbus.txt +cp modbus_config.example.json modbus_config.json # edit hosts, unit ids, registers +DATA_SOURCE=modbus uvicorn main:app --port 8000 +``` -1. **Live WebSocket feed** - All meter readings streamed to the browser with under 5-second latency, with automatic reconnection on disconnect. -2. **8-meter hierarchy simulation** - Utility Feed, Main Switchgear, two distribution panels (IT and Cooling), and four sub-meters (Server Row A1/A2, CRAC Unit 1/2). -3. **Realistic simulator** - Sine-wave load profiles, Gaussian noise, gradual drift, and stochastic fault injection producing voltage sags, current spikes, power factor drops, and frequency deviations. -4. **ISA 18.2 alarm engine** - Four-parameter alarm evaluation (voltage, current, power factor, frequency) with four priority levels (Critical, High, Medium, Low) and full acknowledgement workflow. -5. **One-line SVG diagram** - Interactive electrical distribution single-line diagram with live status color coding and per-node detail panel. -6. **Per-meter analog gauges** - Arc gauges for voltage, current, power factor, and frequency with threshold-aware color states. -7. **Historical trending** - Multi-meter, multi-parameter trend chart with 1h/24h/7d period selection and CSV export. -8. **Alarm management table** - Active and history tabs with inline acknowledgement and ISA 18.2 priority row styling. -9. **Automatic data pruning** - Readings older than 7 days are removed hourly; alarm history is retained indefinitely. +Each device entry maps a meter ID to a host, unit ID, and the register for each value (holding, input, coil, or discrete; `float32`, `int16`, `uint16`, `int32`, `uint32`, or `bool`; optional scale and word order). A meter that stops answering is shown as "Comm Fail" and its alarms hold their last state until communication returns. The alarm engine, history, and UI work the same as in the demo. --- -## Configuration +## Deployment (Render) + +`render.yaml` is a Render Blueprint with two services that deploy from `main` on every merge: -All tunable parameters are in `backend/config.py`: +- **power-dashboard-api**: Python web service in `backend/`, started with `uvicorn main:app --host 0.0.0.0 --port $PORT`, health checked at `/api/health`, running the simulator. +- **power-dashboard**: static site built from `frontend/` with `npm ci && npm run build`, published from `dist`. No dashboard settings are required: hash routes mean refreshes never 404, and the build falls back to the live API URL when `VITE_API_URL` is not set. -- `POLL_INTERVAL` - Simulator polling rate in seconds (default: 5). -- `WS_INTERVAL` - WebSocket broadcast interval in seconds (default: 2). -- `DB_PATH` - SQLite database file path (default: `power_dashboard.db`). -- `METERS` - Full meter hierarchy definition including nominal voltage, nominal current, CT ratio, and PT ratio. -- `ALARM_THRESHOLDS` - Per-parameter alarm thresholds expressed as multipliers of nominal values or absolute values for frequency. +Build filters mean a change under `backend/` only redeploys the API, a change under `frontend/` only redeploys the site, and documentation changes redeploy nothing. GitHub Actions runs both test suites and the production build on every pull request and push to `main`. --- -## Future Enhancements +## Project layout + +``` +backend/ + main.py FastAPI app, REST endpoints, WebSocket + collector.py Scan loop, backfill, broadcast + simulator.py Data center power system model and scenarios + alarms.py ISA 18.2 alarm state machine + config.py Settings, topology, master alarm database + database.py SQLite history and alarm journal + modbus_source.py Modbus TCP polling (optional) + tests/ +frontend/ + src/services/ WebSocket feed, REST client + src/pages/ Overview, One-Line, Meters, Alarms, Trends, System + src/components/ Wake screen, About panel, alarm banner, badges, charts +render.yaml Render Blueprint +``` + +## License -- **Real Modbus polling** - Replace the simulator with `pymodbus` to connect to physical Modbus TCP/RTU meters. -- **BACnet integration** - Add BACnet/IP support for building automation system interoperability. -- **Cloud deployment** - Containerize with Docker and deploy to cloud infrastructure with PostgreSQL replacing SQLite. -- **ML predictive maintenance** - Apply time-series anomaly detection to identify degrading equipment before alarm thresholds are reached. -- **User authentication** - Role-based access control for operator, engineer, and administrator tiers. -- **Email/SMS notifications** - Push alarm notifications via SMTP or SMS gateway on critical priority events. +MIT, see [LICENSE](LICENSE). diff --git a/backend/alarm_engine.py b/backend/alarm_engine.py deleted file mode 100644 index 0ba70b3..0000000 --- a/backend/alarm_engine.py +++ /dev/null @@ -1,73 +0,0 @@ -"""Alarm processing engine aligned with ISA 18.2 principles.""" - -from typing import Dict, Any -from config import ALARM_THRESHOLDS, METERS -from database import upsert_alarm, clear_alarm - -# Build nominal lookup from config -_NOMINALS = {m["id"]: m for m in METERS} - - -def _get_nominal(meter_id: int, key: str) -> float: - return _NOMINALS[meter_id][key] - - -def process_reading(reading: Dict[str, Any]): - """Evaluate a single reading against alarm thresholds.""" - mid = reading["meter_id"] - nom_v = _get_nominal(mid, "nominal_voltage") - nom_i = _get_nominal(mid, "nominal_current") - - v = reading["voltage"] - i = reading["current"] - pf = reading["power_factor"] - freq = reading["frequency"] - - t = ALARM_THRESHOLDS - - # --- Voltage alarms --- - if v > nom_v * t["voltage_high_critical"]: - upsert_alarm(mid, "voltage", "High Voltage Critical", - v, nom_v * t["voltage_high_critical"], priority=1) - elif v > nom_v * t["voltage_high_warning"]: - upsert_alarm(mid, "voltage", "High Voltage Warning", - v, nom_v * t["voltage_high_warning"], priority=2) - clear_alarm(mid, "voltage") # clear critical if only warning now - elif v < nom_v * t["voltage_low_critical"]: - upsert_alarm(mid, "voltage", "Low Voltage Critical", - v, nom_v * t["voltage_low_critical"], priority=1) - elif v < nom_v * t["voltage_low_warning"]: - upsert_alarm(mid, "voltage", "Low Voltage Warning", - v, nom_v * t["voltage_low_warning"], priority=2) - else: - clear_alarm(mid, "voltage") - - # --- Current alarms --- - if i > nom_i * t["current_high_critical"]: - upsert_alarm(mid, "current", "Overcurrent Critical", - i, nom_i * t["current_high_critical"], priority=1) - elif i > nom_i * t["current_high_warning"]: - upsert_alarm(mid, "current", "High Current Warning", - i, nom_i * t["current_high_warning"], priority=2) - else: - clear_alarm(mid, "current") - - # --- Power factor alarms --- - if pf < t["pf_low_critical"]: - upsert_alarm(mid, "power_factor", "Low Power Factor Critical", - pf, t["pf_low_critical"], priority=1) - elif pf < t["pf_low_warning"]: - upsert_alarm(mid, "power_factor", "Low Power Factor Warning", - pf, t["pf_low_warning"], priority=3) - else: - clear_alarm(mid, "power_factor") - - # --- Frequency alarms --- - if freq > t["freq_high_critical"] or freq < t["freq_low_critical"]: - upsert_alarm(mid, "frequency", "Frequency Deviation Critical", - freq, 60.0, priority=1) - elif freq > t["freq_high_warning"] or freq < t["freq_low_warning"]: - upsert_alarm(mid, "frequency", "Frequency Deviation Warning", - freq, 60.0, priority=3) - else: - clear_alarm(mid, "frequency") diff --git a/backend/alarms.py b/backend/alarms.py new file mode 100644 index 0000000..2ca21bc --- /dev/null +++ b/backend/alarms.py @@ -0,0 +1,423 @@ +"""Alarm management aligned with ANSI/ISA-18.2. + +Each configured alarm point runs the ISA 18.2 alarm state model: + + A NORMAL process normal, nothing to acknowledge + B UNACK process abnormal, alarm not yet acknowledged + C ACKED process abnormal, alarm acknowledged by the operator + D RTN_UNACK process returned to normal before anyone acknowledged it + F SHELVED temporarily removed from service by the operator (time limited) + G SUPPRESSED suppressed by design, based on plant state + +Transitions: + + NORMAL --condition--> UNACK + UNACK --acknowledge--> ACKED + UNACK --clears--> RTN_UNACK + ACKED --clears--> NORMAL + RTN_UNACK --acknowledge--> NORMAL + RTN_UNACK --condition--> UNACK (re-alarm) + any of A to D --shelve--> SHELVED --unshelve or timer--> UNACK or NORMAL + any of A to D --design rule--> SUPPRESSED --rule clears--> UNACK or NORMAL + +Conditions use a deadband (hysteresis) and on/off delays so a noisy value +near the setpoint does not chatter. Every transition is written to the +alarm journal through registered listeners. + +This module is pure Python with no I/O so it can be tested in isolation. +""" + +import threading +from collections import deque +from datetime import datetime, timezone +from enum import Enum +from typing import Any, Callable, Dict, Iterable, List, Optional + +from config import ALARM_PRIORITIES, SHELVE_MAX_MIN + + +class AlarmState(str, Enum): + NORMAL = "NORMAL" + UNACK = "UNACK" + ACKED = "ACKED" + RTN_UNACK = "RTN_UNACK" + SHELVED = "SHELVED" + SUPPRESSED = "SUPPRESSED" + + +STATE_LABELS = { + AlarmState.NORMAL: "Normal", + AlarmState.UNACK: "Unacknowledged", + AlarmState.ACKED: "Acknowledged", + AlarmState.RTN_UNACK: "Returned to normal, unacknowledged", + AlarmState.SHELVED: "Shelved", + AlarmState.SUPPRESSED: "Suppressed by design", +} + +# States shown on the operator's active alarm list +ANNUNCIATED = (AlarmState.UNACK, AlarmState.ACKED, AlarmState.RTN_UNACK) +# States that still need an acknowledgement +NEEDS_ACK = (AlarmState.UNACK, AlarmState.RTN_UNACK) + +# Journal event types +EV_ALARM = "ALARM" +EV_ACK = "ACK" +EV_RTN = "RTN" +EV_SHELVE = "SHELVE" +EV_UNSHELVE = "UNSHELVE" +EV_SUPPRESS = "SUPPRESS" +EV_UNSUPPRESS = "UNSUPPRESS" + +# ISA 18.2 performance targets for annunciated alarms per operator per 10 minutes +RATE_ACCEPTABLE = 1 +RATE_MANAGEABLE = 2 +RATE_FLOOD = 10 +RATE_WINDOW_S = 600 + + +class AlarmActionError(Exception): + """An operator action is not valid in the alarm's current state.""" + + +def _iso(epoch: float) -> str: + return datetime.fromtimestamp(epoch, timezone.utc).isoformat(timespec="milliseconds") + + +class AlarmPoint: + """Runtime state for one configured alarm.""" + + def __init__(self, definition: Dict[str, Any], meter_name: str): + self.d = definition + self.tag: str = definition["tag"] + self.meter_name = meter_name + self.state = AlarmState.NORMAL + self.condition = False + self._on_count = 0 + self._off_count = 0 + self.value: Optional[float] = None + self.alarm_value: Optional[float] = None + self.activated_at: Optional[float] = None + self.ack_at: Optional[float] = None + self.ack_by: Optional[str] = None + self.rtn_at: Optional[float] = None + self.shelved_at: Optional[float] = None + self.shelved_until: Optional[float] = None + self.shelved_by: Optional[str] = None + self.shelve_reason: Optional[str] = None + self.suppress_reason: Optional[str] = None + self.evaluated = False + + # Condition with deadband and debounce ----------------------------------- + + def _raw(self, value) -> bool: + kind, sp, db = self.d["kind"], self.d["setpoint"], self.d.get("deadband", 0.0) + if kind == "DISCRETE": + return bool(value) == bool(sp) + if kind in ("HI", "HIHI"): + return value > (sp - db if self.condition else sp) + if kind in ("LO", "LOLO"): + return value < (sp + db if self.condition else sp) + raise ValueError(f"Unknown alarm kind {kind}") + + def in_alarm_now(self) -> bool: + """Current value against the setpoint, ignoring deadband and delays.""" + if self.value is None: + return False + was, self.condition = self.condition, False + try: + return self._raw(self.value) + finally: + self.condition = was + + def update_condition(self, value) -> bool: + """Feed a new value. Returns the debounced condition.""" + self.value = value + raw = self._raw(value) + if raw: + self._off_count = 0 + if not self.condition: + self._on_count += 1 + if self._on_count > self.d.get("on_delay", 0): + self.condition = True + self._on_count = 0 + else: + self._on_count = 0 + if self.condition: + self._off_count += 1 + if self._off_count > self.d.get("off_delay", 0): + self.condition = False + self._off_count = 0 + return self.condition + + def to_dict(self) -> Dict[str, Any]: + d = self.d + pr = d["priority"] + return { + "tag": self.tag, + "meter_id": d["meter_id"], + "meter_name": self.meter_name, + "message": d["message"], + "kind": d["kind"], + "parameter": d["parameter"], + "priority": pr, + "priority_label": ALARM_PRIORITIES[pr]["label"], + "state": self.state.value, + "state_label": STATE_LABELS[self.state], + "condition_active": self.condition, + "value": _num(self.value), + "alarm_value": _num(self.alarm_value), + "setpoint": d["setpoint"], + "unit": d.get("unit", ""), + "activated_at": _iso(self.activated_at) if self.activated_at else None, + "ack_at": _iso(self.ack_at) if self.ack_at else None, + "ack_by": self.ack_by, + "rtn_at": _iso(self.rtn_at) if self.rtn_at else None, + "shelved_at": _iso(self.shelved_at) if self.shelved_at else None, + "shelved_until": _iso(self.shelved_until) if self.shelved_until else None, + "shelved_by": self.shelved_by, + "shelve_reason": self.shelve_reason, + "suppress_reason": self.suppress_reason, + "consequence": d.get("consequence"), + "response": d.get("response"), + } + + +def _num(v): + if isinstance(v, bool): + return int(v) + return v + + +class AlarmManager: + """Evaluates alarm points against readings and handles operator actions.""" + + def __init__(self, definitions: Iterable[Dict[str, Any]], meter_names: Dict[str, str]): + self.points: Dict[str, AlarmPoint] = { + d["tag"]: AlarmPoint(d, meter_names.get(d["meter_id"], d["meter_id"])) + for d in definitions + } + self._listeners: List[Callable[[Dict[str, Any]], None]] = [] + self._recent_alarms: deque = deque() + self._lock = threading.RLock() + + # Listeners -------------------------------------------------------------- + + def add_listener(self, fn: Callable[[Dict[str, Any]], None]): + self._listeners.append(fn) + + def _emit(self, p: AlarmPoint, event: str, now: float, operator: str = "system", + note: Optional[str] = None): + if event == EV_ALARM: + self._recent_alarms.append(now) + record = { + "epoch": now, + "timestamp": _iso(now), + "tag": p.tag, + "meter_id": p.d["meter_id"], + "meter_name": p.meter_name, + "event": event, + "state": p.state.value, + "priority": p.d["priority"], + "value": _num(p.value), + "setpoint": p.d["setpoint"], + "unit": p.d.get("unit", ""), + "message": p.d["message"], + "operator": operator, + "note": note, + } + for fn in self._listeners: + fn(record) + + # Scan evaluation -------------------------------------------------------- + + def evaluate(self, readings: Dict[str, Dict[str, Any]], now: float) -> bool: + """Evaluate every alarm point against one scan. Returns True if anything changed.""" + changed = False + with self._lock: + for p in self.points.values(): + before = p.state + self._evaluate_point(p, readings, now) + changed = changed or p.state != before + return changed + + def _evaluate_point(self, p: AlarmPoint, readings, now: float): + reading = readings.get(p.d["meter_id"]) + value = reading.get(p.d["parameter"]) if reading else None + if value is None: + return # bad or missing data: hold the last state + condition = p.update_condition(value) + + # Time limited shelving + if p.state == AlarmState.SHELVED: + if p.shelved_until is not None and now >= p.shelved_until: + self._unshelve(p, now, "system", "Shelve time expired") + return + + # Designed (state based) suppression + rule = p.d.get("suppress") + suppressed = False + if rule: + src = readings.get(rule["meter_id"]) + if src is not None and src.get(rule["parameter"]) is not None: + suppressed = bool(src.get(rule["parameter"])) == bool(rule["equals"]) + else: + suppressed = p.state == AlarmState.SUPPRESSED + first_scan = not p.evaluated + p.evaluated = True + if suppressed and p.state != AlarmState.SUPPRESSED: + p.state = AlarmState.SUPPRESSED + p.suppress_reason = rule.get("reason") + if not first_scan: # starting up suppressed is not an event + self._emit(p, EV_SUPPRESS, now, note=p.suppress_reason) + return + if p.state == AlarmState.SUPPRESSED: + if suppressed: + return + p.suppress_reason = None + self._return_to_service(p, now, EV_UNSUPPRESS, "system", "Suppression condition cleared") + return + + # Core ISA 18.2 state model + if p.state == AlarmState.NORMAL and condition: + self._annunciate(p, now) + elif p.state == AlarmState.RTN_UNACK and condition: + self._annunciate(p, now) + elif p.state == AlarmState.UNACK and not condition: + p.state = AlarmState.RTN_UNACK + p.rtn_at = now + self._emit(p, EV_RTN, now) + elif p.state == AlarmState.ACKED and not condition: + p.state = AlarmState.NORMAL + p.rtn_at = now + self._emit(p, EV_RTN, now) + + def _annunciate(self, p: AlarmPoint, now: float): + p.state = AlarmState.UNACK + p.activated_at = now + p.alarm_value = p.value + p.ack_at = p.ack_by = p.rtn_at = None + self._emit(p, EV_ALARM, now) + + def _return_to_service(self, p: AlarmPoint, now: float, event: str, operator: str, note: str): + # Re-evaluate from the live value so a stale debounced condition from + # while the alarm was out of service does not annunciate. + p.condition = p.in_alarm_now() + p.state = AlarmState.NORMAL + self._emit(p, event, now, operator, note) + if p.condition: + self._annunciate(p, now) + + # Operator actions ------------------------------------------------------- + + def _get(self, tag: str) -> AlarmPoint: + if tag not in self.points: + raise KeyError(tag) + return self.points[tag] + + def acknowledge(self, tag: str, now: float, operator: str = "operator") -> Dict[str, Any]: + with self._lock: + p = self._get(tag) + if p.state == AlarmState.UNACK: + p.state = AlarmState.ACKED + elif p.state == AlarmState.RTN_UNACK: + p.state = AlarmState.NORMAL + else: + raise AlarmActionError(f"{tag} is {STATE_LABELS[p.state].lower()}, nothing to acknowledge") + p.ack_at, p.ack_by = now, operator + self._emit(p, EV_ACK, now, operator) + return p.to_dict() + + def shelve(self, tag: str, minutes: float, now: float, operator: str = "operator", + reason: Optional[str] = None) -> Dict[str, Any]: + with self._lock: + p = self._get(tag) + if p.state in (AlarmState.SHELVED, AlarmState.SUPPRESSED): + raise AlarmActionError(f"{tag} is {STATE_LABELS[p.state].lower()}") + if not 0 < minutes <= SHELVE_MAX_MIN: + raise AlarmActionError(f"Shelve time must be between 1 and {SHELVE_MAX_MIN} minutes") + p.state = AlarmState.SHELVED + p.shelved_at = now + p.shelved_until = now + minutes * 60 + p.shelved_by = operator + p.shelve_reason = (reason or "").strip() or "Shelved by operator" + self._emit(p, EV_SHELVE, now, operator, f"{p.shelve_reason} ({minutes:g} min)") + return p.to_dict() + + def unshelve(self, tag: str, now: float, operator: str = "operator") -> Dict[str, Any]: + with self._lock: + p = self._get(tag) + if p.state != AlarmState.SHELVED: + raise AlarmActionError(f"{tag} is not shelved") + self._unshelve(p, now, operator, "Unshelved by operator") + return p.to_dict() + + def _unshelve(self, p: AlarmPoint, now: float, operator: str, note: str): + p.shelved_at = p.shelved_until = p.shelved_by = p.shelve_reason = None + self._return_to_service(p, now, EV_UNSHELVE, operator, note) + + # Views ------------------------------------------------------------------ + + def alarm_list(self) -> List[Dict[str, Any]]: + """Every alarm point that is not NORMAL, highest priority and newest first.""" + with self._lock: + items = [p.to_dict() for p in self.points.values() if p.state != AlarmState.NORMAL] + items.sort(key=lambda a: (a["priority"], -(_ts(a["activated_at"])))) + return items + + def definitions(self) -> List[Dict[str, Any]]: + out = [] + for p in self.points.values(): + d = {k: v for k, v in p.d.items() if k != "suppress"} + d["meter_name"] = p.meter_name + d["priority_label"] = ALARM_PRIORITIES[d["priority"]]["label"] + d["suppression"] = p.d["suppress"]["reason"] if p.d.get("suppress") else None + out.append(d) + return out + + def alarm_rate(self, now: float) -> int: + with self._lock: + while self._recent_alarms and self._recent_alarms[0] < now - RATE_WINDOW_S: + self._recent_alarms.popleft() + return len(self._recent_alarms) + + def summary(self, now: float) -> Dict[str, Any]: + with self._lock: + states = [p.state for p in self.points.values()] + annunciated = [p for p in self.points.values() if p.state in ANNUNCIATED] + unacked = [p for p in annunciated if p.state in NEEDS_ACK] + rate = self.alarm_rate(now) + top = min(unacked, key=lambda p: (p.d["priority"], -(p.activated_at or 0)), default=None) + + if rate >= RATE_FLOOD: + rate_status = "Flood" + elif rate > RATE_MANAGEABLE: + rate_status = "Above target" + elif rate > RATE_ACCEPTABLE: + rate_status = "Manageable" + else: + rate_status = "Acceptable" + + by_priority = {str(k): 0 for k in ALARM_PRIORITIES} + for p in annunciated: + by_priority[str(p.d["priority"])] += 1 + + return { + "annunciated": len(annunciated), + "in_alarm": states.count(AlarmState.UNACK) + states.count(AlarmState.ACKED), + "unacknowledged": len(unacked), + "acknowledged": states.count(AlarmState.ACKED), + "rtn_unack": states.count(AlarmState.RTN_UNACK), + "shelved": states.count(AlarmState.SHELVED), + "suppressed": states.count(AlarmState.SUPPRESSED), + "by_priority": by_priority, + "alarm_rate_10min": rate, + "rate_status": rate_status, + "highest_unacked": top.to_dict() if top else None, + "configured": len(states), + } + + +def _ts(iso: Optional[str]) -> float: + if not iso: + return 0.0 + return datetime.fromisoformat(iso).timestamp() diff --git a/backend/collector.py b/backend/collector.py index 7469420..2e95fc4 100644 --- a/backend/collector.py +++ b/backend/collector.py @@ -1,68 +1,265 @@ -"""Background data collection service.""" +"""Scan loop: read the data source, evaluate alarms, store history, push to clients.""" import asyncio import json import logging -from datetime import datetime, timezone -from typing import Set +import random +import time +from typing import Any, Callable, Dict, List, Optional, Set -from simulator import DeviceSimulator -from database import insert_reading, prune_old_readings -from alarm_engine import process_reading -from config import POLL_INTERVAL +import config +import database +from alarms import ANNUNCIATED, NEEDS_ACK, AlarmManager, AlarmState +from simulator import SCENARIOS, FacilitySimulator logger = logging.getLogger(__name__) -_simulator = DeviceSimulator() -_latest_readings: dict = {} -_websocket_clients: Set = set() +BACKFILL_STEP_S = 60.0 +BACKFILL_EVENT_STEP_S = 5.0 # finer steps while an event plays out +SIM_OPERATOR = "operator (simulated)" -def get_latest_readings_cache() -> dict: - return _latest_readings +class Collector: + """Owns the live state of the system and the set of WebSocket clients.""" + def __init__(self, source: str = None, clock: Callable[[], float] = time.time): + self.source = (source or config.DATA_SOURCE) + self.clock = clock + self.started_at = clock() + self.meter_names = {m["id"]: m["name"] for m in config.METERS} + self.alarms = AlarmManager(config.ALARM_POINTS, self.meter_names) + self.alarms.add_listener(database.insert_alarm_event) + self.latest: Dict[str, Dict[str, Any]] = {} + self.clients: Set[Any] = set() + self.ready = False + self.scan_count = 0 + self._last_history = 0.0 + self._last_prune = 0.0 + self._task: Optional[asyncio.Task] = None -def register_ws_client(ws): - _websocket_clients.add(ws) + self.sim: Optional[FacilitySimulator] = None + self.modbus = None + if self.source == "modbus": + from modbus_source import ModbusSource, load_config + self.modbus = ModbusSource(load_config(config.MODBUS_CONFIG)) + else: + self.source = "simulator" + seed = int(config.SIM_SEED) if config.SIM_SEED else None + self.sim = FacilitySimulator( + seed=seed, + event_interval=(config.SIM_EVENT_MIN, config.SIM_EVENT_MAX), + first_event_delay=config.SIM_FIRST_EVENT, + ) + @property + def simulated(self) -> bool: + return self.sim is not None -def unregister_ws_client(ws): - _websocket_clients.discard(ws) + # ------------------------------------------------------------------ + # Startup + # ------------------------------------------------------------------ + def initialize(self): + """Prepare storage. A simulated demo starts fresh with backfilled history.""" + database.init_db(reset=self.simulated) + if self.simulated and config.BACKFILL_HOURS > 0: + t0 = time.perf_counter() + self.backfill(config.BACKFILL_HOURS, self.clock()) + logger.info("Backfilled %.0f h of simulated history in %.2f s", + config.BACKFILL_HOURS, time.perf_counter() - t0) -async def broadcast_readings(): - """Push latest readings to all connected WebSocket clients.""" - if not _websocket_clients or not _latest_readings: - return - payload = json.dumps({"type": "readings", "data": list(_latest_readings.values())}) - dead = set() - for ws in _websocket_clients: + def backfill(self, hours: float, now: float): + """Run the simulator over the past few hours so trends and history are populated. + + Events are spaced out the way a real site sees them, and a simulated + operator acknowledges alarms shortly after they annunciate. + """ + sim = self.sim + live_interval, live_first = sim.event_interval, sim.first_event_delay + sim.event_interval, sim.first_event_delay = (1800.0, 5400.0), 600.0 + rng = random.Random(sim.rng.random()) + + events: List[Dict[str, Any]] = [] + mgr = AlarmManager(config.ALARM_POINTS, self.meter_names) + mgr.add_listener(events.append) + rows: List[Dict[str, Any]] = [] + + t = now - hours * 3600 + quiet_from = now - 900 # no new events in the last 15 minutes + while t < now: + sim.auto_events = t < quiet_from + readings = sim.step(t) + rows.extend(readings.values()) + mgr.evaluate(readings, t) + for p in mgr.points.values(): + if p.state in NEEDS_ACK and p.activated_at is not None \ + and t - p.activated_at >= rng.uniform(30, 150): + mgr.acknowledge(p.tag, t, SIM_OPERATOR) + t += BACKFILL_EVENT_STEP_S if sim.scenario else BACKFILL_STEP_S + + database.insert_readings(rows) + database.insert_alarm_events(events) + sim.auto_events = True + sim.event_interval, sim.first_event_delay = live_interval, live_first + sim.schedule_event_at(now + live_first) # first live event soon after startup + self._last_history = now + + async def start(self): + self.initialize() + await self.scan_once() + self.ready = True + self._task = asyncio.create_task(self._run()) + + async def stop(self): + if self._task: + self._task.cancel() + try: + await self._task + except asyncio.CancelledError: + pass + if self.modbus: + self.modbus.close() + + # ------------------------------------------------------------------ + # Scan loop + # ------------------------------------------------------------------ + + async def _run(self): + while True: + started = self.clock() + await asyncio.sleep(max(0.05, config.POLL_INTERVAL - (self.clock() - started))) + try: + await self.scan_once() + except Exception: + logger.exception("Scan failed") + + async def scan_once(self, now: Optional[float] = None) -> Dict[str, Dict[str, Any]]: + now = self.clock() if now is None else now + if self.sim: + readings = self.sim.step(now) + else: + readings = await self.modbus.read_all(now) + self.latest.update(readings) + self.scan_count += 1 + + alarms_changed = self.alarms.evaluate(readings, now) + + if now - self._last_history >= config.HISTORY_INTERVAL: + database.insert_readings(r for r in readings.values() if r.get("voltage") is not None) + self._last_history = now + if now - self._last_prune >= 3600: + database.prune(config.RETENTION_DAYS, now) + self._last_prune = now + + await self.broadcast({"type": "readings", "data": { + "readings": list(readings.values()), + "system": self.system_status(now), + }}) + if alarms_changed or self.scan_count % 5 == 0: + await self.broadcast_alarms(now) + return readings + + # ------------------------------------------------------------------ + # Views + # ------------------------------------------------------------------ + + def alarm_payload(self, now: Optional[float] = None) -> Dict[str, Any]: + now = self.clock() if now is None else now + return {"alarms": self.alarms.alarm_list(), "summary": self.alarms.summary(now)} + + def system_status(self, now: Optional[float] = None) -> Dict[str, Any]: + now = self.clock() if now is None else now + r = self.latest + facility_kw = r.get("MSB-1", {}).get("power_kw") or 0.0 + it_kw = sum((r.get(m["id"], {}).get("power_kw") or 0.0) + for m in config.METERS if m["type"] == "pdu") + ups_runtime = [r[u].get("battery_runtime_min") for u in ("UPS-A", "UPS-B") + if u in r and r[u].get("battery_runtime_min") is not None] + + on_battery = any(r.get(u, {}).get("on_battery") for u in ("UPS-A", "UPS-B")) + + in_alarm = [p for p in self.alarms.points.values() + if p.state in (AlarmState.UNACK, AlarmState.ACKED)] + priorities = {p.d["priority"] for p in in_alarm} + if 1 in priorities: + health = "critical" + elif 2 in priorities: + health = "warning" + elif priorities: + health = "caution" + else: + health = "normal" + + online = sum(1 for v in r.values() if v.get("comm_ok", True)) + return { + "health": health, + "data_source": self.source, + "simulated": self.simulated, + "facility_kw": round(facility_kw, 1), + "it_kw": round(it_kw, 1), + "pue": round(facility_kw / it_kw, 2) if facility_kw > 0 and it_kw > 0 and not on_battery else None, + "power_source": r.get("ATS-1", {}).get("position"), + "utility_status": r.get("UTIL-1", {}).get("status"), + "generator_status": r.get("GEN-1", {}).get("status"), + "ups_min_runtime_min": min(ups_runtime) if ups_runtime else None, + "annunciated_alarms": sum(1 for p in self.alarms.points.values() if p.state in ANNUNCIATED), + "meter_count": len(config.METERS), + "meters_online": online, + "scenario": self.sim.scenario_info(now) if self.sim else None, + "server_time": now, + } + + def demo_info(self) -> Dict[str, Any]: + return { + "simulated": self.simulated, + "events_enabled": self.simulated and config.DEMO_EVENTS_ENABLED, + "scenarios": [{"name": k, **v} for k, v in SCENARIOS.items()] if self.simulated else [], + "poll_interval_s": config.POLL_INTERVAL, + "version": config.VERSION, + } + + def snapshot(self) -> Dict[str, Any]: + now = self.clock() + return { + "meters": config.METERS, + "readings": list(self.latest.values()), + "system": self.system_status(now), + "demo": self.demo_info(), + **self.alarm_payload(now), + } + + # ------------------------------------------------------------------ + # Demo events + # ------------------------------------------------------------------ + + def trigger_event(self, name: str) -> Dict[str, Any]: + if not self.simulated or not config.DEMO_EVENTS_ENABLED: + raise PermissionError("Demo events are only available in simulation mode") + now = self.clock() + info = self.sim.trigger(name, now) + return info + + # ------------------------------------------------------------------ + # WebSocket clients + # ------------------------------------------------------------------ + + def register(self, ws): + self.clients.add(ws) + + def unregister(self, ws): + self.clients.discard(ws) + + async def _send(self, ws, text: str): try: - await ws.send_text(payload) + await asyncio.wait_for(ws.send_text(text), timeout=5) except Exception: - dead.add(ws) - for ws in dead: - _websocket_clients.discard(ws) + self.clients.discard(ws) + async def broadcast(self, message: Dict[str, Any]): + if not self.clients: + return + text = json.dumps(message, separators=(",", ":")) + await asyncio.gather(*(self._send(ws, text) for ws in list(self.clients))) -async def collection_loop(): - """Main polling loop: read all meters, store, process alarms, broadcast.""" - prune_counter = 0 - while True: - try: - readings = _simulator.read_all() - for meter_id, reading in readings.items(): - insert_reading(reading) - process_reading(reading) - _latest_readings[meter_id] = reading - - await broadcast_readings() - prune_counter += 1 - if prune_counter >= 720: # every hour at 5s interval - prune_old_readings(days=7) - prune_counter = 0 - - except Exception as exc: - logger.error("Collection loop error: %s", exc) - - await asyncio.sleep(POLL_INTERVAL) + async def broadcast_alarms(self, now: Optional[float] = None): + await self.broadcast({"type": "alarms", "data": self.alarm_payload(now)}) diff --git a/backend/config.py b/backend/config.py index 36cd81e..dbb2b36 100644 --- a/backend/config.py +++ b/backend/config.py @@ -1,126 +1,291 @@ -"""System configuration for Power Dashboard.""" +"""System configuration for Power Dashboard. -# Polling interval in seconds -POLL_INTERVAL = 5 +Runtime settings come from environment variables so the same code runs the +public demo (simulated data) and a real site (Modbus TCP polling). +""" -# Database -DB_PATH = "power_dashboard.db" +import os -# WebSocket broadcast interval (seconds) -WS_INTERVAL = 2 +VERSION = "1.1.0" + + +def _env_bool(name: str, default: bool) -> bool: + raw = os.getenv(name) + if raw is None: + return default + return raw.strip().lower() in ("1", "true", "yes", "on") + + +def _env_float(name: str, default: float) -> float: + raw = os.getenv(name) + return float(raw) if raw not in (None, "") else default + + +# --------------------------------------------------------------------------- +# Runtime settings +# --------------------------------------------------------------------------- + +# "simulator" (default, used by the public demo) or "modbus" +DATA_SOURCE = os.getenv("DATA_SOURCE", "simulator").strip().lower() + +# Path to the Modbus device map (only used when DATA_SOURCE=modbus) +MODBUS_CONFIG = os.getenv("MODBUS_CONFIG", "modbus_config.json") + +# Scan rate for the live feed and alarm evaluation (seconds) +POLL_INTERVAL = _env_float("POLL_INTERVAL", 2.0) + +# How often a scan is written to the history table (seconds) +HISTORY_INTERVAL = _env_float("HISTORY_INTERVAL", 10.0) + +# SQLite database file +DB_PATH = os.getenv("DB_PATH", "power_dashboard.db") + +# Days of readings kept before pruning +RETENTION_DAYS = int(_env_float("RETENTION_DAYS", 7)) + +# Simulator: hours of history generated on startup so trends are never empty +BACKFILL_HOURS = _env_float("BACKFILL_HOURS", 24) + +# Simulator: seconds between automatic demo events (min, max) +SIM_EVENT_MIN = _env_float("SIM_EVENT_MIN", 150) +SIM_EVENT_MAX = _env_float("SIM_EVENT_MAX", 360) + +# Simulator: delay before the first live event after startup (seconds) +SIM_FIRST_EVENT = _env_float("SIM_FIRST_EVENT", 25) + +# Simulator: optional fixed random seed for repeatable runs +SIM_SEED = os.getenv("SIM_SEED") + +# Allow visitors to trigger demo events from the UI (simulator only) +DEMO_EVENTS_ENABLED = _env_bool("DEMO_EVENTS_ENABLED", True) + +# Comma separated list of allowed browser origins, "*" for any +CORS_ORIGINS = [o.strip() for o in os.getenv("CORS_ORIGINS", "*").split(",") if o.strip()] + +# ISA 18.2 shelving is time limited. Durations offered to the operator (minutes) +SHELVE_DURATIONS_MIN = [5, 15, 60] +SHELVE_MAX_MIN = 60 + +# Nominal system frequency +NOMINAL_FREQUENCY = 60.0 + + +# --------------------------------------------------------------------------- +# Facility topology +# +# A small 2N data center: utility service and standby generator feed an +# automatic transfer switch, which feeds the main switchboard. Two UPS +# systems (A side and B side) each feed two PDUs. Mechanical (cooling) load +# is fed directly from the switchboard. +# --------------------------------------------------------------------------- -# Meter hierarchy definition METERS = [ { - "id": 1, - "name": "Utility Feed", - "description": "Main utility supply point", - "type": "utility", - "parent_id": None, - "nominal_voltage": 480.0, - "nominal_current": 800.0, - "ct_ratio": 800, - "pt_ratio": 1, - }, - { - "id": 2, - "name": "Main Switchgear", - "description": "Main distribution switchgear", - "type": "switchgear", - "parent_id": 1, - "nominal_voltage": 480.0, - "nominal_current": 750.0, - "ct_ratio": 750, - "pt_ratio": 1, - }, - { - "id": 3, - "name": "Panel A", - "description": "Distribution Panel A - IT Load", - "type": "panel", - "parent_id": 2, - "nominal_voltage": 480.0, - "nominal_current": 400.0, - "ct_ratio": 400, - "pt_ratio": 1, - }, - { - "id": 4, - "name": "Panel B", - "description": "Distribution Panel B - Cooling Load", - "type": "panel", - "parent_id": 2, - "nominal_voltage": 480.0, - "nominal_current": 350.0, - "ct_ratio": 350, - "pt_ratio": 1, - }, - { - "id": 5, - "name": "Server Row A1", - "description": "Server row PDU - Rack group A1", - "type": "meter", - "parent_id": 3, - "nominal_voltage": 480.0, - "nominal_current": 120.0, - "ct_ratio": 120, - "pt_ratio": 1, - }, - { - "id": 6, - "name": "Server Row A2", - "description": "Server row PDU - Rack group A2", - "type": "meter", - "parent_id": 3, - "nominal_voltage": 480.0, - "nominal_current": 120.0, - "ct_ratio": 120, - "pt_ratio": 1, - }, - { - "id": 7, - "name": "CRAC Unit 1", - "description": "Computer Room Air Conditioner 1", - "type": "meter", - "parent_id": 4, - "nominal_voltage": 480.0, - "nominal_current": 180.0, - "ct_ratio": 180, - "pt_ratio": 1, - }, - { - "id": 8, - "name": "CRAC Unit 2", - "description": "Computer Room Air Conditioner 2", - "type": "meter", - "parent_id": 4, - "nominal_voltage": 480.0, - "nominal_current": 170.0, - "ct_ratio": 170, - "pt_ratio": 1, + "id": "UTIL-1", "name": "Utility Service", "type": "utility", "parent_id": None, + "description": "Utility service entrance, 480 V secondary of the service transformer", + "nominal_voltage": 480.0, "rated_current": 1600.0, "rated_kva": 1330.0, + }, + { + "id": "GEN-1", "name": "Standby Generator", "type": "generator", "parent_id": None, + "description": "750 kW diesel standby generator", + "nominal_voltage": 480.0, "rated_current": 1128.0, "rated_kva": 937.5, + }, + { + "id": "ATS-1", "name": "Automatic Transfer Switch", "type": "ats", "parent_id": None, + "description": "Open transition ATS, utility normal / generator emergency", + "nominal_voltage": 480.0, "rated_current": 1600.0, "rated_kva": 1330.0, + }, + { + "id": "MSB-1", "name": "Main Switchboard", "type": "switchgear", "parent_id": "ATS-1", + "description": "480 V main switchboard", + "nominal_voltage": 480.0, "rated_current": 1600.0, "rated_kva": 1330.0, + }, + { + "id": "UPS-A", "name": "UPS A", "type": "ups", "parent_id": "MSB-1", + "description": "400 kVA double conversion UPS, A side", + "nominal_voltage": 480.0, "rated_current": 481.0, "rated_kva": 400.0, + }, + { + "id": "UPS-B", "name": "UPS B", "type": "ups", "parent_id": "MSB-1", + "description": "400 kVA double conversion UPS, B side", + "nominal_voltage": 480.0, "rated_current": 481.0, "rated_kva": 400.0, + }, + { + "id": "MECH-1", "name": "Mechanical Panel", "type": "panel", "parent_id": "MSB-1", + "description": "Cooling plant: CRAH units, pumps, condenser fans", + "nominal_voltage": 480.0, "rated_current": 400.0, "rated_kva": 332.0, + }, + { + "id": "PDU-A1", "name": "PDU A1", "type": "pdu", "parent_id": "UPS-A", + "description": "150 kVA PDU, 480 to 208/120 V, server rows 1 and 2", + "nominal_voltage": 208.0, "rated_current": 416.0, "rated_kva": 150.0, + }, + { + "id": "PDU-A2", "name": "PDU A2", "type": "pdu", "parent_id": "UPS-A", + "description": "150 kVA PDU, 480 to 208/120 V, server rows 3 and 4", + "nominal_voltage": 208.0, "rated_current": 416.0, "rated_kva": 150.0, + }, + { + "id": "PDU-B1", "name": "PDU B1", "type": "pdu", "parent_id": "UPS-B", + "description": "150 kVA PDU, 480 to 208/120 V, server rows 1 and 2", + "nominal_voltage": 208.0, "rated_current": 416.0, "rated_kva": 150.0, + }, + { + "id": "PDU-B2", "name": "PDU B2", "type": "pdu", "parent_id": "UPS-B", + "description": "150 kVA PDU, 480 to 208/120 V, server rows 3 and 4", + "nominal_voltage": 208.0, "rated_current": 416.0, "rated_kva": 150.0, }, ] -# Alarm thresholds per meter type -ALARM_THRESHOLDS = { - "voltage_high_critical": 1.10, # 110% of nominal - "voltage_high_warning": 1.05, # 105% of nominal - "voltage_low_warning": 0.95, # 95% of nominal - "voltage_low_critical": 0.90, # 90% of nominal - "current_high_critical": 1.00, # 100% of nominal (full load) - "current_high_warning": 0.85, # 85% of nominal - "pf_low_warning": 0.85, # Power factor warning - "pf_low_critical": 0.75, # Power factor critical - "freq_high_warning": 60.5, # Hz - "freq_high_critical": 61.0, # Hz - "freq_low_warning": 59.5, # Hz - "freq_low_critical": 59.0, # Hz -} +METERS_BY_ID = {m["id"]: m for m in METERS} + + +# --------------------------------------------------------------------------- +# Alarm priorities (ISA 18.2: priority reflects consequence severity and the +# time available for the operator to respond) +# --------------------------------------------------------------------------- -# ISA 18.2 Priority labels ALARM_PRIORITIES = { - 1: "Critical", - 2: "High", - 3: "Medium", - 4: "Low", + 1: {"label": "Critical", "response": "Respond immediately (under 5 min)"}, + 2: {"label": "High", "response": "Respond promptly (under 15 min)"}, + 3: {"label": "Medium", "response": "Respond within the shift (under 1 h)"}, + 4: {"label": "Low", "response": "Respond when practical"}, } + + +# --------------------------------------------------------------------------- +# Master alarm database +# +# Each alarm was rationalized: one alarm per abnormal condition that needs an +# operator response, with a documented consequence and corrective action. +# +# kind HI / HIHI / LO / LOLO compare an analog value to a setpoint. +# DISCRETE alarms when the value is true. +# deadband hysteresis in engineering units before the alarm can clear +# on_delay consecutive scans in alarm before annunciating (anti-chatter) +# off_delay consecutive scans normal before returning to normal +# suppress designed (state based) suppression rule, see alarms.py +# --------------------------------------------------------------------------- + +def _pdu_alarms(pdu: str): + name = METERS_BY_ID[pdu]["name"] + return [ + { + "tag": f"{pdu}.LOAD.HI", "meter_id": pdu, "parameter": "load_pct", "kind": "HI", + "setpoint": 80.0, "deadband": 3.0, "priority": 3, "on_delay": 2, "off_delay": 2, + "unit": "%", + "message": f"{name} load high", + "consequence": "Reduced margin before the PDU main breaker trips.", + "response": "Check for new rack load or a failed partner PDU. Plan load rebalancing.", + }, + { + "tag": f"{pdu}.LOAD.HIHI", "meter_id": pdu, "parameter": "load_pct", "kind": "HIHI", + "setpoint": 90.0, "deadband": 3.0, "priority": 2, "on_delay": 2, "off_delay": 2, + "unit": "%", + "message": f"{name} load very high", + "consequence": "PDU breaker trip drops every server on this side.", + "response": "Shed or move load now. Confirm the partner PDU can carry the racks.", + }, + ] + + +def _ups_alarms(ups: str): + name = METERS_BY_ID[ups]["name"] + return [ + { + "tag": f"{ups}.ONBATT", "meter_id": ups, "parameter": "on_battery", "kind": "DISCRETE", + "setpoint": 1, "deadband": 0, "priority": 1, "on_delay": 0, "off_delay": 1, + "unit": "", + "message": f"{name} on battery", + "consequence": "Critical load is on limited battery runtime.", + "response": "Verify generator start and ATS transfer. Watch battery runtime.", + }, + { + "tag": f"{ups}.LOAD.HI", "meter_id": ups, "parameter": "load_pct", "kind": "HI", + "setpoint": 50.0, "deadband": 2.0, "priority": 2, "on_delay": 2, "off_delay": 2, + "unit": "%", + "message": f"{name} load above 2N limit", + "consequence": "If the other side fails, this UPS cannot carry the full IT load.", + "response": "Rebalance IT load between A and B sides below 50 percent each.", + }, + { + "tag": f"{ups}.BATT.LO", "meter_id": ups, "parameter": "battery_soc", "kind": "LO", + "setpoint": 60.0, "deadband": 5.0, "priority": 2, "on_delay": 1, "off_delay": 2, + "unit": "%", + "message": f"{name} battery charge low", + "consequence": "Reduced ride through time on the next utility event.", + "response": "Confirm the rectifier is charging. Avoid planned transfers until recharged.", + }, + ] + + +ALARM_POINTS = [ + { + "tag": "UTIL-1.FAIL", "meter_id": "UTIL-1", "parameter": "source_failed", "kind": "DISCRETE", + "setpoint": 1, "deadband": 0, "priority": 2, "on_delay": 0, "off_delay": 1, + "unit": "", + "message": "Utility power failure", + "consequence": "Facility is running on UPS battery and standby generator.", + "response": "Confirm generator pickup. Notify the utility and facility manager.", + }, + { + "tag": "UTIL-1.V.LO", "meter_id": "UTIL-1", "parameter": "voltage", "kind": "LO", + "setpoint": 432.0, "deadband": 4.8, "priority": 3, "on_delay": 0, "off_delay": 1, + "unit": "V", + "message": "Utility voltage low (sag)", + "consequence": "UPS may transfer to battery. Motors may drop out.", + "response": "Check UPS mode and mechanical equipment restart.", + "suppress": {"meter_id": "UTIL-1", "parameter": "source_failed", "equals": True, + "reason": "Utility failure alarm is active (cascade suppression)"}, + }, + { + "tag": "GEN-1.F.LO", "meter_id": "GEN-1", "parameter": "frequency", "kind": "LO", + "setpoint": 59.5, "deadband": 0.1, "priority": 3, "on_delay": 0, "off_delay": 1, + "unit": "Hz", + "message": "Generator frequency low", + "consequence": "UPS may stay on battery if frequency is out of tolerance.", + "response": "Check generator governor and block load step.", + "suppress": {"meter_id": "GEN-1", "parameter": "running", "equals": False, + "reason": "Generator not running (state based suppression)"}, + }, + { + "tag": "ATS-1.EMERG", "meter_id": "ATS-1", "parameter": "on_emergency", "kind": "DISCRETE", + "setpoint": 1, "deadband": 0, "priority": 3, "on_delay": 0, "off_delay": 1, + "unit": "", + "message": "ATS on emergency source", + "consequence": "Facility depends on generator fuel and runtime.", + "response": "Check fuel level and generator load. Track utility restoration.", + }, + { + "tag": "MSB-1.LOAD.HI", "meter_id": "MSB-1", "parameter": "load_pct", "kind": "HI", + "setpoint": 80.0, "deadband": 3.0, "priority": 3, "on_delay": 3, "off_delay": 3, + "unit": "%", + "message": "Main switchboard load high", + "consequence": "Main breaker approaching continuous rating.", + "response": "Review facility load growth and defer non critical load.", + }, + *_ups_alarms("UPS-A"), + *_ups_alarms("UPS-B"), + { + "tag": "MECH-1.PF.LO", "meter_id": "MECH-1", "parameter": "power_factor", "kind": "LO", + "setpoint": 0.85, "deadband": 0.01, "priority": 4, "on_delay": 2, "off_delay": 2, + "unit": "", + "message": "Mechanical panel power factor low", + "consequence": "Higher current and possible utility power factor penalty.", + "response": "Check the capacitor bank and VFD bypass status.", + "suppress": {"meter_id": "MECH-1", "parameter": "energized", "equals": False, + "reason": "Panel de-energized (state based suppression)"}, + }, + { + "tag": "MECH-1.LOAD.HI", "meter_id": "MECH-1", "parameter": "load_pct", "kind": "HI", + "setpoint": 85.0, "deadband": 3.0, "priority": 3, "on_delay": 3, "off_delay": 3, + "unit": "%", + "message": "Mechanical panel load high", + "consequence": "Cooling plant near feeder capacity.", + "response": "Check for stuck dampers or a failed CRAH unit forcing others to full speed.", + }, + *_pdu_alarms("PDU-A1"), + *_pdu_alarms("PDU-A2"), + *_pdu_alarms("PDU-B1"), + *_pdu_alarms("PDU-B2"), +] diff --git a/backend/database.py b/backend/database.py index 9d30137..cc67d2b 100644 --- a/backend/database.py +++ b/backend/database.py @@ -1,214 +1,158 @@ -"""Database layer for Power Dashboard using SQLite.""" +"""SQLite storage for meter history and the alarm event journal.""" import sqlite3 -import json -from datetime import datetime, timezone -from typing import List, Dict, Any, Optional -from config import DB_PATH, METERS - - -def get_connection() -> sqlite3.Connection: - conn = sqlite3.connect(DB_PATH) - conn.row_factory = sqlite3.Row - conn.execute("PRAGMA journal_mode=WAL") - conn.execute("PRAGMA synchronous=NORMAL") - return conn - - -def init_db(): - """Initialize database schema.""" - conn = get_connection() - cur = conn.cursor() - - cur.executescript(""" - CREATE TABLE IF NOT EXISTS meters ( - id INTEGER PRIMARY KEY, - name TEXT NOT NULL, - description TEXT, - type TEXT, - parent_id INTEGER, - nominal_voltage REAL, - nominal_current REAL, - ct_ratio INTEGER, - pt_ratio INTEGER, - created_at TEXT DEFAULT (datetime('now')) - ); - - CREATE TABLE IF NOT EXISTS readings ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - meter_id INTEGER NOT NULL, - voltage REAL, - current REAL, - power_kw REAL, - power_factor REAL, - energy_kwh REAL, - frequency REAL, - demand_kw REAL, - timestamp TEXT NOT NULL, - FOREIGN KEY (meter_id) REFERENCES meters(id) - ); - - CREATE INDEX IF NOT EXISTS idx_readings_meter_ts - ON readings(meter_id, timestamp DESC); - - CREATE TABLE IF NOT EXISTS alarms ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - meter_id INTEGER NOT NULL, - parameter TEXT NOT NULL, - condition TEXT NOT NULL, - value REAL NOT NULL, - threshold REAL NOT NULL, - priority INTEGER NOT NULL, - state TEXT NOT NULL DEFAULT 'active', - acknowledged INTEGER NOT NULL DEFAULT 0, - ack_time TEXT, - ack_by TEXT, - raised_at TEXT NOT NULL, - cleared_at TEXT, - FOREIGN KEY (meter_id) REFERENCES meters(id) - ); - - CREATE INDEX IF NOT EXISTS idx_alarms_state - ON alarms(state, meter_id); - """) - - # Seed meter definitions - for m in METERS: - cur.execute(""" - INSERT OR IGNORE INTO meters - (id, name, description, type, parent_id, nominal_voltage, nominal_current, ct_ratio, pt_ratio) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) - """, (m["id"], m["name"], m["description"], m["type"], m["parent_id"], - m["nominal_voltage"], m["nominal_current"], m["ct_ratio"], m["pt_ratio"])) - - conn.commit() - conn.close() - - -def insert_reading(reading: Dict[str, Any]): - conn = get_connection() - conn.execute(""" - INSERT INTO readings (meter_id, voltage, current, power_kw, power_factor, - energy_kwh, frequency, demand_kw, timestamp) - VALUES (:meter_id, :voltage, :current, :power_kw, :power_factor, - :energy_kwh, :frequency, :demand_kw, :timestamp) - """, reading) - conn.commit() - conn.close() - - -def get_latest_readings() -> List[Dict]: - conn = get_connection() - rows = conn.execute(""" - SELECT r.*, m.name AS meter_name, m.type, m.parent_id, - m.nominal_voltage, m.nominal_current - FROM readings r - JOIN meters m ON m.id = r.meter_id - WHERE r.id IN ( - SELECT MAX(id) FROM readings GROUP BY meter_id +import threading +import time +from typing import Any, Dict, Iterable, List, Optional + +import config + +HISTORY_FIELDS = ("voltage", "current", "power_kw", "power_factor", "frequency", + "energy_kwh", "demand_kw", "load_pct") + +PERIODS = {"1h": 3600, "24h": 86400, "7d": 7 * 86400} +HISTORY_POINTS = 360 # target points per trend after downsampling + +_lock = threading.Lock() +_conn: Optional[sqlite3.Connection] = None + + +def _db() -> sqlite3.Connection: + global _conn + if _conn is None: + _conn = sqlite3.connect(config.DB_PATH, check_same_thread=False) + _conn.row_factory = sqlite3.Row + if config.DB_PATH != ":memory:": + _conn.execute("PRAGMA journal_mode=WAL") + _conn.execute("PRAGMA synchronous=NORMAL") + return _conn + + +def close(): + global _conn + with _lock: + if _conn is not None: + _conn.close() + _conn = None + + +def init_db(reset: bool = False): + """Create tables. reset=True clears stored data (used for a fresh demo).""" + with _lock: + db = _db() + if reset: + db.executescript("DROP TABLE IF EXISTS meter_readings; DROP TABLE IF EXISTS alarm_events;") + db.executescript(""" + CREATE TABLE IF NOT EXISTS meter_readings ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + meter_id TEXT NOT NULL, + epoch REAL NOT NULL, + timestamp TEXT NOT NULL, + voltage REAL, + current REAL, + power_kw REAL, + power_factor REAL, + frequency REAL, + energy_kwh REAL, + demand_kw REAL, + load_pct REAL + ); + CREATE INDEX IF NOT EXISTS idx_meter_readings_meter_epoch + ON meter_readings(meter_id, epoch); + + CREATE TABLE IF NOT EXISTS alarm_events ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + epoch REAL NOT NULL, + timestamp TEXT NOT NULL, + tag TEXT NOT NULL, + meter_id TEXT NOT NULL, + meter_name TEXT, + event TEXT NOT NULL, + state TEXT NOT NULL, + priority INTEGER NOT NULL, + value REAL, + setpoint REAL, + unit TEXT, + message TEXT, + operator TEXT, + note TEXT + ); + CREATE INDEX IF NOT EXISTS idx_alarm_events_epoch ON alarm_events(epoch); + """) + db.commit() + + +def insert_readings(readings: Iterable[Dict[str, Any]]): + rows = [ + (r["meter_id"], r["epoch"], r["timestamp"], *(r.get(f) for f in HISTORY_FIELDS)) + for r in readings + ] + with _lock: + db = _db() + db.executemany( + f"INSERT INTO meter_readings (meter_id, epoch, timestamp, {', '.join(HISTORY_FIELDS)}) " + f"VALUES (?, ?, ?, {', '.join('?' * len(HISTORY_FIELDS))})", + rows, ) - ORDER BY m.id - """).fetchall() - conn.close() + db.commit() + + +def get_meter_history(meter_id: str, period: str = "1h", now: Optional[float] = None) -> List[Dict]: + """Return averaged history buckets for one meter, oldest first.""" + span = PERIODS[period] + now = time.time() if now is None else now + bucket = max(span / HISTORY_POINTS, 1.0) + avgs = ", ".join(f"ROUND(AVG({f}), 3) AS {f}" for f in HISTORY_FIELDS) + with _lock: + rows = _db().execute( + f""" + SELECT CAST(epoch / ? AS INTEGER) * ? AS epoch, {avgs} + FROM meter_readings + WHERE meter_id = ? AND epoch >= ? + GROUP BY CAST(epoch / ? AS INTEGER) + ORDER BY epoch ASC + """, + (bucket, bucket, meter_id, now - span, bucket), + ).fetchall() return [dict(r) for r in rows] -def get_meter_history(meter_id: int, period: str = "1h") -> List[Dict]: - period_map = {"1h": "1 hours", "24h": "24 hours", "7d": "7 days"} - interval = period_map.get(period, "1 hours") - conn = get_connection() - rows = conn.execute(f""" - SELECT * FROM readings - WHERE meter_id = ? - AND timestamp >= datetime('now', '-{interval}') - ORDER BY timestamp ASC - """, (meter_id,)).fetchall() - conn.close() - return [dict(r) for r in rows] - - -def get_all_meters() -> List[Dict]: - conn = get_connection() - rows = conn.execute("SELECT * FROM meters ORDER BY id").fetchall() - conn.close() - return [dict(r) for r in rows] +def insert_alarm_event(event: Dict[str, Any]): + insert_alarm_events([event]) -def upsert_alarm(meter_id: int, parameter: str, condition: str, - value: float, threshold: float, priority: int) -> Optional[int]: - """Insert new alarm if one does not already exist for this meter+parameter.""" - conn = get_connection() - existing = conn.execute(""" - SELECT id FROM alarms - WHERE meter_id=? AND parameter=? AND state='active' - """, (meter_id, parameter)).fetchone() - - if existing: - conn.close() - return existing["id"] - - now = datetime.now(timezone.utc).isoformat() - cur = conn.execute(""" - INSERT INTO alarms (meter_id, parameter, condition, value, threshold, priority, state, raised_at) - VALUES (?, ?, ?, ?, ?, ?, 'active', ?) - """, (meter_id, parameter, condition, value, threshold, priority, now)) - alarm_id = cur.lastrowid - conn.commit() - conn.close() - return alarm_id - - -def clear_alarm(meter_id: int, parameter: str): - """Clear active alarm when condition resolves.""" - now = datetime.now(timezone.utc).isoformat() - conn = get_connection() - conn.execute(""" - UPDATE alarms SET state='cleared', cleared_at=? - WHERE meter_id=? AND parameter=? AND state='active' - """, (now, meter_id, parameter)) - conn.commit() - conn.close() - - -def get_active_alarms() -> List[Dict]: - conn = get_connection() - rows = conn.execute(""" - SELECT a.*, m.name AS meter_name - FROM alarms a JOIN meters m ON m.id = a.meter_id - WHERE a.state = 'active' - ORDER BY a.priority ASC, a.raised_at DESC - """).fetchall() - conn.close() - return [dict(r) for r in rows] - - -def get_alarm_history(limit: int = 200) -> List[Dict]: - conn = get_connection() - rows = conn.execute(""" - SELECT a.*, m.name AS meter_name - FROM alarms a JOIN meters m ON m.id = a.meter_id - ORDER BY a.raised_at DESC LIMIT ? - """, (limit,)).fetchall() - conn.close() +def insert_alarm_events(events: Iterable[Dict[str, Any]]): + cols = ("epoch", "timestamp", "tag", "meter_id", "meter_name", "event", "state", + "priority", "value", "setpoint", "unit", "message", "operator", "note") + rows = [tuple(e.get(c) for c in cols) for e in events] + if not rows: + return + with _lock: + db = _db() + db.executemany( + f"INSERT INTO alarm_events ({', '.join(cols)}) VALUES ({', '.join('?' * len(cols))})", + rows, + ) + db.commit() + + +def get_alarm_events(limit: int = 200, tag: Optional[str] = None) -> List[Dict]: + sql = "SELECT * FROM alarm_events" + args: list = [] + if tag: + sql += " WHERE tag = ?" + args.append(tag) + sql += " ORDER BY epoch DESC, id DESC LIMIT ?" + args.append(limit) + with _lock: + rows = _db().execute(sql, args).fetchall() return [dict(r) for r in rows] -def acknowledge_alarm(alarm_id: int, ack_by: str = "operator"): - now = datetime.now(timezone.utc).isoformat() - conn = get_connection() - conn.execute(""" - UPDATE alarms SET acknowledged=1, ack_time=?, ack_by=? - WHERE id=? - """, (now, ack_by, alarm_id)) - conn.commit() - conn.close() - - -def prune_old_readings(days: int = 7): - """Remove readings older than specified days to manage DB size.""" - conn = get_connection() - conn.execute(""" - DELETE FROM readings WHERE timestamp < datetime('now', ? || ' days') - """, (f"-{days}",)) - conn.commit() - conn.close() +def prune(days: int, now: Optional[float] = None): + cutoff = (time.time() if now is None else now) - days * 86400 + with _lock: + db = _db() + db.execute("DELETE FROM meter_readings WHERE epoch < ?", (cutoff,)) + db.execute("DELETE FROM alarm_events WHERE epoch < ?", (cutoff,)) + db.commit() diff --git a/backend/main.py b/backend/main.py index 4bfdf6c..f69b25b 100644 --- a/backend/main.py +++ b/backend/main.py @@ -1,142 +1,234 @@ """FastAPI application entry point for Power Dashboard.""" -import asyncio import json import logging from contextlib import asynccontextmanager from typing import Optional -from fastapi import FastAPI, WebSocket, WebSocketDisconnect, HTTPException +from fastapi import FastAPI, HTTPException, Request, WebSocket, WebSocketDisconnect from fastapi.middleware.cors import CORSMiddleware +from pydantic import BaseModel, Field -from database import ( - init_db, get_all_meters, get_latest_readings, get_meter_history, - get_active_alarms, get_alarm_history, acknowledge_alarm -) -from collector import ( - collection_loop, register_ws_client, unregister_ws_client, - get_latest_readings_cache, broadcast_readings -) -from config import METERS, WS_INTERVAL +import config +import database +from alarms import AlarmActionError +from collector import Collector -logging.basicConfig(level=logging.INFO) +logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(name)s: %(message)s") logger = logging.getLogger(__name__) @asynccontextmanager async def lifespan(app: FastAPI): - init_db() - task = asyncio.create_task(collection_loop()) - logger.info("Power Dashboard collection loop started") + collector = Collector() + app.state.collector = collector + await collector.start() + logger.info("Power Dashboard %s started (data source: %s)", config.VERSION, collector.source) yield - task.cancel() + await collector.stop() + database.close() app = FastAPI( title="Power Dashboard API", - description="Real-time Electrical Power Monitoring System", - version="1.0.0", + description="Real-time data center electrical power monitoring with ISA 18.2 alarm management", + version=config.VERSION, lifespan=lifespan, ) app.add_middleware( CORSMiddleware, - allow_origins=["*"], + allow_origins=config.CORS_ORIGINS, allow_credentials=False, allow_methods=["*"], allow_headers=["*"], ) -# REST Endpoints +def _collector(request: Request) -> Collector: + return request.app.state.collector + + +def _operator(name: Optional[str]) -> str: + name = (name or "").strip()[:40] + return name or "operator" + + +class OperatorAction(BaseModel): + operator: Optional[str] = None + + +class ShelveRequest(BaseModel): + minutes: float = Field(15, gt=0, le=config.SHELVE_MAX_MIN) + reason: Optional[str] = Field(None, max_length=200) + operator: Optional[str] = None + + +# --------------------------------------------------------------------------- +# Health and configuration +# --------------------------------------------------------------------------- + +@app.get("/api/health") +def health(request: Request): + c = _collector(request) + return { + "status": "ok" if c.ready else "starting", + "version": config.VERSION, + "data_source": c.source, + "uptime_s": round(c.clock() - c.started_at, 1), + "clients": len(c.clients), + } + + +@app.get("/api/config") +def get_config(request: Request): + c = _collector(request) + return { + "version": config.VERSION, + "data_source": c.source, + "poll_interval_s": config.POLL_INTERVAL, + "history_interval_s": config.HISTORY_INTERVAL, + "retention_days": config.RETENTION_DAYS, + "shelve_durations_min": config.SHELVE_DURATIONS_MIN, + "shelve_max_min": config.SHELVE_MAX_MIN, + "priorities": config.ALARM_PRIORITIES, + "meters": config.METERS, + } + + +# --------------------------------------------------------------------------- +# Meters +# --------------------------------------------------------------------------- @app.get("/api/meters") -def list_meters(): - meters = get_all_meters() - readings_cache = get_latest_readings_cache() - result = [] - for m in meters: - reading = readings_cache.get(m["id"], {}) - result.append({**m, "current_reading": reading}) - return result +def list_meters(request: Request): + latest = _collector(request).latest + return [{**m, "current_reading": latest.get(m["id"], {})} for m in config.METERS] @app.get("/api/meters/{meter_id}") -def meter_detail(meter_id: int): - meters = {m["id"]: m for m in get_all_meters()} - if meter_id not in meters: +def meter_detail(meter_id: str, request: Request): + if meter_id not in config.METERS_BY_ID: raise HTTPException(status_code=404, detail="Meter not found") - reading = get_latest_readings_cache().get(meter_id, {}) - return {**meters[meter_id], "current_reading": reading} + reading = _collector(request).latest.get(meter_id, {}) + return {**config.METERS_BY_ID[meter_id], "current_reading": reading} @app.get("/api/meters/{meter_id}/history") -def meter_history(meter_id: int, period: str = "1h"): - if period not in ("1h", "24h", "7d"): +def meter_history(meter_id: str, period: str = "1h"): + if meter_id not in config.METERS_BY_ID: + raise HTTPException(status_code=404, detail="Meter not found") + if period not in database.PERIODS: raise HTTPException(status_code=400, detail="period must be 1h, 24h, or 7d") - return get_meter_history(meter_id, period) + return database.get_meter_history(meter_id, period) + + +@app.get("/api/system/status") +def system_status(request: Request): + return _collector(request).system_status() + +# --------------------------------------------------------------------------- +# Alarms (ISA 18.2) +# --------------------------------------------------------------------------- @app.get("/api/alarms") -def active_alarms(): - return get_active_alarms() +def alarms(request: Request): + return _collector(request).alarm_payload() @app.get("/api/alarms/history") -def alarms_history(limit: int = 200): - return get_alarm_history(limit) +def alarm_history(limit: int = 200, tag: Optional[str] = None): + return database.get_alarm_events(min(max(limit, 1), 1000), tag) -@app.post("/api/alarms/{alarm_id}/acknowledge") -def ack_alarm(alarm_id: int, ack_by: str = "operator"): - acknowledge_alarm(alarm_id, ack_by) - return {"status": "acknowledged", "alarm_id": alarm_id} +@app.get("/api/alarms/definitions") +def alarm_definitions(request: Request): + return _collector(request).alarms.definitions() -@app.get("/api/system/status") -def system_status(): - cache = get_latest_readings_cache() - active = get_active_alarms() - - total_power = sum(r.get("power_kw", 0) for r in cache.values()) - pf_values = [r["power_factor"] for r in cache.values() if r.get("power_factor")] - avg_pf = round(sum(pf_values) / len(pf_values), 4) if pf_values else 0.0 - - critical_count = sum(1 for a in active if a["priority"] == 1) - high_count = sum(1 for a in active if a["priority"] == 2) - - if critical_count > 0: - health = "critical" - elif high_count > 0: - health = "warning" - elif len(active) > 0: - health = "caution" - else: - health = "normal" +async def _alarm_action(request: Request, fn, *args, **kwargs): + c = _collector(request) + try: + result = fn(*args, now=c.clock(), **kwargs) + except KeyError: + raise HTTPException(status_code=404, detail="Alarm tag not found") + except AlarmActionError as exc: + raise HTTPException(status_code=409, detail=str(exc)) + await c.broadcast_alarms() + return result - return { - "health": health, - "total_power_kw": round(total_power, 2), - "avg_power_factor": avg_pf, - "active_alarm_count": len(active), - "critical_alarm_count": critical_count, - "high_alarm_count": high_count, - "meter_count": len(METERS), - "meters_online": len(cache), - } +@app.post("/api/alarms/{tag}/acknowledge") +async def acknowledge(tag: str, request: Request, body: Optional[OperatorAction] = None): + c = _collector(request) + return await _alarm_action(request, c.alarms.acknowledge, tag, + operator=_operator(body.operator if body else None)) -# WebSocket + +@app.post("/api/alarms/{tag}/shelve") +async def shelve(tag: str, body: ShelveRequest, request: Request): + c = _collector(request) + return await _alarm_action(request, c.alarms.shelve, tag, body.minutes, + operator=_operator(body.operator), reason=body.reason) + + +@app.post("/api/alarms/{tag}/unshelve") +async def unshelve(tag: str, request: Request, body: Optional[OperatorAction] = None): + c = _collector(request) + return await _alarm_action(request, c.alarms.unshelve, tag, + operator=_operator(body.operator if body else None)) + + +# --------------------------------------------------------------------------- +# Demo (simulation only) +# --------------------------------------------------------------------------- + +@app.get("/api/demo") +def demo(request: Request): + c = _collector(request) + return {**c.demo_info(), "active_scenario": c.sim.scenario_info(c.clock()) if c.sim else None} + + +@app.post("/api/demo/events/{name}") +async def trigger_event(name: str, request: Request): + c = _collector(request) + try: + info = c.trigger_event(name) + except PermissionError as exc: + raise HTTPException(status_code=403, detail=str(exc)) + except KeyError: + raise HTTPException(status_code=404, detail="Unknown scenario") + except RuntimeError as exc: + raise HTTPException(status_code=409, detail=str(exc)) + return info + + +# --------------------------------------------------------------------------- +# WebSocket live feed +# +# On connect the server sends a "snapshot" with everything the UI needs. After +# that it pushes "readings" every scan and "alarms" whenever the alarm list +# changes. Clients may send {"type": "ping"} and get {"type": "pong"} back. +# --------------------------------------------------------------------------- @app.websocket("/ws/live") async def websocket_live(websocket: WebSocket): + c: Collector = websocket.app.state.collector await websocket.accept() - register_ws_client(websocket) + await websocket.send_text(json.dumps({"type": "snapshot", "data": c.snapshot()}, + separators=(",", ":"))) + c.register(websocket) try: while True: - # Keep connection alive; data is pushed by broadcast_readings - await asyncio.sleep(1) + text = await websocket.receive_text() + try: + msg = json.loads(text) + except ValueError: + continue + if isinstance(msg, dict) and msg.get("type") == "ping": + await websocket.send_text('{"type":"pong"}') except WebSocketDisconnect: pass finally: - unregister_ws_client(websocket) + c.unregister(websocket) diff --git a/backend/modbus_config.example.json b/backend/modbus_config.example.json new file mode 100644 index 0000000..4a3df61 --- /dev/null +++ b/backend/modbus_config.example.json @@ -0,0 +1,37 @@ +{ + "defaults": { + "port": 502, + "timeout": 2.0, + "word_order": "big" + }, + "devices": [ + { + "meter_id": "UTIL-1", + "host": "meter-util.example.internal", + "unit_id": 1, + "registers": { + "voltage": { "address": 3000, "type": "float32", "function": "holding" }, + "current": { "address": 3002, "type": "float32", "function": "holding" }, + "power_kw": { "address": 3004, "type": "float32", "function": "holding" }, + "power_factor": { "address": 3006, "type": "float32", "function": "holding" }, + "frequency": { "address": 3008, "type": "float32", "function": "holding" }, + "energy_kwh": { "address": 3010, "type": "uint32", "function": "holding", "scale": 0.1 }, + "demand_kw": { "address": 3012, "type": "float32", "function": "holding" } + } + }, + { + "meter_id": "UPS-A", + "host": "ups-a.example.internal", + "unit_id": 1, + "registers": { + "voltage": { "address": 100, "type": "uint16", "function": "input", "scale": 0.1 }, + "current": { "address": 101, "type": "uint16", "function": "input", "scale": 0.1 }, + "power_kw": { "address": 102, "type": "uint16", "function": "input", "scale": 0.1 }, + "power_factor": { "address": 103, "type": "uint16", "function": "input", "scale": 0.001 }, + "frequency": { "address": 104, "type": "uint16", "function": "input", "scale": 0.01 }, + "battery_soc": { "address": 110, "type": "uint16", "function": "input" }, + "on_battery": { "address": 0, "type": "bool", "function": "discrete" } + } + } + ] +} diff --git a/backend/modbus_source.py b/backend/modbus_source.py new file mode 100644 index 0000000..2fb583c --- /dev/null +++ b/backend/modbus_source.py @@ -0,0 +1,134 @@ +"""Modbus TCP data source for real power meters. + +Enabled with DATA_SOURCE=modbus. The device map is a JSON file (see +modbus_config.example.json) that lists each meter's host, unit id, and the +register for every value. Requires the optional dependency in +requirements-modbus.txt (pymodbus). + +Readings come back in the same shape the simulator produces, so the alarm +engine, history, and front end work unchanged. A meter that fails to answer +is reported with status "Comm Fail" and empty values; alarms on that meter +hold their last state until communication returns. +""" + +import json +import logging +import struct +from datetime import datetime, timezone +from typing import Any, Dict, List, Optional + +from config import METERS_BY_ID + +logger = logging.getLogger(__name__) + +REGISTER_COUNT = {"int16": 1, "uint16": 1, "int32": 2, "uint32": 2, "float32": 2, "bool": 1} +NUMERIC_FIELDS = ("voltage", "current", "power_kw", "power_factor", "frequency", + "energy_kwh", "demand_kw", "load_pct") + + +def decode(registers: List[int], data_type: str, word_order: str = "big") -> float: + """Decode raw 16 bit registers into a value.""" + if data_type == "bool": + return bool(registers[0]) + if data_type == "uint16": + return registers[0] + if data_type == "int16": + return struct.unpack(">h", struct.pack(">H", registers[0]))[0] + words = list(registers[:2]) + if word_order == "little": + words.reverse() + raw = struct.pack(">HH", *words) + fmt = {"int32": ">i", "uint32": ">I", "float32": ">f"}[data_type] + return struct.unpack(fmt, raw)[0] + + +def load_config(path: str) -> Dict[str, Any]: + with open(path, encoding="utf-8") as fh: + cfg = json.load(fh) + for dev in cfg.get("devices", []): + if dev["meter_id"] not in METERS_BY_ID: + raise ValueError(f"Modbus device references unknown meter {dev['meter_id']}") + return cfg + + +class ModbusSource: + """Polls every configured device once per scan.""" + + def __init__(self, cfg: Dict[str, Any], client_factory=None): + self.cfg = cfg + self.defaults = cfg.get("defaults", {}) + self._clients: Dict[str, Any] = {} + if client_factory is None: + from pymodbus.client import AsyncModbusTcpClient # optional dependency + + def client_factory(host, port, timeout): + return AsyncModbusTcpClient(host, port=port, timeout=timeout) + self._factory = client_factory + + async def _client(self, host: str, port: int): + key = f"{host}:{port}" + client = self._clients.get(key) + if client is None: + client = self._factory(host, port, self.defaults.get("timeout", 2.0)) + self._clients[key] = client + if not client.connected: + await client.connect() + return client + + async def _read_register(self, client, unit: int, spec: Dict[str, Any]): + function = spec.get("function", "holding") + address = spec["address"] + dtype = spec.get("type", "float32") + count = REGISTER_COUNT[dtype] + if function == "coil": + rr = await client.read_coils(address, count=1, slave=unit) + elif function == "discrete": + rr = await client.read_discrete_inputs(address, count=1, slave=unit) + elif function == "input": + rr = await client.read_input_registers(address, count=count, slave=unit) + else: + rr = await client.read_holding_registers(address, count=count, slave=unit) + if rr.isError(): + raise IOError(f"Modbus error reading {address}: {rr}") + if function in ("coil", "discrete"): + return bool(rr.bits[0]) + value = decode(rr.registers, dtype, spec.get("word_order", self.defaults.get("word_order", "big"))) + if isinstance(value, bool): + return value + return value * spec.get("scale", 1.0) + + async def read_device(self, dev: Dict[str, Any], now: float) -> Dict[str, Any]: + mid = dev["meter_id"] + reading: Dict[str, Any] = { + "meter_id": mid, + "timestamp": datetime.fromtimestamp(now, timezone.utc).isoformat(timespec="milliseconds"), + "epoch": round(now, 3), + **{f: None for f in NUMERIC_FIELDS}, + } + try: + client = await self._client(dev["host"], dev.get("port", self.defaults.get("port", 502))) + for field, spec in dev["registers"].items(): + reading[field] = await self._read_register(client, dev.get("unit_id", 1), spec) + except Exception as exc: # comm failure: report it, keep polling others + logger.warning("Modbus read failed for %s: %s", mid, exc) + return {**reading, **{f: None for f in NUMERIC_FIELDS}, "status": "Comm Fail", "comm_ok": False} + + rated = METERS_BY_ID[mid]["rated_current"] + if reading["load_pct"] is None and reading["current"] is not None and rated: + reading["load_pct"] = round(reading["current"] / rated * 100.0, 1) + reading["status"] = dev.get("status_label", "Online") + reading["comm_ok"] = True + return reading + + async def read_all(self, now: float) -> Dict[str, Dict[str, Any]]: + out = {} + for dev in self.cfg.get("devices", []): + out[dev["meter_id"]] = await self.read_device(dev, now) + return out + + def close(self): + for client in self._clients.values(): + try: + client.close() + except Exception: + pass diff --git a/backend/requirements-dev.txt b/backend/requirements-dev.txt new file mode 100644 index 0000000..b918a15 --- /dev/null +++ b/backend/requirements-dev.txt @@ -0,0 +1,3 @@ +-r requirements.txt +pytest==8.3.3 +httpx==0.27.2 diff --git a/backend/requirements-modbus.txt b/backend/requirements-modbus.txt new file mode 100644 index 0000000..5ae2f13 --- /dev/null +++ b/backend/requirements-modbus.txt @@ -0,0 +1 @@ +pymodbus==3.6.9 diff --git a/backend/requirements.txt b/backend/requirements.txt index c83aa69..1eab1e7 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -1,2 +1,3 @@ fastapi==0.111.0 uvicorn[standard]==0.29.0 +pydantic==2.14.1 diff --git a/backend/simulator.py b/backend/simulator.py index 063a367..86d1e26 100644 --- a/backend/simulator.py +++ b/backend/simulator.py @@ -1,129 +1,445 @@ -"""Modbus device simulator for Power Dashboard. +"""Data center power system simulator. -Generates realistic electrical meter readings using sine-wave modulation, -Gaussian noise, and occasional fault injection to simulate real power meters. +Models a small 2N data center so the public demo runs without hardware: + + Utility ---+ + +--> ATS --> Main Switchboard --+--> UPS A --> PDU A1, PDU A2 + Generator -+ +--> UPS B --> PDU B1, PDU B2 + +--> Mechanical panel + +Values are computed bottom up so they stay physically consistent: IT load at +each PDU rolls up through transformer and UPS losses to the switchboard, and +the switchboard load appears on whichever source the ATS has selected. + +Equipment behaves like the real thing: the generator cranks and warms up, the +ATS waits for a stable source before transferring, UPS systems ride through on +battery and recharge afterward, and cooling load drops out and restarts when +the switchboard loses power. + +Scenarios inject abnormal conditions (utility outage, voltage sag, PDU +overload, low power factor) so the alarm system has something to show. They +fire automatically at random intervals and can be triggered on demand. """ import math import random -import time from datetime import datetime, timezone -from typing import Dict, Any - -from config import METERS - - -class MeterSimulator: - """Simulates a single power meter with realistic electrical values.""" - - def __init__(self, meter_cfg: dict): - self.meter_id = meter_cfg["id"] - self.name = meter_cfg["name"] - self.nominal_voltage = meter_cfg["nominal_voltage"] - self.nominal_current = meter_cfg["nominal_current"] - - # Internal state - self._energy_kwh = random.uniform(1000, 50000) - self._phase_offset = random.uniform(0, 2 * math.pi) - self._drift = 0.0 - self._fault_active = False - self._fault_type = None - self._fault_counter = 0 - self._last_demand_window = [] - self._start_time = time.time() - - # Load profile: base load as fraction of nominal - self._base_load = random.uniform(0.45, 0.72) - - def _inject_fault(self): - """Occasionally inject a fault condition.""" - if not self._fault_active and random.random() < 0.0015: - self._fault_type = random.choice([ - "voltage_sag", "current_spike", "pf_drop", "freq_deviation" - ]) - self._fault_active = True - self._fault_counter = random.randint(3, 12) - - if self._fault_active: - self._fault_counter -= 1 - if self._fault_counter <= 0: - self._fault_active = False - self._fault_type = None - - def read(self) -> Dict[str, Any]: - """Return a simulated meter reading.""" - self._inject_fault() - - t = time.time() - self._start_time - - # Base voltage: 480V nominal with slow sine oscillation + noise - v_swing = math.sin(t / 47 + self._phase_offset) * 3.5 - v_noise = random.gauss(0, 0.4) - voltage = self.nominal_voltage + v_swing + v_noise - - # Current: follows a daily load profile approximated with a sine wave - load_cycle = 0.82 + 0.18 * math.sin(t / 3600 * math.pi - math.pi / 2) - i_base = self.nominal_current * self._base_load * load_cycle - i_noise = random.gauss(0, i_base * 0.015) - current = max(0.0, i_base + i_noise) - - # Power factor - pf_base = 0.92 + math.sin(t / 120 + self._phase_offset) * 0.025 - pf = min(1.0, max(0.70, pf_base + random.gauss(0, 0.005))) - - # Frequency - freq = 60.0 + math.sin(t / 23) * 0.08 + random.gauss(0, 0.03) - - # Apply faults - if self._fault_active: - ft = self._fault_type - p = self._fault_counter / 12 - if ft == "voltage_sag": - voltage *= (0.88 + 0.07 * p) - elif ft == "current_spike": - current *= (1.25 - 0.15 * p) - elif ft == "pf_drop": - pf *= (0.78 + 0.12 * p) - elif ft == "freq_deviation": - freq += (0.6 - 0.3 * p) * random.choice([-1, 1]) - - # Power - power_kw = (voltage * current * pf * math.sqrt(3)) / 1000.0 - - # Demand (15-minute rolling average approximation) - self._last_demand_window.append(power_kw) - if len(self._last_demand_window) > 180: - self._last_demand_window.pop(0) - demand_kw = sum(self._last_demand_window) / len(self._last_demand_window) - - # Energy accumulation - self._energy_kwh += power_kw * (5 / 3600) +from typing import Any, Dict, Optional, Tuple + +from config import METERS, METERS_BY_ID, NOMINAL_FREQUENCY + +SQRT3 = math.sqrt(3) + +SCENARIOS = { + "utility_outage": { + "label": "Utility outage", + "description": "Utility power is lost. UPS systems carry the IT load on battery, the " + "generator starts, the ATS transfers, then retransfers when utility returns.", + }, + "voltage_sag": { + "label": "Utility voltage sag", + "description": "A short utility voltage dip to about 85 percent. UPS systems ride " + "through on battery for a few seconds.", + }, + "pdu_overload": { + "label": "PDU overload", + "description": "New servers push one PDU past its load alarm limits, then the load " + "is rebalanced.", + }, + "pf_drop": { + "label": "Low power factor", + "description": "The cooling plant capacitor bank drops out and the mechanical panel " + "power factor falls below its alarm limit.", + }, +} + +# Base IT load per PDU (kW) and the cooling load ratio +_PDU_BASE_KW = {"PDU-A1": 74.0, "PDU-A2": 66.0, "PDU-B1": 71.0, "PDU-B2": 63.0} +_PDU_EFFICIENCY = 0.975 +_UPS_EFFICIENCY = 0.955 +_UPS_BATTERY_KWH = 50.0 +_UPS_CHARGE_KW = 10.0 +_HOUSE_KW = 18.0 +_MECH_RATIO = 0.48 + +# Equipment timing (seconds) +_GEN_START_DELAY = 2.0 # utility must be bad this long before engine start +_GEN_CRANK_TIME = 6.0 # crank to rated voltage and frequency +_ATS_GEN_STABLE = 4.0 # generator must be stable before transfer +_ATS_RETRANSFER = 30.0 # utility must be stable before retransfer +_GEN_COOLDOWN = 30.0 # unloaded run after retransfer +_UPS_WALK_IN = 3.0 # rectifier walk in after input returns +_MECH_RESTART = 20.0 # cooling equipment restart ramp + + +def _iso(epoch: float) -> str: + return datetime.fromtimestamp(epoch, timezone.utc).isoformat(timespec="milliseconds") + + +class FacilitySimulator: + """Stateful simulator. Call step(now) once per scan.""" + def __init__(self, seed: Optional[int] = None, auto_events: bool = True, + event_interval: Tuple[float, float] = (150.0, 360.0), + first_event_delay: float = 25.0): + self.rng = random.Random(seed) + self.auto_events = auto_events + self.event_interval = event_interval + self.first_event_delay = first_event_delay + + self._t_last: Optional[float] = None + self._next_event_at: Optional[float] = None + self.scenario: Optional[Dict[str, Any]] = None + + # Slow random walks (Ornstein Uhlenbeck) keep values drifting naturally + self._walk: Dict[str, float] = {k: 0.0 for k in ( + "util_v", "util_f", "gen_v", "mech", "mech_pf", *_PDU_BASE_KW.keys())} + + # Meter registers + sizes = {"utility": 1.2e6, "generator": 4.0e3, "ats": 1.2e6, "switchgear": 1.2e6, + "ups": 4.5e5, "panel": 4.0e5, "pdu": 2.0e5} + self._energy = {m["id"]: sizes[m["type"]] * self.rng.uniform(0.6, 1.4) for m in METERS} + self._demand: Dict[str, Optional[float]] = {m["id"]: None for m in METERS} + + # Equipment state + self.gen_state = "STANDBY" + self._gen_since = 0.0 + self._gen_running_since: Optional[float] = None + self.ats_position = "UTILITY" + self._transfer_at: Optional[float] = None + self._util_bad_since: Optional[float] = None + self._util_good_since: Optional[float] = 0.0 + self._source_ok_since: Optional[float] = 0.0 + self._mech_restart_since: Optional[float] = None + self.ups = { + side: {"soc": 100.0, "on_battery": False, "input_ok_since": 0.0} + for side in ("UPS-A", "UPS-B") + } + + # ------------------------------------------------------------------ + # Scenarios + # ------------------------------------------------------------------ + + def trigger(self, name: str, now: float, **params) -> Dict[str, Any]: + """Start a scenario. Raises KeyError if unknown, RuntimeError if busy.""" + if name not in SCENARIOS: + raise KeyError(name) + if self.scenario is not None: + raise RuntimeError(f"Scenario '{self.scenario['name']}' is still running") + + rng = self.rng + if name == "utility_outage": + p = {"duration": params.get("duration", rng.uniform(90, 150))} + elif name == "voltage_sag": + p = {"duration": params.get("duration", rng.uniform(4, 8)), + "depth": params.get("depth", rng.uniform(0.84, 0.875))} + elif name == "pdu_overload": + ramp = params.get("ramp", 40.0) + hold = params.get("hold", rng.uniform(60, 90)) + p = {"pdu": params.get("pdu", rng.choice(list(_PDU_BASE_KW))), + "target_pct": params.get("target_pct", rng.uniform(86, 95)), + "ramp": ramp, "hold": hold, "duration": 2 * ramp + hold} + else: # pf_drop + p = {"duration": params.get("duration", rng.uniform(70, 130)), + "pf": params.get("pf", rng.uniform(0.78, 0.82))} + + self.scenario = {"name": name, "label": SCENARIOS[name]["label"], + "started_at": now, "params": p} + return self.scenario_info(now) + + def scenario_info(self, now: float) -> Optional[Dict[str, Any]]: + if self.scenario is None: + return None return { - "meter_id": self.meter_id, - "voltage": round(voltage, 2), - "current": round(current, 2), - "power_kw": round(power_kw, 2), - "power_factor": round(pf, 4), - "energy_kwh": round(self._energy_kwh, 2), - "frequency": round(freq, 3), - "demand_kw": round(demand_kw, 2), - "timestamp": datetime.now(timezone.utc).isoformat(), + "name": self.scenario["name"], + "label": self.scenario["label"], + "started_at": _iso(self.scenario["started_at"]), + "elapsed_s": round(now - self.scenario["started_at"], 1), } + def _scenario_elapsed(self, name: str, now: float) -> Optional[float]: + if self.scenario and self.scenario["name"] == name: + return now - self.scenario["started_at"] + return None + + def _update_scenario(self, now: float): + sc = self.scenario + if sc is not None: + elapsed = now - sc["started_at"] + done = elapsed >= sc["params"]["duration"] + if sc["name"] == "utility_outage": + done = done and self.ats_position == "UTILITY" and self.gen_state == "STANDBY" + if done: + self.scenario = None + self._schedule_next(now) + + if self._next_event_at is None: + self._next_event_at = now + self.first_event_delay -class DeviceSimulator: - """Manages all meter simulators.""" + if self.auto_events and self.scenario is None and now >= self._next_event_at: + weights = {"voltage_sag": 0.3, "pdu_overload": 0.25, "pf_drop": 0.25, + "utility_outage": 0.2} + name = self.rng.choices(list(weights), weights=list(weights.values()))[0] + self.trigger(name, now) - def __init__(self): - self._meters: Dict[int, MeterSimulator] = {} - for cfg in METERS: - self._meters[cfg["id"]] = MeterSimulator(cfg) + def _schedule_next(self, now: float): + lo, hi = self.event_interval + self._next_event_at = now + self.rng.uniform(lo, hi) - def read_all(self) -> Dict[int, Dict[str, Any]]: - return {mid: m.read() for mid, m in self._meters.items()} + def schedule_event_at(self, at: float): + """Set the time of the next automatic event.""" + self._next_event_at = at - def read_meter(self, meter_id: int) -> Dict[str, Any]: - if meter_id not in self._meters: - raise ValueError(f"Meter {meter_id} not found") - return self._meters[meter_id].read() + # ------------------------------------------------------------------ + # Helpers + # ------------------------------------------------------------------ + + def _ou(self, key: str, dt: float, sigma: float, tau: float) -> float: + """Advance an Ornstein Uhlenbeck random walk with mean 0.""" + decay = math.exp(-dt / tau) + x = self._walk[key] * decay + sigma * math.sqrt(1 - decay * decay) * self.rng.gauss(0, 1) + self._walk[key] = x + return x + + def _noise(self, sigma: float) -> float: + return self.rng.gauss(0, sigma) + + def _register(self, mid: str, kw: float, dt: float) -> Tuple[float, float]: + self._energy[mid] += max(kw, 0.0) * dt / 3600.0 + d = self._demand[mid] + d = kw if d is None else d + (kw - d) * min(1.0, dt / 900.0) # 15 min demand + self._demand[mid] = d + return self._energy[mid], d + + # ------------------------------------------------------------------ + # Main step + # ------------------------------------------------------------------ + + def step(self, now: float) -> Dict[str, Dict[str, Any]]: + dt = 2.0 if self._t_last is None else min(max(now - self._t_last, 0.0), 3600.0) + self._t_last = now + self._update_scenario(now) + + hour = (now % 86400) / 3600.0 + it_profile = 1.0 + 0.04 * math.sin(2 * math.pi * (hour - 10) / 24) + temp_profile = 1.0 + 0.12 * math.sin(2 * math.pi * (hour - 9) / 24) + + # --- Utility source ------------------------------------------------ + outage_t = self._scenario_elapsed("utility_outage", now) + util_available = not (outage_t is not None and outage_t < self.scenario["params"]["duration"]) + sag_t = self._scenario_elapsed("voltage_sag", now) + v_factor = 1.0 + if sag_t is not None and sag_t < self.scenario["params"]["duration"]: + v_factor = self.scenario["params"]["depth"] + + util_walk_v = self._ou("util_v", dt, 2.5, 600) + util_walk_f = self._ou("util_f", dt, 0.012, 120) + if util_available: + v_util = (482.0 + util_walk_v + self._noise(0.6)) * v_factor + f_util = NOMINAL_FREQUENCY + util_walk_f + self._noise(0.004) + else: + v_util, f_util = 0.0, 0.0 + util_ok = v_util >= 0.80 * 480.0 # ATS normal source dropout + + if util_ok: + self._util_bad_since = None + if self._util_good_since is None: + self._util_good_since = now + else: + self._util_good_since = None + if self._util_bad_since is None: + self._util_bad_since = now + + # --- Generator and ATS controller ----------------------------------- + start_signal = (self._util_bad_since is not None and now - self._util_bad_since >= _GEN_START_DELAY) \ + or self.ats_position == "GENERATOR" + + if self.gen_state == "STANDBY" and start_signal: + self.gen_state, self._gen_since = "CRANKING", now + elif self.gen_state == "CRANKING": + if not start_signal: + self.gen_state, self._gen_since = "STANDBY", now + elif now - self._gen_since >= _GEN_CRANK_TIME: + self.gen_state, self._gen_since = "RUNNING", now + self._gen_running_since = now + elif self.gen_state == "RUNNING" and not start_signal and self.ats_position == "UTILITY": + self.gen_state, self._gen_since = "COOLDOWN", now + elif self.gen_state == "COOLDOWN": + if start_signal: + self.gen_state, self._gen_since = "RUNNING", now + self._gen_running_since = now + elif now - self._gen_since >= _GEN_COOLDOWN: + self.gen_state, self._gen_since = "STANDBY", now + self._gen_running_since = None + + gen_running = self.gen_state in ("RUNNING", "COOLDOWN") + if self.ats_position == "UTILITY" and not util_ok and self.gen_state == "RUNNING" \ + and now - self._gen_running_since >= _ATS_GEN_STABLE: + self.ats_position, self._transfer_at = "GENERATOR", now + elif self.ats_position == "GENERATOR" and util_ok \ + and now - self._util_good_since >= _ATS_RETRANSFER: + self.ats_position, self._transfer_at = "UTILITY", now + + gen_walk_v = self._ou("gen_v", dt, 1.5, 60) + if gen_running: + v_gen = 480.0 + gen_walk_v + self._noise(0.8) + f_gen = NOMINAL_FREQUENCY + self._noise(0.04) + if self.ats_position == "GENERATOR" and self._transfer_at is not None: + since = now - self._transfer_at + if since < 6.0: # frequency dip from the block load step + f_gen -= 1.2 * math.exp(-since / 1.5) + elif self.gen_state == "CRANKING": + ramp = min(1.0, (now - self._gen_since) / _GEN_CRANK_TIME) + v_gen, f_gen = 480.0 * ramp, NOMINAL_FREQUENCY * ramp + else: + v_gen, f_gen = 0.0, 0.0 + + if self.ats_position == "UTILITY": + v_src, f_src = v_util, f_util + else: + v_src, f_src = v_gen, f_gen + src_live = v_src >= 0.5 * 480.0 + if src_live: + if self._source_ok_since is None: + self._source_ok_since = now + else: + self._source_ok_since = None + + # --- PDUs (IT load) -------------------------------------------------- + readings: Dict[str, Dict[str, Any]] = {} + overload_t = self._scenario_elapsed("pdu_overload", now) + ups_out: Dict[str, Dict[str, float]] = {"UPS-A": {"kw": 0.0, "kvar": 0.0}, + "UPS-B": {"kw": 0.0, "kvar": 0.0}} + it_total = 0.0 + for pdu, base in _PDU_BASE_KW.items(): + cfg = METERS_BY_ID[pdu] + walk = self._ou(pdu, dt, 0.025, 1800) + kw = base * it_profile * (1 + walk) * (1 + self._noise(0.006)) + if overload_t is not None and self.scenario["params"]["pdu"] == pdu: + p = self.scenario["params"] + target_kw = cfg["rated_kva"] * p["target_pct"] / 100.0 * 0.98 + shape = min(1.0, overload_t / p["ramp"], + max(0.0, (p["duration"] - overload_t) / p["ramp"])) + kw = kw * (1 - shape) + target_kw * shape + pf = min(0.995, 0.98 + self._noise(0.003)) + kva = kw / pf + v = 208.0 * (1 - 0.012 * kva / cfg["rated_kva"]) + self._noise(0.25) + side = "UPS-A" if pdu.startswith("PDU-A") else "UPS-B" + kw_in = kw / _PDU_EFFICIENCY + ups_out[side]["kw"] += kw_in + ups_out[side]["kvar"] += kw_in * math.tan(math.acos(0.97)) + it_total += kw + readings[pdu] = self._reading(pdu, now, dt, v, kva, kw, pf, f_src or NOMINAL_FREQUENCY, + status="Normal") + + # --- UPS ------------------------------------------------------------- + input_ok = 0.88 * 480.0 <= v_src <= 1.12 * 480.0 and 55.0 <= f_src <= 65.0 + ups_in_kw = 0.0 + for side, st in self.ups.items(): + cfg = METERS_BY_ID[side] + if input_ok: + if st["input_ok_since"] is None: + st["input_ok_since"] = now + else: + st["input_ok_since"] = None + st["on_battery"] = st["input_ok_since"] is None or now - st["input_ok_since"] < _UPS_WALK_IN + + out_kw = ups_out[side]["kw"] + out_kva = math.hypot(out_kw, ups_out[side]["kvar"]) + if st["on_battery"]: + st["soc"] = max(0.0, st["soc"] - out_kw / 0.96 * dt / 3600.0 / _UPS_BATTERY_KWH * 100.0) + in_kw = 0.0 + else: + charge = _UPS_CHARGE_KW if st["soc"] < 100.0 else 0.0 + st["soc"] = min(100.0, st["soc"] + charge * dt / 3600.0 / _UPS_BATTERY_KWH * 100.0) + in_kw = out_kw / _UPS_EFFICIENCY + charge + ups_in_kw += in_kw + runtime = st["soc"] / 100.0 * _UPS_BATTERY_KWH / max(out_kw, 1.0) * 60.0 + v_out = 480.0 + self._noise(0.4) + readings[side] = self._reading( + side, now, dt, v_out, out_kva, out_kw, out_kw / out_kva if out_kva else 1.0, + NOMINAL_FREQUENCY if st["on_battery"] else (f_src or NOMINAL_FREQUENCY), + status="On Battery" if st["on_battery"] else "Online", + on_battery=st["on_battery"], battery_soc=round(st["soc"], 1), + battery_runtime_min=round(runtime, 1), input_kw=round(in_kw, 1), + ) + + # --- Mechanical panel (not on UPS) ----------------------------------- + cfg = METERS_BY_ID["MECH-1"] + mech_walk = self._ou("mech", dt, 0.04, 900) + pf_walk = self._ou("mech_pf", dt, 0.008, 300) + mech_pf = 0.90 + pf_walk + self._noise(0.002) + pf_t = self._scenario_elapsed("pf_drop", now) + if pf_t is not None: + p = self.scenario["params"] + shape = min(1.0, pf_t / 10.0, max(0.0, (p["duration"] - pf_t) / 10.0)) + mech_pf = mech_pf * (1 - shape) + (p["pf"] + self._noise(0.004)) * shape + if src_live: + if self._mech_restart_since is not None: + ramp = min(1.0, (now - self._mech_restart_since) / _MECH_RESTART) + if ramp >= 1.0: + self._mech_restart_since = None + else: + ramp = 1.0 + mech_kw = _MECH_RATIO * it_total * temp_profile * (1 + mech_walk) * ramp + mech_status = "Restarting" if ramp < 1.0 else "Normal" + house_kw = _HOUSE_KW + else: + self._mech_restart_since = now + mech_kw, house_kw, mech_status = 0.0, 0.0, "De-energized" + mech_kva = mech_kw / mech_pf + mech_v = v_src * (1 - 0.006 * mech_kva / cfg["rated_kva"]) if src_live else 0.0 + readings["MECH-1"] = self._reading("MECH-1", now, dt, mech_v, mech_kva, mech_kw, + mech_pf if mech_kw > 0 else 0.0, f_src, status=mech_status, + energized=src_live) + + # --- Main switchboard and ATS ---------------------------------------- + total_kw = ups_in_kw + mech_kw + house_kw + total_kvar = ups_in_kw * math.tan(math.acos(0.99)) \ + + mech_kw * math.tan(math.acos(max(mech_pf, 0.5))) \ + + house_kw * math.tan(math.acos(0.95)) + total_kva = math.hypot(total_kw, total_kvar) + msb_pf = total_kw / total_kva if total_kva else 0.0 + msb_v = v_src * (1 - 0.004 * total_kva / 1330.0) if src_live else 0.0 + readings["MSB-1"] = self._reading("MSB-1", now, dt, msb_v, total_kva, total_kw, msb_pf, f_src, + status="Energized" if src_live else "De-energized") + on_gen = self.ats_position == "GENERATOR" + readings["ATS-1"] = self._reading( + "ATS-1", now, dt, v_src, total_kva, total_kw, msb_pf, f_src, + status="On Generator" if on_gen else "On Utility", + position=self.ats_position, on_emergency=on_gen, + ) + + # --- Sources --------------------------------------------------------- + util_load = (total_kva, total_kw, msb_pf) if not on_gen else (0.0, 0.0, 0.0) + util_status = "Failed" if not util_available else ("Sag" if v_factor < 1.0 else "Normal") + readings["UTIL-1"] = self._reading("UTIL-1", now, dt, v_util, *util_load, f_util, + status=util_status, source_failed=not util_available) + gen_load = (total_kva, total_kw, msb_pf) if on_gen else (0.0, 0.0, 0.0) + readings["GEN-1"] = self._reading("GEN-1", now, dt, v_gen, *gen_load, f_gen, + status=self.gen_state.title(), + running=self.gen_state == "RUNNING", + gen_state=self.gen_state) + + return {m["id"]: readings[m["id"]] for m in METERS} + + def _reading(self, mid: str, now: float, dt: float, voltage: float, kva: float, kw: float, + pf: float, freq: float, status: str, **extra) -> Dict[str, Any]: + cfg = METERS_BY_ID[mid] + current = kva * 1000.0 / (SQRT3 * voltage) if voltage > 1.0 else 0.0 + energy, demand = self._register(mid, kw, dt) + return { + "meter_id": mid, + "timestamp": _iso(now), + "epoch": round(now, 3), + "voltage": round(max(voltage, 0.0), 1), + "current": round(current, 1), + "power_kw": round(kw, 1), + "power_factor": round(pf, 3), + "frequency": round(max(freq, 0.0), 2), + "energy_kwh": round(energy, 1), + "demand_kw": round(demand, 1), + "load_pct": round(current / cfg["rated_current"] * 100.0, 1), + "status": status, + **extra, + } diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py new file mode 100644 index 0000000..261622d --- /dev/null +++ b/backend/tests/conftest.py @@ -0,0 +1,15 @@ +import os +import sys +import tempfile + +# Settings must be in place before config.py is imported +_tmp = tempfile.mkdtemp(prefix="power-dashboard-tests-") +os.environ.setdefault("DB_PATH", os.path.join(_tmp, "test.db")) +os.environ.setdefault("BACKFILL_HOURS", "1") +os.environ.setdefault("POLL_INTERVAL", "0.2") +os.environ.setdefault("HISTORY_INTERVAL", "0.2") +os.environ.setdefault("SIM_SEED", "7") +os.environ.setdefault("SIM_FIRST_EVENT", "100000") +os.environ["DATA_SOURCE"] = "simulator" + +sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), ".."))) diff --git a/backend/tests/test_alarms.py b/backend/tests/test_alarms.py new file mode 100644 index 0000000..9ea1d58 --- /dev/null +++ b/backend/tests/test_alarms.py @@ -0,0 +1,274 @@ +import pytest + +import config +from alarms import AlarmActionError, AlarmManager, AlarmState +from simulator import FacilitySimulator + +T0 = 1_760_000_000.0 + +HI = { + "tag": "PDU-1.LOAD.HI", "meter_id": "PDU-1", "parameter": "load_pct", "kind": "HI", + "setpoint": 80.0, "deadband": 3.0, "priority": 3, "on_delay": 0, "off_delay": 0, + "message": "PDU load high", "unit": "%", +} +LO = { + "tag": "UTIL.V.LO", "meter_id": "UTIL", "parameter": "voltage", "kind": "LO", + "setpoint": 432.0, "deadband": 5.0, "priority": 3, "on_delay": 0, "off_delay": 0, + "message": "Voltage low", + "suppress": {"meter_id": "UTIL", "parameter": "failed", "equals": True, "reason": "Utility failed"}, +} +DISCRETE = { + "tag": "UPS.ONBATT", "meter_id": "UPS", "parameter": "on_battery", "kind": "DISCRETE", + "setpoint": 1, "deadband": 0, "priority": 1, "on_delay": 0, "off_delay": 0, + "message": "UPS on battery", +} + + +class Harness: + def __init__(self, *defs): + self.mgr = AlarmManager(defs, {}) + self.events = [] + self.mgr.add_listener(self.events.append) + self.t = T0 + + def scan(self, **values): + """values: meter_id -> dict of parameters""" + self.t += 2 + return self.mgr.evaluate(values, self.t) + + def load(self, pct): + return self.scan(**{"PDU-1": {"load_pct": pct}}) + + def state(self, tag="PDU-1.LOAD.HI"): + return self.mgr.points[tag].state + + def kinds(self): + return [e["event"] for e in self.events] + + +def test_alarm_ack_then_clear(): + h = Harness(HI) + h.load(50) + assert h.state() == AlarmState.NORMAL + assert h.load(85) is True + assert h.state() == AlarmState.UNACK + h.mgr.acknowledge(HI["tag"], h.t, "jdoe") + assert h.state() == AlarmState.ACKED + assert h.mgr.points[HI["tag"]].ack_by == "jdoe" + h.load(70) + assert h.state() == AlarmState.NORMAL + assert h.kinds() == ["ALARM", "ACK", "RTN"] + + +def test_return_to_normal_unacknowledged_then_ack(): + h = Harness(HI) + h.load(85) + h.load(70) + assert h.state() == AlarmState.RTN_UNACK + item = h.mgr.alarm_list()[0] + assert item["state_label"] == "Returned to normal, unacknowledged" + h.mgr.acknowledge(HI["tag"], h.t) + assert h.state() == AlarmState.NORMAL + assert h.mgr.alarm_list() == [] + assert h.kinds() == ["ALARM", "RTN", "ACK"] + + +def test_rtn_unack_realarms(): + h = Harness(HI) + h.load(85) + h.load(70) + h.load(88) + assert h.state() == AlarmState.UNACK + assert h.kinds() == ["ALARM", "RTN", "ALARM"] + + +def test_ack_is_rejected_when_nothing_to_acknowledge(): + h = Harness(HI) + h.load(50) + with pytest.raises(AlarmActionError): + h.mgr.acknowledge(HI["tag"], h.t) + h.load(85) + h.mgr.acknowledge(HI["tag"], h.t) + with pytest.raises(AlarmActionError): + h.mgr.acknowledge(HI["tag"], h.t) + with pytest.raises(KeyError): + h.mgr.acknowledge("NOPE", h.t) + + +def test_deadband_prevents_chatter(): + h = Harness(HI) + h.load(81) + h.mgr.acknowledge(HI["tag"], h.t) + for v in (79.5, 80.5, 78.0, 79.9, 77.5): # inside the 3% deadband + h.load(v) + assert h.state() == AlarmState.ACKED + h.load(76.9) + assert h.state() == AlarmState.NORMAL + assert h.kinds().count("ALARM") == 1 + + +def test_on_and_off_delay(): + h = Harness({**HI, "on_delay": 2, "off_delay": 1}) + h.load(85) + h.load(85) + assert h.state() == AlarmState.NORMAL + h.load(70) # a single normal scan resets the on delay + h.load(85) + h.load(85) + assert h.state() == AlarmState.NORMAL + h.load(85) + assert h.state() == AlarmState.UNACK + h.load(70) + assert h.state() == AlarmState.UNACK + h.load(70) + assert h.state() == AlarmState.RTN_UNACK + + +def test_lo_and_discrete_kinds(): + h = Harness(LO, DISCRETE) + h.scan(UTIL={"voltage": 480, "failed": False}, UPS={"on_battery": False}) + assert h.state("UTIL.V.LO") == AlarmState.NORMAL + assert h.state("UPS.ONBATT") == AlarmState.NORMAL + h.scan(UTIL={"voltage": 410, "failed": False}, UPS={"on_battery": True}) + assert h.state("UTIL.V.LO") == AlarmState.UNACK + assert h.state("UPS.ONBATT") == AlarmState.UNACK + h.scan(UTIL={"voltage": 434, "failed": False}, UPS={"on_battery": False}) + assert h.state("UTIL.V.LO") == AlarmState.UNACK # within deadband + assert h.state("UPS.ONBATT") == AlarmState.RTN_UNACK + + +def test_missing_value_holds_state(): + h = Harness(HI) + h.load(85) + h.scan() # meter offline + h.scan(**{"PDU-1": {"load_pct": None}}) + assert h.state() == AlarmState.UNACK + assert h.kinds() == ["ALARM"] + + +def test_shelve_hides_alarm_and_expires(): + h = Harness(HI) + h.load(85) + h.mgr.shelve(HI["tag"], 5, h.t, "jdoe", "Known overload during migration") + assert h.state() == AlarmState.SHELVED + p = h.mgr.points[HI["tag"]] + assert p.shelve_reason == "Known overload during migration" + summary = h.mgr.summary(h.t) + assert summary["shelved"] == 1 and summary["annunciated"] == 0 + + h.load(95) + assert h.state() == AlarmState.SHELVED # no annunciation while shelved + h.t += 5 * 60 + h.load(95) + assert h.state() == AlarmState.UNACK # timer expired, condition still active + assert h.kinds() == ["ALARM", "SHELVE", "UNSHELVE", "ALARM"] + assert h.events[2]["note"] == "Shelve time expired" + + +def test_unshelve_when_condition_cleared_returns_to_normal(): + h = Harness(HI) + h.load(85) + h.mgr.shelve(HI["tag"], 15, h.t) + h.load(60) + h.mgr.unshelve(HI["tag"], h.t) + assert h.state() == AlarmState.NORMAL + with pytest.raises(AlarmActionError): + h.mgr.unshelve(HI["tag"], h.t) + + +def test_shelve_limits(): + h = Harness(HI) + h.load(85) + with pytest.raises(AlarmActionError): + h.mgr.shelve(HI["tag"], config.SHELVE_MAX_MIN + 1, h.t) + with pytest.raises(AlarmActionError): + h.mgr.shelve(HI["tag"], 0, h.t) + h.mgr.shelve(HI["tag"], 5, h.t) + with pytest.raises(AlarmActionError): + h.mgr.shelve(HI["tag"], 5, h.t) + + +def test_designed_suppression(): + h = Harness(LO) + h.scan(UTIL={"voltage": 480, "failed": False}) + h.scan(UTIL={"voltage": 0, "failed": True}) + assert h.state("UTIL.V.LO") == AlarmState.SUPPRESSED + assert h.mgr.points["UTIL.V.LO"].suppress_reason == "Utility failed" + assert h.kinds() == ["SUPPRESS"] + # Utility back at normal voltage: return to service without a stale alarm + h.scan(UTIL={"voltage": 481, "failed": False}) + assert h.state("UTIL.V.LO") == AlarmState.NORMAL + assert h.kinds() == ["SUPPRESS", "UNSUPPRESS"] + # Utility back but low: return to service straight into alarm + h.scan(UTIL={"voltage": 0, "failed": True}) + h.scan(UTIL={"voltage": 420, "failed": False}) + assert h.state("UTIL.V.LO") == AlarmState.UNACK + + +def test_starting_suppressed_is_silent(): + h = Harness(LO) + h.scan(UTIL={"voltage": 0, "failed": True}) + assert h.state("UTIL.V.LO") == AlarmState.SUPPRESSED + assert h.events == [] + + +def test_summary_rate_and_priorities(): + h = Harness(HI, DISCRETE) + h.scan(**{"PDU-1": {"load_pct": 85}}, UPS={"on_battery": True}) + s = h.mgr.summary(h.t) + assert s["annunciated"] == 2 and s["unacknowledged"] == 2 + assert s["by_priority"]["1"] == 1 and s["by_priority"]["3"] == 1 + assert s["highest_unacked"]["tag"] == "UPS.ONBATT" + assert s["alarm_rate_10min"] == 2 and s["rate_status"] == "Manageable" + + # Chatter between the states to build up a flood + for _ in range(10): + h.scan(**{"PDU-1": {"load_pct": 60}}, UPS={"on_battery": False}) + h.scan(**{"PDU-1": {"load_pct": 85}}, UPS={"on_battery": True}) + assert h.mgr.summary(h.t)["rate_status"] == "Flood" + # Older than 10 minutes drops out of the rate window + assert h.mgr.summary(h.t + 601)["alarm_rate_10min"] == 0 + + +def test_alarm_list_orders_by_priority(): + h = Harness(HI, DISCRETE) + h.scan(**{"PDU-1": {"load_pct": 85}}, UPS={"on_battery": True}) + tags = [a["tag"] for a in h.mgr.alarm_list()] + assert tags == ["UPS.ONBATT", "PDU-1.LOAD.HI"] + + +def test_configured_alarm_database_is_valid(): + tags = [d["tag"] for d in config.ALARM_POINTS] + assert len(tags) == len(set(tags)) + for d in config.ALARM_POINTS: + assert d["meter_id"] in config.METERS_BY_ID + assert d["priority"] in config.ALARM_PRIORITIES + assert d["kind"] in ("HI", "HIHI", "LO", "LOLO", "DISCRETE") + assert d["response"] and d["consequence"] + + +def test_outage_through_real_alarm_database(): + """The simulator and the configured alarms together tell the outage story.""" + sim = FacilitySimulator(seed=5, auto_events=False) + names = {m["id"]: m["name"] for m in config.METERS} + mgr = AlarmManager(config.ALARM_POINTS, names) + events = [] + mgr.add_listener(events.append) + + t = T0 + for _ in range(10): + mgr.evaluate(sim.step(t), t) + t += 2 + assert events == [] # quiet plant, no standing alarms + + sim.trigger("utility_outage", t, duration=40) + for _ in range(80): + mgr.evaluate(sim.step(t), t) + t += 2 + + alarmed = {e["tag"] for e in events if e["event"] == "ALARM"} + assert {"UTIL-1.FAIL", "UPS-A.ONBATT", "UPS-B.ONBATT", "ATS-1.EMERG"} <= alarmed + assert "UTIL-1.V.LO" not in alarmed # cascade suppressed by the failure alarm + assert "MECH-1.PF.LO" not in alarmed # no nuisance alarm from a de-energized panel + states = {a["tag"]: a["state"] for a in mgr.alarm_list()} + assert states.get("UPS-A.ONBATT") == "RTN_UNACK" # ran on battery, nobody acked diff --git a/backend/tests/test_api_websocket.py b/backend/tests/test_api_websocket.py new file mode 100644 index 0000000..5ee387e --- /dev/null +++ b/backend/tests/test_api_websocket.py @@ -0,0 +1,131 @@ +import json +import time + +import pytest +from fastapi.testclient import TestClient + +from main import app + + +@pytest.fixture(scope="module") +def client(): + with TestClient(app) as c: + yield c + + +def receive_until(ws, predicate, limit=200): + """Read messages until one matches. Returns that message.""" + for _ in range(limit): + msg = json.loads(ws.receive_text()) + if predicate(msg): + return msg + raise AssertionError("expected message never arrived") + + +def test_health(client): + body = client.get("/api/health").json() + assert body["status"] == "ok" + assert body["data_source"] == "simulator" + assert body["version"] == "1.1.0" + + +def test_websocket_sends_snapshot_on_connect(client): + with client.websocket_connect("/ws/live") as ws: + msg = json.loads(ws.receive_text()) + assert msg["type"] == "snapshot" + data = msg["data"] + assert len(data["meters"]) == 11 + assert {r["meter_id"] for r in data["readings"]} == {m["id"] for m in data["meters"]} + assert data["demo"]["simulated"] is True + assert {s["name"] for s in data["demo"]["scenarios"]} >= {"utility_outage", "voltage_sag"} + assert "summary" in data and "alarms" in data + assert data["system"]["health"] in ("normal", "caution", "warning", "critical") + + +def test_websocket_streams_readings(client): + with client.websocket_connect("/ws/live") as ws: + ws.receive_text() # snapshot + first = receive_until(ws, lambda m: m["type"] == "readings") + second = receive_until(ws, lambda m: m["type"] == "readings") + assert len(first["data"]["readings"]) == 11 + assert second["data"]["readings"][0]["epoch"] > first["data"]["readings"][0]["epoch"] + assert first["data"]["system"]["facility_kw"] > 0 + + +def test_websocket_ping_pong(client): + with client.websocket_connect("/ws/live") as ws: + ws.receive_text() + ws.send_text(json.dumps({"type": "ping"})) + receive_until(ws, lambda m: m["type"] == "pong") + + +def test_alarm_lifecycle_over_rest_and_websocket(client): + """Trigger an outage, see the alarm arrive live, acknowledge, shelve, unshelve.""" + with client.websocket_connect("/ws/live") as ws: + ws.receive_text() + r = client.post("/api/demo/events/utility_outage") + assert r.status_code == 200 and r.json()["name"] == "utility_outage" + assert client.post("/api/demo/events/voltage_sag").status_code == 409 + + def has_state(tag, state): + def check(m): + return m["type"] == "alarms" and any( + a["tag"] == tag and a["state"] == state for a in m["data"]["alarms"]) + return check + + msg = receive_until(ws, has_state("UPS-A.ONBATT", "UNACK")) + summary = msg["data"]["summary"] + assert summary["highest_unacked"]["priority"] == 1 + + r = client.post("/api/alarms/UPS-A.ONBATT/acknowledge", json={"operator": "tester"}) + assert r.status_code == 200 + assert r.json()["state"] in ("ACKED", "NORMAL") + receive_until(ws, lambda m: m["type"] == "alarms" and all( + a["state"] != "UNACK" for a in m["data"]["alarms"] if a["tag"] == "UPS-A.ONBATT")) + + r = client.post("/api/alarms/UTIL-1.FAIL/shelve", + json={"minutes": 5, "reason": "Utility crew on site", "operator": "tester"}) + assert r.status_code == 200 and r.json()["state"] == "SHELVED" + receive_until(ws, has_state("UTIL-1.FAIL", "SHELVED")) + assert client.post("/api/alarms/UTIL-1.FAIL/unshelve").json()["state"] != "SHELVED" + + history = client.get("/api/alarms/history?limit=500").json() + kinds = {(e["tag"], e["event"]) for e in history} + assert ("UPS-A.ONBATT", "ALARM") in kinds + assert ("UPS-A.ONBATT", "ACK") in kinds + assert ("UTIL-1.FAIL", "SHELVE") in kinds + assert ("UTIL-1.FAIL", "UNSHELVE") in kinds + ack = next(e for e in history if e["tag"] == "UPS-A.ONBATT" and e["event"] == "ACK") + assert ack["operator"] == "tester" + + +def test_alarm_action_errors(client): + assert client.post("/api/alarms/NOPE/acknowledge").status_code == 404 + assert client.post("/api/alarms/MSB-1.LOAD.HI/acknowledge").status_code == 409 + assert client.post("/api/alarms/MSB-1.LOAD.HI/shelve", json={"minutes": 999}).status_code == 422 + assert client.post("/api/alarms/MSB-1.LOAD.HI/unshelve").status_code == 409 + + +def test_meters_and_history(client): + meters = client.get("/api/meters").json() + assert len(meters) == 11 + by_id = {m["id"]: m for m in meters} + # PDUs sit behind the UPS, so they stay energized even if an outage is in progress + assert by_id["PDU-A1"]["current_reading"]["voltage"] > 200 + assert client.get("/api/meters/UPS-A").json()["type"] == "ups" + assert client.get("/api/meters/NOPE").status_code == 404 + hist = client.get("/api/meters/MSB-1/history?period=1h").json() + assert len(hist) > 30 # backfilled on startup + epochs = [h["epoch"] for h in hist] + assert epochs == sorted(epochs) + assert time.time() - epochs[-1] < 120 + assert client.get("/api/meters/MSB-1/history?period=2y").status_code == 400 + + +def test_definitions_and_config(client): + defs = client.get("/api/alarms/definitions").json() + assert any(d["suppression"] for d in defs) + cfg = client.get("/api/config").json() + assert cfg["data_source"] == "simulator" + assert cfg["shelve_max_min"] == 60 + assert client.post("/api/demo/events/nope").status_code == 404 diff --git a/backend/tests/test_modbus.py b/backend/tests/test_modbus.py new file mode 100644 index 0000000..3fd1b1a --- /dev/null +++ b/backend/tests/test_modbus.py @@ -0,0 +1,71 @@ +import asyncio +import struct + +from modbus_source import ModbusSource, decode + + +def float_regs(value): + hi, lo = struct.unpack(">HH", struct.pack(">f", value)) + return [hi, lo] + + +def test_decode_types(): + assert decode([0xFFFF], "int16") == -1 + assert decode([0xFFFF], "uint16") == 65535 + assert abs(decode(float_regs(480.5), "float32") - 480.5) < 1e-4 + assert abs(decode(list(reversed(float_regs(59.98))), "float32", "little") - 59.98) < 1e-4 + assert decode([0x0001, 0x0000], "uint32") == 65536 + assert decode([1], "bool") is True + + +class FakeResult: + def __init__(self, registers=None, bits=None, error=False): + self.registers, self.bits, self._error = registers, bits, error + + def isError(self): + return self._error + + +class FakeClient: + def __init__(self, fail=False): + self.connected = False + self.fail = fail + + async def connect(self): + self.connected = True + + async def read_holding_registers(self, address, count, slave): + if self.fail: + return FakeResult(error=True) + return FakeResult(registers={3000: float_regs(481.0), 3002: float_regs(800.0)}[address]) + + async def read_discrete_inputs(self, address, count, slave): + return FakeResult(bits=[True]) + + def close(self): + pass + + +def make_source(fail=False): + cfg = {"devices": [{ + "meter_id": "UTIL-1", "host": "meter.example.internal", "unit_id": 1, + "registers": { + "voltage": {"address": 3000, "type": "float32"}, + "current": {"address": 3002, "type": "float32"}, + }, + }]} + return ModbusSource(cfg, client_factory=lambda host, port, timeout: FakeClient(fail)) + + +def test_modbus_reading_shape(): + out = asyncio.run(make_source().read_all(1_760_000_000.0)) + r = out["UTIL-1"] + assert r["voltage"] == 481.0 and r["current"] == 800.0 + assert r["load_pct"] == 50.0 # 800 A on a 1600 A service + assert r["comm_ok"] is True and r["status"] == "Online" + + +def test_modbus_comm_failure_is_reported(): + r = asyncio.run(make_source(fail=True).read_all(1_760_000_000.0))["UTIL-1"] + assert r["comm_ok"] is False and r["status"] == "Comm Fail" + assert r["voltage"] is None diff --git a/backend/tests/test_simulator.py b/backend/tests/test_simulator.py new file mode 100644 index 0000000..ccc8c9b --- /dev/null +++ b/backend/tests/test_simulator.py @@ -0,0 +1,164 @@ +import pytest + +from config import METERS, METERS_BY_ID +from simulator import SCENARIOS, FacilitySimulator + +T0 = 1_760_000_000.0 +FIELDS = ("voltage", "current", "power_kw", "power_factor", "frequency", + "energy_kwh", "demand_kw", "load_pct", "status", "timestamp", "epoch") + + +def run(sim, start, seconds, step=1.0): + """Step the simulator and return the list of scans.""" + out, t = [], start + while t < start + seconds: + out.append(sim.step(t)) + t += step + return out + + +def quiet_sim(seed=1): + return FacilitySimulator(seed=seed, auto_events=False) + + +def test_every_meter_reports_all_fields(): + r = quiet_sim().step(T0) + assert set(r) == {m["id"] for m in METERS} + for mid, reading in r.items(): + assert reading["meter_id"] == mid + for f in FIELDS: + assert f in reading, f"{mid} missing {f}" + + +def test_normal_operation_values_are_believable(): + sim = quiet_sim() + for scan in run(sim, T0, 600, step=2): + for mid, r in scan.items(): + cfg = METERS_BY_ID[mid] + if cfg["type"] == "generator": + assert r["status"] == "Standby" and r["voltage"] == 0 + continue + assert abs(r["voltage"] - cfg["nominal_voltage"]) / cfg["nominal_voltage"] < 0.03 + assert 59.9 < r["frequency"] < 60.1 + assert 5 < r["load_pct"] < 75 + assert 0.8 < r["power_factor"] <= 1.0 + assert scan["ATS-1"]["position"] == "UTILITY" + assert not scan["UPS-A"]["on_battery"] and not scan["UPS-B"]["on_battery"] + + +def test_power_balances_through_the_hierarchy(): + r = quiet_sim().step(T0) + pdu_a = r["PDU-A1"]["power_kw"] + r["PDU-A2"]["power_kw"] + # UPS output covers its PDUs plus transformer losses + assert pdu_a < r["UPS-A"]["power_kw"] < pdu_a * 1.05 + # Switchboard carries both UPS inputs, cooling, and house load + floor = r["UPS-A"]["input_kw"] + r["UPS-B"]["input_kw"] + r["MECH-1"]["power_kw"] + assert floor < r["MSB-1"]["power_kw"] < floor + 25 + # On utility, the ATS and the utility meter see the switchboard load + assert r["UTIL-1"]["power_kw"] == r["ATS-1"]["power_kw"] == r["MSB-1"]["power_kw"] + # A plausible PUE for a small facility + it = sum(r[p]["power_kw"] for p in ("PDU-A1", "PDU-A2", "PDU-B1", "PDU-B2")) + assert 1.3 < r["MSB-1"]["power_kw"] / it < 2.0 + + +def test_values_move_and_energy_accumulates(): + scans = run(quiet_sim(), T0, 300, step=2) + kw = {s["MSB-1"]["power_kw"] for s in scans} + assert len(kw) > 20 + energy = [s["UTIL-1"]["energy_kwh"] for s in scans] + assert energy == sorted(energy) and energy[-1] > energy[0] + + +def test_same_seed_is_repeatable(): + a = run(quiet_sim(seed=42), T0, 60, step=2) + b = run(quiet_sim(seed=42), T0, 60, step=2) + assert a == b + + +def test_utility_outage_sequence(): + sim = quiet_sim() + sim.step(T0) + sim.trigger("utility_outage", T0 + 1, duration=60) + scans = run(sim, T0 + 1, 200, step=1) + + def first(pred): + return next(i for i, s in enumerate(scans) if pred(s)) + + lost = first(lambda s: s["UTIL-1"]["source_failed"]) + on_batt = first(lambda s: s["UPS-A"]["on_battery"] and s["UPS-B"]["on_battery"]) + cranking = first(lambda s: s["GEN-1"]["gen_state"] == "CRANKING") + running = first(lambda s: s["GEN-1"]["gen_state"] == "RUNNING") + on_gen = first(lambda s: s["ATS-1"]["position"] == "GENERATOR") + ups_back = first(lambda s: s["ATS-1"]["position"] == "GENERATOR" and not s["UPS-A"]["on_battery"]) + assert lost == on_batt == 0 + assert 0 < cranking < running < on_gen < ups_back + + # While on battery the critical IT load is still served + assert scans[on_batt]["PDU-A1"]["power_kw"] > 50 + assert scans[on_batt]["MSB-1"]["power_kw"] == 0 + # The block load step dips generator frequency + assert scans[on_gen]["GEN-1"]["frequency"] < 59.5 + # On generator, the generator meter carries the facility + assert scans[ups_back + 5]["GEN-1"]["power_kw"] > 300 + assert scans[ups_back + 5]["UTIL-1"]["power_kw"] == 0 + + back = first(lambda s: not s["UTIL-1"]["source_failed"]) + retransfer = first(lambda s: s["ATS-1"]["position"] == "UTILITY" and scans.index(s) > on_gen) + assert retransfer - back >= 29 # retransfer delay + assert scans[-1]["GEN-1"]["gen_state"] == "STANDBY" + assert sim.scenario is None + assert scans[-1]["UPS-A"]["battery_soc"] < 100 # still recharging + + +def test_voltage_sag_rides_through_on_battery_without_generator(): + sim = quiet_sim() + sim.step(T0) + sim.trigger("voltage_sag", T0 + 1, duration=5, depth=0.85) + scans = run(sim, T0 + 1, 20) + assert min(s["UTIL-1"]["voltage"] for s in scans) < 432 + assert any(s["UPS-A"]["on_battery"] for s in scans) + assert all(s["GEN-1"]["gen_state"] == "STANDBY" for s in scans) + assert not scans[-1]["UPS-A"]["on_battery"] + + +def test_pdu_overload_crosses_alarm_limits(): + sim = quiet_sim() + sim.step(T0) + sim.trigger("pdu_overload", T0 + 1, pdu="PDU-B2", target_pct=93, hold=60) + scans = run(sim, T0 + 1, 160, step=2) + peak = max(s["PDU-B2"]["load_pct"] for s in scans) + assert peak > 90 + assert max(s["PDU-A1"]["load_pct"] for s in scans) < 75 + assert scans[-1]["PDU-B2"]["load_pct"] < 75 + + +def test_power_factor_drop(): + sim = quiet_sim() + sim.step(T0) + sim.trigger("pf_drop", T0 + 1, duration=60, pf=0.79) + scans = run(sim, T0 + 1, 80, step=2) + assert min(s["MECH-1"]["power_factor"] for s in scans) < 0.82 + assert scans[-1]["MECH-1"]["power_factor"] > 0.85 + + +def test_trigger_validation(): + sim = quiet_sim() + sim.step(T0) + with pytest.raises(KeyError): + sim.trigger("meteor_strike", T0) + sim.trigger("pf_drop", T0) + with pytest.raises(RuntimeError): + sim.trigger("voltage_sag", T0 + 1) + + +def test_automatic_events_fire_and_finish(): + sim = FacilitySimulator(seed=3, auto_events=True, event_interval=(20, 30), first_event_delay=5) + seen = set() + t = T0 + for _ in range(1500): + sim.step(t) + if sim.scenario: + seen.add(sim.scenario["name"]) + t += 2 + assert seen <= set(SCENARIOS) + assert len(seen) >= 3 diff --git a/frontend/.env.example b/frontend/.env.example index 5934e2e..d42e601 100644 --- a/frontend/.env.example +++ b/frontend/.env.example @@ -1 +1,6 @@ -VITE_API_URL=http://localhost:8000 +# Base URL of the backend API, read at build time. +# Optional: a production build without it uses the live demo API +# (https://power-dashboard-api.onrender.com). The dev server without it +# proxies /api and /ws to http://localhost:8000. +# VITE_API_URL=https://your-api.example.com +VITE_API_URL= diff --git a/frontend/index.html b/frontend/index.html index 67b1b2f..b43a095 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -2,14 +2,25 @@ - - Power Dashboard | EPMS - - - + + Power Dashboard | Data Center Power Monitoring + + + + + + + + + -
+
⚡ Loading Power Dashboard...
+ diff --git a/frontend/package-lock.json b/frontend/package-lock.json new file mode 100644 index 0000000..1a3e166 --- /dev/null +++ b/frontend/package-lock.json @@ -0,0 +1,2532 @@ +{ + "name": "power-dashboard", + "version": "1.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "power-dashboard", + "version": "1.1.0", + "dependencies": { + "lucide-react": "^0.379.0", + "react": "^18.3.1", + "react-dom": "^18.3.1", + "react-router-dom": "^6.23.1", + "recharts": "^2.12.7" + }, + "devDependencies": { + "@vitejs/plugin-react": "^4.3.0", + "vite": "^5.2.0", + "vitest": "^2.1.9" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", + "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.29.7", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.7.tgz", + "integrity": "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.7.tgz", + "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-compilation-targets": "^7.29.7", + "@babel/helper-module-transforms": "^7.29.7", + "@babel/helpers": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/generator": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.8.tgz", + "integrity": "sha512-gZbepsdh3WDtgZKWL+vTPh71LSBrm/Y4/QDZBVCcYfmeTEEuoOYwlSy+G1StfJg+/Zy550u/3TATbm7qDbbMtg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.8", + "@babel/types": "^7.29.8", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.29.7.tgz", + "integrity": "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.29.7", + "@babel/helper-validator-option": "^7.29.7", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.29.7.tgz", + "integrity": "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.29.7.tgz", + "integrity": "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.29.7.tgz", + "integrity": "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7", + "@babel/traverse": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-plugin-utils": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.29.7.tgz", + "integrity": "sha512-G7sHYigPY17oO5SYWnfD/0MTBwVR781S/JI643e/JhUYgVgWE/61SoW3NH9KWUKyKq5LVh3npif99Wkt6j86Jw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.29.7.tgz", + "integrity": "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.29.10", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.10.tgz", + "integrity": "sha512-EMCx3AGZ3WfAl6ht7AyQoPm2Lh+BGYEMdc4Q6Hf7uSCDOZFuLq44qNwL+iaim8VlyYhRAo4X43+weadK8ZB4Yg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.8" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.9", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.9.tgz", + "integrity": "sha512-CjXrNHTnvqBVqHgdBysY3vk2T8tpJHb5/RMeHJBTyVa9xgugCB0CJTx/3oO8RV2QRQP391RWpB7D6hLjm8V9uA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.8" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-self": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-self/-/plugin-transform-react-jsx-self-7.29.7.tgz", + "integrity": "sha512-TL0hMc9xzy86VD31nUiwzd5otRAcyEPcsegCxolO0PvcXuH1v0kECe/UIznYFihpkvU5wg/jk4v0TTEFfm53fw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/plugin-transform-react-jsx-source": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-source/-/plugin-transform-react-jsx-source-7.29.7.tgz", + "integrity": "sha512-06IyK09H3wi4cGbhDBwp5gUGo0IKtnYa8tyTiephirPCK6fbobVGiXMMI5zLQ4aKEYP3wZ3ArU44o+8KMrSG/Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-plugin-utils": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0-0" + } + }, + "node_modules/@babel/runtime": { + "version": "7.29.10", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.10.tgz", + "integrity": "sha512-NpugTzbKgGrNe45Ui32cN8vCFFMxVtP/9rtfwv0nP5FRDAjvYBxJcLVpYA9mr1jxP6cnIT7x9LUzuDTbWYYNRA==", + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/template": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz", + "integrity": "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.29.10", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.10.tgz", + "integrity": "sha512-B3lYAs2nkKskbgQ1nfy8Ma9ao9Q1rntTAQHrzzyDuj+TSO+X3le6PfWwqDvodsb+OumE10eMFfuMCTQSQ5iuIQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.8", + "@babel/helper-globals": "^7.29.7", + "@babel/parser": "^7.29.9", + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.8", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.8", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.8.tgz", + "integrity": "sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@esbuild/aix-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", + "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "aix" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz", + "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", + "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/android-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz", + "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", + "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/darwin-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", + "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", + "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/freebsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", + "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", + "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", + "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", + "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-loong64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", + "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-mips64el": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", + "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", + "cpu": [ + "mips64el" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-ppc64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", + "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-riscv64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", + "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-s390x": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", + "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/linux-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", + "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/netbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", + "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "netbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/openbsd-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", + "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/sunos-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", + "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "sunos" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-arm64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", + "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-ia32": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", + "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@esbuild/win32-x64": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", + "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">=12" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.6.0.tgz", + "integrity": "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@napi-rs/lzma-linux-x64-gnu": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/@napi-rs/lzma-linux-x64-gnu/-/lzma-linux-x64-gnu-1.5.1.tgz", + "integrity": "sha512-oTXEIha4SsuXdTA4Iyskj0kpdx2yVXdhd75c2v3xGrHFfVMsbhTPZU/nMPL4sWKo4pBHm3aucLaqGlF696dTyQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^22.20 || ^24.12 || >=25" + } + }, + "node_modules/@remix-run/router": { + "version": "1.23.4", + "resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.23.4.tgz", + "integrity": "sha512-q7j5geK7xs3UJSdm9/iytUNclBnLmYx1EnSeCFXHPeutdqgIMeFeHtUZgS3EhlKxdBEAu8OwtJCwmLrEzpSs7Q==", + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.0-beta.27", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.0-beta.27.tgz", + "integrity": "sha512-+d0F4MKMCbeVUJwG96uQ4SgAznZNSq93I3V+9NHA4OpvqG8mRCpGdKmK8l/dl02h2CCDHwW2FqilnTyDcAnqjA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@rollup/rollup-android-arm-eabi": { + "version": "4.64.7", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.64.7.tgz", + "integrity": "sha512-GFF2tHk0NqYVObrK0+/ps/LEXsV/v2q6Lyiy7mZr+Ej24fpQScvqK2ziz9NHeEQYnKWKym38Ml7dZbPtC9tuAw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-android-arm64": { + "version": "4.64.7", + "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.64.7.tgz", + "integrity": "sha512-BOymcXJFYYXqluDidj6sLelNjXruC3u1BqVxLn2OPCXH9xaPSOTH+MS4uychsGI1NU7tJoiv9KvqijDHFe+hhQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@rollup/rollup-darwin-arm64": { + "version": "4.64.7", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.64.7.tgz", + "integrity": "sha512-jgVpGweIbls2H5jfBB70tnX52abMd5pJr3xmtoDILx6JdmGl+Un+tMb9R+MbHPVB46tTBpY8emtn7M71SnQhEQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-darwin-x64": { + "version": "4.64.7", + "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.64.7.tgz", + "integrity": "sha512-AVMpt94mrXH7WjyWKmd9nT4RC5yM0AN3Lt6A89xVIsuLDi3dDX6dxMytLbW36FlppY9JsoqUoS3LUUpUrYxiMQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@rollup/rollup-freebsd-arm64": { + "version": "4.64.7", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.64.7.tgz", + "integrity": "sha512-Nje29c/MyajxRNFGU05oyeTDS6SWdMg/wWMSlZSBTBju5BKz2NVc0kFiFSQGzRELNiB+CBk/ZvAVsWsENgjGag==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-freebsd-x64": { + "version": "4.64.7", + "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.64.7.tgz", + "integrity": "sha512-xxgN5/kMzCNz/NYJ4t49B0/WS6InNlWv6xvgwUQqNnWM9WJMMwMUGWrAxdNEgYWdHL0sMnuh8I87StGrIMTA+w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@rollup/rollup-linux-arm-gnueabihf": { + "version": "4.64.7", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.64.7.tgz", + "integrity": "sha512-5U/9gVn/X657I8+NHoHO9Wnfyd0lEXwqbslobRoB9TC8SwYzOitcIr6TLE7531Q/NWs3L4mb+YOGzlf3vHIeYw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm-musleabihf": { + "version": "4.64.7", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.64.7.tgz", + "integrity": "sha512-hcDS0a/Y8ggM3dcOfzH7UpSaogCViWTtNDSl0EXpWIj8g8/r86ITqptjil2hayTXS0Umzb+Yt3mjgpQVFVf2+A==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-gnu": { + "version": "4.64.7", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.64.7.tgz", + "integrity": "sha512-awbAr4N7Qd/yjZAQBEEPzgcPveYG2Gi5L6gq1/Bp+3SMRjn5F68Wv1zKsntzvvoioBR6yKvhLtWqu11am1TsxA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-arm64-musl": { + "version": "4.64.7", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.64.7.tgz", + "integrity": "sha512-Ut7xQJfeLhzu2yN3rHzVvL9ccyPJvN4aCdP1V7hJ5lGxX/eCzgB9SiMk/Md3J+rWWZgvoYsDW4BUFggJr32pBA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-gnu": { + "version": "4.64.7", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.64.7.tgz", + "integrity": "sha512-trqoni/vHkuM5oo5iQhoKPh6fjjD7E80pMgCXWgGxU8vSRPUlwvG8GeYvEEcB6eJAxNa99WH9iVHPHelgtKiHA==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-loong64-musl": { + "version": "4.64.7", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.64.7.tgz", + "integrity": "sha512-gvlUy0XFIwtUWiQmhc+4GT2Knm3KosinpUVhr7CtTFA78gFDnE5NWwMPzHrJsYpKkh2LlY7yvBIboN1FAV6Xeg==", + "cpu": [ + "loong64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-gnu": { + "version": "4.64.7", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.64.7.tgz", + "integrity": "sha512-8yS/kSXTJ/47Z62JW+C2AJ3vd/8zZw016vFjtNpZwmZlwJumLua4s1i78dkYuSSqQ+0aIMxJcBp/4uIwp2ehjA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-ppc64-musl": { + "version": "4.64.7", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.64.7.tgz", + "integrity": "sha512-mxVSKvvty0hOP1AFo/l4Kpv3i3HK0zx4rYUrirZDToYlWhS3xzmIkq3vUJ7rtrM2cPyXveDyDT6M1FfXZ9hTRA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-gnu": { + "version": "4.64.7", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.64.7.tgz", + "integrity": "sha512-lEse1zra1CagM+uHH2bl28GInDsZZ45NJzHei2OMaXQFzZWKBO1hFo07rS0xB6ayV4WU360UrSmPDSyCMpYsUQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-riscv64-musl": { + "version": "4.64.7", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.64.7.tgz", + "integrity": "sha512-d/cqCoiG9WMZ5VuJZ5Qdlv9g97QyoGwnmvHccvGhKSacTy7xFrAS+jMcJSBdMNOw1oacSkl6p7K6O/DRYTKL3g==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-s390x-gnu": { + "version": "4.64.7", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.64.7.tgz", + "integrity": "sha512-kVEA//CeDOZZP1XCH2JBgqXb9+ALlwuA/4civaazMVdgzaSQC9WM38w65zG/LLk0lA7F9Od19CSBZW7J9mCbNg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-gnu": { + "version": "4.64.7", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.64.7.tgz", + "integrity": "sha512-14oLfDsOSr0TSntT/34jRg/V8B2XW/Jomh9zX9CjIM9gwwNlI1OCymS/GVn0DV32h67eRf7U0GGem0rvfXgNwQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-linux-x64-musl": { + "version": "4.64.7", + "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.64.7.tgz", + "integrity": "sha512-dFVtPfjZqAnUPpPY4Bfg+87yo1IBW9HiY8iCFtg49Pn46MfZTebSHZh8PL6SOsgyt9LQP8Nj/sE6jm3aWpySmQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@rollup/rollup-openbsd-x64": { + "version": "4.64.7", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.64.7.tgz", + "integrity": "sha512-Dm9CZ2SM3u2NgvBj4awW/gGqkOwzH8f6MSYIJE6+bKcKLI+T0+LhpD6vtetTfrIIx+rLXDf5RJqBP6v7V5O+wg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openbsd" + ] + }, + "node_modules/@rollup/rollup-openharmony-arm64": { + "version": "4.64.7", + "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.64.7.tgz", + "integrity": "sha512-oaxsvS2mL6+78741CT2HCORBvy1USg3Wqpt5qvZwy3aMXRMl4zxyDo8maeFRWJkF3dp0Wbi71fKH5Gr9YI3vYA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ] + }, + "node_modules/@rollup/rollup-win32-arm64-msvc": { + "version": "4.64.7", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.64.7.tgz", + "integrity": "sha512-UKZz1Y4mxUqkWh60uH7B1aZn6UcAimPk4ipRktpb3wfZq7JrzZYyAXTwiafkb3T+kd5P3Sf+vzcRdsDks4WtjQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-ia32-msvc": { + "version": "4.64.7", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.64.7.tgz", + "integrity": "sha512-WWkSuC1J+mZJnsDh0mRaCAFSWfQdl6y3fDjZeWElI6zHo8CcOSG5lY3yurKJjEq+FrX0WD60sFrVVpbHqzMmrw==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-gnu": { + "version": "4.64.7", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.64.7.tgz", + "integrity": "sha512-u9bp2yXCI3/BUjUxY6uWknAY32pfvvDCPysyUTySotx0dj3ydlFHrRQYfn/b/kZgnfjyEwySMtI7Ga054uiysQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@rollup/rollup-win32-x64-msvc": { + "version": "4.64.7", + "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.64.7.tgz", + "integrity": "sha512-EhX6N3XPMEnVU5cRfNgzPOKcSucsbu3nNNWuoAaEuKIqmddBKHN5b9d4rPe65T1I+FaOT176JKiZNsUBp3rqbA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@types/babel__core": { + "version": "7.20.5", + "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", + "integrity": "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.20.7", + "@babel/types": "^7.20.7", + "@types/babel__generator": "*", + "@types/babel__template": "*", + "@types/babel__traverse": "*" + } + }, + "node_modules/@types/babel__generator": { + "version": "7.27.0", + "resolved": "https://registry.npmjs.org/@types/babel__generator/-/babel__generator-7.27.0.tgz", + "integrity": "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__template": { + "version": "7.4.4", + "resolved": "https://registry.npmjs.org/@types/babel__template/-/babel__template-7.4.4.tgz", + "integrity": "sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.1.0", + "@babel/types": "^7.0.0" + } + }, + "node_modules/@types/babel__traverse": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@types/babel__traverse/-/babel__traverse-7.28.0.tgz", + "integrity": "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.28.2" + } + }, + "node_modules/@types/d3-array": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/@types/d3-array/-/d3-array-3.2.2.tgz", + "integrity": "sha512-hOLWVbm7uRza0BYXpIIW5pxfrKe0W+D5lrFiAEYR+pb6w3N2SwSMaJbXdUfSEv+dT4MfHBLtn5js0LAWaO6otw==", + "license": "MIT" + }, + "node_modules/@types/d3-color": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz", + "integrity": "sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==", + "license": "MIT" + }, + "node_modules/@types/d3-ease": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-ease/-/d3-ease-3.0.2.tgz", + "integrity": "sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==", + "license": "MIT" + }, + "node_modules/@types/d3-interpolate": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-interpolate/-/d3-interpolate-3.0.4.tgz", + "integrity": "sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==", + "license": "MIT", + "dependencies": { + "@types/d3-color": "*" + } + }, + "node_modules/@types/d3-path": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/@types/d3-path/-/d3-path-3.1.1.tgz", + "integrity": "sha512-VMZBYyQvbGmWyWVea0EHs/BwLgxc+MKi1zLDCONksozI4YJMcTt8ZEuIR4Sb1MMTE8MMW49v0IwI5+b7RmfWlg==", + "license": "MIT" + }, + "node_modules/@types/d3-scale": { + "version": "4.0.9", + "resolved": "https://registry.npmjs.org/@types/d3-scale/-/d3-scale-4.0.9.tgz", + "integrity": "sha512-dLmtwB8zkAeO/juAMfnV+sItKjlsw2lKdZVVy6LRr0cBmegxSABiLEpGVmSJJ8O08i4+sGR6qQtb6WtuwJdvVw==", + "license": "MIT", + "dependencies": { + "@types/d3-time": "*" + } + }, + "node_modules/@types/d3-shape": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/@types/d3-shape/-/d3-shape-3.2.0.tgz", + "integrity": "sha512-kVd74ta9eof3eJOvbNd1vGKS/XERRyQbT26Og63hIsvDO84cjD5gEOhsXf26w3FSoNlPVz84DOFcKv/oou+fMw==", + "license": "MIT", + "dependencies": { + "@types/d3-path": "*" + } + }, + "node_modules/@types/d3-time": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/@types/d3-time/-/d3-time-3.0.4.tgz", + "integrity": "sha512-yuzZug1nkAAaBlBBikKZTgzCeA+k1uy4ZFwWANOfKw5z5LRhV0gNA7gNkKm7HoK+HRN0wX3EkxGk0fpbWhmB7g==", + "license": "MIT" + }, + "node_modules/@types/d3-timer": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@types/d3-timer/-/d3-timer-3.0.2.tgz", + "integrity": "sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==", + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@vitejs/plugin-react": { + "version": "4.7.0", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-4.7.0.tgz", + "integrity": "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.28.0", + "@babel/plugin-transform-react-jsx-self": "^7.27.1", + "@babel/plugin-transform-react-jsx-source": "^7.27.1", + "@rolldown/pluginutils": "1.0.0-beta.27", + "@types/babel__core": "^7.20.5", + "react-refresh": "^0.17.0" + }, + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "peerDependencies": { + "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" + } + }, + "node_modules/@vitest/expect": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-2.1.9.tgz", + "integrity": "sha512-UJCIkTBenHeKT1TTlKMJWy1laZewsRIzYighyYiJKZreqtdxSos/S1t+ktRMQWu2CKqaarrkeszJx1cgC5tGZw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "2.1.9", + "@vitest/utils": "2.1.9", + "chai": "^5.1.2", + "tinyrainbow": "^1.2.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-2.1.9.tgz", + "integrity": "sha512-tVL6uJgoUdi6icpxmdrn5YNo3g3Dxv+IHJBr0GXHaEdTcw3F+cPKnsXFhli6nO+f/6SDKPHEK1UN+k+TQv0Ehg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "2.1.9", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.12" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^5.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-2.1.9.tgz", + "integrity": "sha512-KhRIdGV2U9HOUzxfiHmY8IFHTdqtOhIzCpd8WRdJiE7D/HUcZVD0EgQCVjm+Q9gkUXWgBvMmTtZgIG48wq7sOQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^1.2.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-2.1.9.tgz", + "integrity": "sha512-ZXSSqTFIrzduD63btIfEyOmNcBmQvgOVsPNPe0jYtESiXkhd8u2erDLnMxmGrDCwHCCHE7hxwRDCT3pt0esT4g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "2.1.9", + "pathe": "^1.1.2" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-2.1.9.tgz", + "integrity": "sha512-oBO82rEjsxLNJincVhLhaxxZdEtV0EFHMK5Kmx5sJ6H9L183dHECjiefOAdnqpIgT5eZwT04PoggUnW88vOBNQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "2.1.9", + "magic-string": "^0.30.12", + "pathe": "^1.1.2" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-2.1.9.tgz", + "integrity": "sha512-E1B35FwzXXTs9FHNK6bDszs7mtydNi5MIfUWpceJ8Xbfb1gBMscAnwLbEu+B44ed6W3XjL9/ehLPHR1fkf1KLQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyspy": "^3.0.2" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-2.1.9.tgz", + "integrity": "sha512-v0psaMSkNJ3A2NMrUEHFRzJtDPFn+/VWZ5WxImB21T9fjucJRmS7xCS3ppEnARb9y11OAzaD+P2Ps+b+BGX5iQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "2.1.9", + "loupe": "^3.1.2", + "tinyrainbow": "^1.2.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/baseline-browser-mapping": { + "version": "2.11.29", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.29.tgz", + "integrity": "sha512-YeesxhUmR4h5y5V8KF8iBCqJb8YFrBJ++XBTeGcWIaQOYXGN19JM0FoiOjAhwuDoln8owMgXcWBmgHtYSkky2w==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/browserslist": { + "version": "4.29.3", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.29.3.tgz", + "integrity": "sha512-1R4kiYKXGViqEN0CnoDrXc1StD9niAwu+j2dukWzrD4bJgsD4lDmEp0CRbc6E/vYJIfTHwPmwyaKtVSudICdPA==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.11.26", + "caniuse-lite": "^1.0.30001813", + "electron-to-chromium": "^1.5.439", + "node-releases": "^2.0.57", + "update-browserslist-db": "^1.3.3" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/cac": { + "version": "6.7.14", + "resolved": "https://registry.npmjs.org/cac/-/cac-6.7.14.tgz", + "integrity": "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001816", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001816.tgz", + "integrity": "sha512-+8Yx7BXCoqk6IF8yTicjfUR6D/ldJzdlsodlEOiOSPmhK844WIgk67/zIQaGAOxNkZyyvBlrRFnLvqKIoqbAFw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/chai": { + "version": "5.3.3", + "resolved": "https://registry.npmjs.org/chai/-/chai-5.3.3.tgz", + "integrity": "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==", + "dev": true, + "license": "MIT", + "dependencies": { + "assertion-error": "^2.0.1", + "check-error": "^2.1.1", + "deep-eql": "^5.0.1", + "loupe": "^3.1.0", + "pathval": "^2.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/check-error": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/check-error/-/check-error-2.1.3.tgz", + "integrity": "sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 16" + } + }, + "node_modules/clsx": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", + "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "license": "MIT" + }, + "node_modules/d3-array": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/d3-array/-/d3-array-3.2.4.tgz", + "integrity": "sha512-tdQAmyA18i4J7wprpYq8ClcxZy3SC31QMeByyCFyRt7BVHdREQZ5lpzoe5mFEYZUWe+oq8HBvk9JjpibyEV4Jg==", + "license": "ISC", + "dependencies": { + "internmap": "1 - 2" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-color": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-color/-/d3-color-3.1.0.tgz", + "integrity": "sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-ease": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-ease/-/d3-ease-3.0.1.tgz", + "integrity": "sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-format": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/d3-format/-/d3-format-3.1.2.tgz", + "integrity": "sha512-AJDdYOdnyRDV5b6ArilzCPPwc1ejkHcoyFarqlPqT7zRYjhavcT3uSrqcMvsgh2CgoPbK3RCwyHaVyxYcP2Arg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-interpolate": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-interpolate/-/d3-interpolate-3.0.1.tgz", + "integrity": "sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==", + "license": "ISC", + "dependencies": { + "d3-color": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-path": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-path/-/d3-path-3.1.0.tgz", + "integrity": "sha512-p3KP5HCf/bvjBSSKuXid6Zqijx7wIfNW+J/maPs+iwR35at5JCbLUT0LzF1cnjbCHWhqzQTIN2Jpe8pRebIEFQ==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-scale": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/d3-scale/-/d3-scale-4.0.2.tgz", + "integrity": "sha512-GZW464g1SH7ag3Y7hXjf8RoUuAFIqklOAq3MRl4OaWabTFJY9PN/E1YklhXLh+OQ3fM9yS2nOkCoS+WLZ6kvxQ==", + "license": "ISC", + "dependencies": { + "d3-array": "2.10.0 - 3", + "d3-format": "1 - 3", + "d3-interpolate": "1.2.0 - 3", + "d3-time": "2.1.1 - 3", + "d3-time-format": "2 - 4" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-shape": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/d3-shape/-/d3-shape-3.2.0.tgz", + "integrity": "sha512-SaLBuwGm3MOViRq2ABk3eLoxwZELpH6zhl3FbAoJ7Vm1gofKx6El1Ib5z23NUEhF9AsGl7y+dzLe5Cw2AArGTA==", + "license": "ISC", + "dependencies": { + "d3-path": "^3.1.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-time": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/d3-time/-/d3-time-3.1.0.tgz", + "integrity": "sha512-VqKjzBLejbSMT4IgbmVgDjpkYrNWUYJnbCGo874u7MMKIWsILRX+OpX/gTk8MqjpT1A/c6HY2dCA77ZN0lkQ2Q==", + "license": "ISC", + "dependencies": { + "d3-array": "2 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-time-format": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/d3-time-format/-/d3-time-format-4.1.0.tgz", + "integrity": "sha512-dJxPBlzC7NugB2PDLwo9Q8JiTR3M3e4/XANkreKSUxF8vvXKqm1Yfq4Q5dl8budlunRVlUUaDUgFt7eA8D6NLg==", + "license": "ISC", + "dependencies": { + "d3-time": "1 - 3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/d3-timer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/d3-timer/-/d3-timer-3.0.1.tgz", + "integrity": "sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/decimal.js-light": { + "version": "2.5.1", + "resolved": "https://registry.npmjs.org/decimal.js-light/-/decimal.js-light-2.5.1.tgz", + "integrity": "sha512-qIMFpTMZmny+MMIitAB6D7iVPEorVw6YQRWkvarTkT4tBeSLLiHzcwj6q0MmYSFCiVpiqPJTJEYIrpcPzVEIvg==", + "license": "MIT" + }, + "node_modules/deep-eql": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-5.0.2.tgz", + "integrity": "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/dom-helpers": { + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/dom-helpers/-/dom-helpers-5.2.1.tgz", + "integrity": "sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA==", + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.8.7", + "csstype": "^3.0.2" + } + }, + "node_modules/electron-to-chromium": { + "version": "1.5.458", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.458.tgz", + "integrity": "sha512-/hIH2gRSmxrf4/X4Nw/t/Wc8PVS22l8/3HnWjWTWsTOM5dsnMUPZF/+u+yr3LqweNuTBJE1rq6qAQzKkZVwMfA==", + "dev": true, + "license": "ISC" + }, + "node_modules/es-module-lexer": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", + "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", + "dev": true, + "license": "MIT" + }, + "node_modules/esbuild": { + "version": "0.21.5", + "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", + "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "bin": { + "esbuild": "bin/esbuild" + }, + "engines": { + "node": ">=12" + }, + "optionalDependencies": { + "@esbuild/aix-ppc64": "0.21.5", + "@esbuild/android-arm": "0.21.5", + "@esbuild/android-arm64": "0.21.5", + "@esbuild/android-x64": "0.21.5", + "@esbuild/darwin-arm64": "0.21.5", + "@esbuild/darwin-x64": "0.21.5", + "@esbuild/freebsd-arm64": "0.21.5", + "@esbuild/freebsd-x64": "0.21.5", + "@esbuild/linux-arm": "0.21.5", + "@esbuild/linux-arm64": "0.21.5", + "@esbuild/linux-ia32": "0.21.5", + "@esbuild/linux-loong64": "0.21.5", + "@esbuild/linux-mips64el": "0.21.5", + "@esbuild/linux-ppc64": "0.21.5", + "@esbuild/linux-riscv64": "0.21.5", + "@esbuild/linux-s390x": "0.21.5", + "@esbuild/linux-x64": "0.21.5", + "@esbuild/netbsd-x64": "0.21.5", + "@esbuild/openbsd-x64": "0.21.5", + "@esbuild/sunos-x64": "0.21.5", + "@esbuild/win32-arm64": "0.21.5", + "@esbuild/win32-ia32": "0.21.5", + "@esbuild/win32-x64": "0.21.5" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/eventemitter3": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-4.0.7.tgz", + "integrity": "sha512-8guHBZCwKnFhYdHr2ysuRWErTwhoN2X8XELRlrRwpmfeY2jjuUN4taQMsULKUVo1K4DvZl+0pgfyoysHxvmvEw==", + "license": "MIT" + }, + "node_modules/expect-type": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz", + "integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/fast-equals": { + "version": "5.4.5", + "resolved": "https://registry.npmjs.org/fast-equals/-/fast-equals-5.4.5.tgz", + "integrity": "sha512-yKEpcFuRPu/pTQL2VB8YKE2yFldrLlIsoWtJeBnAdqEhvu21OgkFT0C8njf/xYc2LSZGEr3K9JGjthHJxnVLvQ==", + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/internmap": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/internmap/-/internmap-2.0.3.tgz", + "integrity": "sha512-5Hh7Y1wQbvY5ooGgPbDaL5iYLAPzMTUrjMulskHLH6wnv/A+1q5rgEaiuqEjB+oxGXIVZs1FF+R/KPN3ZSQYYg==", + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "license": "MIT" + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/lodash": { + "version": "4.18.1", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz", + "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==", + "license": "MIT" + }, + "node_modules/loose-envify": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", + "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", + "license": "MIT", + "dependencies": { + "js-tokens": "^3.0.0 || ^4.0.0" + }, + "bin": { + "loose-envify": "cli.js" + } + }, + "node_modules/loupe": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz", + "integrity": "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/lucide-react": { + "version": "0.379.0", + "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.379.0.tgz", + "integrity": "sha512-KcdeVPqmhRldldAAgptb8FjIunM2x2Zy26ZBh1RsEUcdLIvsEmbcw7KpzFYUy5BbpGeWhPu9Z9J5YXfStiXwhg==", + "license": "ISC", + "peerDependencies": { + "react": "^16.5.1 || ^17.0.0 || ^18.0.0" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.20", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.20.tgz", + "integrity": "sha512-uKdg2G3GNCKQn9byYOpxbGqrT2fGO5KRt5J/8b3pok8rT6qxGWF6hxMyJiEYtAf+FVyYuD9hRaDqX5uPFYJ4ZQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/node-releases": { + "version": "2.0.59", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.59.tgz", + "integrity": "sha512-5qr2okC4ItAI0hG5SaV0HoYMpnM/Y6+Oqr7AmPYy/2uEoGvS+GbSKLeRxSS1uAdu59yz0ouzcuXievSgg///WA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/pathe": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-1.1.2.tgz", + "integrity": "sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/pathval": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/pathval/-/pathval-2.0.1.tgz", + "integrity": "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 14.16" + } + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/postcss": { + "version": "8.5.30", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.30.tgz", + "integrity": "sha512-zkd+e9S1AQHhmaBKye9zlFHWjOwVgqXl1q3ECYy8561K7qxR2cREf/STLskulBFd78k6AOFeA+ff1TtEzk9bAA==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.20", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.2" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/prop-types": { + "version": "15.8.1", + "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", + "integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.4.0", + "object-assign": "^4.1.1", + "react-is": "^16.13.1" + } + }, + "node_modules/prop-types/node_modules/react-is": { + "version": "16.13.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", + "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", + "license": "MIT" + }, + "node_modules/react": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz", + "integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz", + "integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0", + "scheduler": "^0.23.2" + }, + "peerDependencies": { + "react": "^18.3.1" + } + }, + "node_modules/react-is": { + "version": "18.3.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-18.3.1.tgz", + "integrity": "sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==", + "license": "MIT" + }, + "node_modules/react-refresh": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz", + "integrity": "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-router": { + "version": "6.30.6", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-6.30.6.tgz", + "integrity": "sha512-5HfK7k5im7LTOB0EqCQmfvy4C13G92Ssj1VTmouTK3AJvyjKTnFuCV0vcMAD/JS+JC4DvDIBRrlAeJIFjh5VWg==", + "license": "MIT", + "dependencies": { + "@remix-run/router": "1.23.4" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "react": ">=16.8" + } + }, + "node_modules/react-router-dom": { + "version": "6.30.6", + "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-6.30.6.tgz", + "integrity": "sha512-0RHKZz7wwffvkU+2MFVT2NnjK44ssLEV+m0CAJaS2Ksmorrwj7WxH00jO0SOCW26/tINUnJHToXblDs33I38YQ==", + "license": "MIT", + "dependencies": { + "@remix-run/router": "1.23.4", + "react-router": "6.30.6" + }, + "engines": { + "node": ">=14.0.0" + }, + "peerDependencies": { + "react": ">=16.8", + "react-dom": ">=16.8" + } + }, + "node_modules/react-smooth": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/react-smooth/-/react-smooth-4.0.4.tgz", + "integrity": "sha512-gnGKTpYwqL0Iii09gHobNolvX4Kiq4PKx6eWBCYYix+8cdw+cGo3do906l1NBPKkSWx1DghC1dlWG9L2uGd61Q==", + "license": "MIT", + "dependencies": { + "fast-equals": "^5.0.1", + "prop-types": "^15.8.1", + "react-transition-group": "^4.4.5" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/react-transition-group": { + "version": "4.4.5", + "resolved": "https://registry.npmjs.org/react-transition-group/-/react-transition-group-4.4.5.tgz", + "integrity": "sha512-pZcd1MCJoiKiBR2NRxeCRg13uCXbydPnmB4EOeRrY7480qNWO8IIgQG6zlDkm6uRMsURXPuKq0GWtiM59a5Q6g==", + "license": "BSD-3-Clause", + "dependencies": { + "@babel/runtime": "^7.5.5", + "dom-helpers": "^5.0.1", + "loose-envify": "^1.4.0", + "prop-types": "^15.6.2" + }, + "peerDependencies": { + "react": ">=16.6.0", + "react-dom": ">=16.6.0" + } + }, + "node_modules/recharts": { + "version": "2.15.4", + "resolved": "https://registry.npmjs.org/recharts/-/recharts-2.15.4.tgz", + "integrity": "sha512-UT/q6fwS3c1dHbXv2uFgYJ9BMFHu3fwnd7AYZaEQhXuYQ4hgsxLvsUXzGdKeZrW5xopzDCvuA2N41WJ88I7zIw==", + "deprecated": "1.x and 2.x branches are no longer active. Bump to Recharts v3 to receive latest features and bugfixes. See https://github.com/recharts/recharts/wiki/3.0-migration-guide", + "license": "MIT", + "dependencies": { + "clsx": "^2.0.0", + "eventemitter3": "^4.0.1", + "lodash": "^4.17.21", + "react-is": "^18.3.1", + "react-smooth": "^4.0.4", + "recharts-scale": "^0.4.4", + "tiny-invariant": "^1.3.1", + "victory-vendor": "^36.6.8" + }, + "engines": { + "node": ">=14" + }, + "peerDependencies": { + "react": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0", + "react-dom": "^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/recharts-scale": { + "version": "0.4.5", + "resolved": "https://registry.npmjs.org/recharts-scale/-/recharts-scale-0.4.5.tgz", + "integrity": "sha512-kivNFO+0OcUNu7jQquLXAxz1FIwZj8nrj+YkOKc5694NbjCvcT6aSZiIzNzd2Kul4o4rTto8QVR9lMNtxD4G1w==", + "license": "MIT", + "dependencies": { + "decimal.js-light": "^2.4.1" + } + }, + "node_modules/rollup": { + "version": "4.64.7", + "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.64.7.tgz", + "integrity": "sha512-aCiJONoZp0lNGJ6sYqspLkh6UXaWEey/7Ko2TZyXQ4kgzAhDUxAgbt/oulXKmD+zFHXw1k+RF6ZZWOrjp7DZnQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "1.0.9" + }, + "bin": { + "rollup": "dist/bin/rollup" + }, + "engines": { + "node": ">=18.0.0", + "npm": ">=8.0.0" + }, + "optionalDependencies": { + "@napi-rs/lzma-linux-x64-gnu": "1.5.1", + "@rollup/rollup-android-arm-eabi": "4.64.7", + "@rollup/rollup-android-arm64": "4.64.7", + "@rollup/rollup-darwin-arm64": "4.64.7", + "@rollup/rollup-darwin-x64": "4.64.7", + "@rollup/rollup-freebsd-arm64": "4.64.7", + "@rollup/rollup-freebsd-x64": "4.64.7", + "@rollup/rollup-linux-arm-gnueabihf": "4.64.7", + "@rollup/rollup-linux-arm-musleabihf": "4.64.7", + "@rollup/rollup-linux-arm64-gnu": "4.64.7", + "@rollup/rollup-linux-arm64-musl": "4.64.7", + "@rollup/rollup-linux-loong64-gnu": "4.64.7", + "@rollup/rollup-linux-loong64-musl": "4.64.7", + "@rollup/rollup-linux-ppc64-gnu": "4.64.7", + "@rollup/rollup-linux-ppc64-musl": "4.64.7", + "@rollup/rollup-linux-riscv64-gnu": "4.64.7", + "@rollup/rollup-linux-riscv64-musl": "4.64.7", + "@rollup/rollup-linux-s390x-gnu": "4.64.7", + "@rollup/rollup-linux-x64-gnu": "4.64.7", + "@rollup/rollup-linux-x64-musl": "4.64.7", + "@rollup/rollup-openbsd-x64": "4.64.7", + "@rollup/rollup-openharmony-arm64": "4.64.7", + "@rollup/rollup-win32-arm64-msvc": "4.64.7", + "@rollup/rollup-win32-ia32-msvc": "4.64.7", + "@rollup/rollup-win32-x64-gnu": "4.64.7", + "@rollup/rollup-win32-x64-msvc": "4.64.7", + "fsevents": "~2.3.2" + } + }, + "node_modules/scheduler": { + "version": "0.23.2", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.23.2.tgz", + "integrity": "sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==", + "license": "MIT", + "dependencies": { + "loose-envify": "^1.1.0" + } + }, + "node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, + "node_modules/source-map-js": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.2.tgz", + "integrity": "sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/std-env": { + "version": "3.10.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", + "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tiny-invariant": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.3.3.tgz", + "integrity": "sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==", + "license": "MIT" + }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "0.3.2", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-0.3.2.tgz", + "integrity": "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinypool": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/tinypool/-/tinypool-1.1.1.tgz", + "integrity": "sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.0.0 || >=20.0.0" + } + }, + "node_modules/tinyrainbow": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-1.2.0.tgz", + "integrity": "sha512-weEDEq7Z5eTHPDh4xjX789+fHfF+P8boiFB+0vbWzpbnbsEr/GRaohi/uMKxg8RZMXnl1ItAi/IUHWMsjDV7kQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tinyspy": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/tinyspy/-/tinyspy-3.0.2.tgz", + "integrity": "sha512-n1cw8k1k0x4pgA2+9XrOkFydTerNcJ1zWCO5Nn9scWHTD+5tp8dghT2x1uduQePZTZgd3Tupf+x9BxJjeJi77Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/update-browserslist-db": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.3.4.tgz", + "integrity": "sha512-t8wonIOdQyOjtPd2VktlBu6QjTVTks6v8bSi7kd0r2Vll8TK1JYDZtRRdbmCHOAMphkZgrzK+L+vXK4pyZ8t4A==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/victory-vendor": { + "version": "36.9.2", + "resolved": "https://registry.npmjs.org/victory-vendor/-/victory-vendor-36.9.2.tgz", + "integrity": "sha512-PnpQQMuxlwYdocC8fIJqVXvkeViHYzotI+NJrCuav0ZYFoq912ZHBk3mCeuj+5/VpodOjPe1z0Fk2ihgzlXqjQ==", + "license": "MIT AND ISC", + "dependencies": { + "@types/d3-array": "^3.0.3", + "@types/d3-ease": "^3.0.0", + "@types/d3-interpolate": "^3.0.1", + "@types/d3-scale": "^4.0.2", + "@types/d3-shape": "^3.1.0", + "@types/d3-time": "^3.0.0", + "@types/d3-timer": "^3.0.0", + "d3-array": "^3.1.6", + "d3-ease": "^3.0.1", + "d3-interpolate": "^3.0.1", + "d3-scale": "^4.0.2", + "d3-shape": "^3.1.0", + "d3-time": "^3.0.0", + "d3-timer": "^3.0.1" + } + }, + "node_modules/vite": { + "version": "5.4.21", + "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.21.tgz", + "integrity": "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw==", + "dev": true, + "license": "MIT", + "dependencies": { + "esbuild": "^0.21.3", + "postcss": "^8.4.43", + "rollup": "^4.20.0" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^18.0.0 || >=20.0.0", + "less": "*", + "lightningcss": "^1.21.0", + "sass": "*", + "sass-embedded": "*", + "stylus": "*", + "sugarss": "*", + "terser": "^5.4.0" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "less": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + } + } + }, + "node_modules/vite-node": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/vite-node/-/vite-node-2.1.9.tgz", + "integrity": "sha512-AM9aQ/IPrW/6ENLQg3AGY4K1N2TGZdR5e4gu/MmmR2xR3Ll1+dib+nook92g4TV3PXVyeyxdWwtaCAiUL0hMxA==", + "dev": true, + "license": "MIT", + "dependencies": { + "cac": "^6.7.14", + "debug": "^4.3.7", + "es-module-lexer": "^1.5.4", + "pathe": "^1.1.2", + "vite": "^5.0.0" + }, + "bin": { + "vite-node": "vite-node.mjs" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/vitest": { + "version": "2.1.9", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-2.1.9.tgz", + "integrity": "sha512-MSmPM9REYqDGBI8439mA4mWhV5sKmDlBKWIYbA3lRb2PTHACE0mgKwA8yQ2xq9vxDTuk4iPrECBAEW2aoFXY0Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "2.1.9", + "@vitest/mocker": "2.1.9", + "@vitest/pretty-format": "^2.1.9", + "@vitest/runner": "2.1.9", + "@vitest/snapshot": "2.1.9", + "@vitest/spy": "2.1.9", + "@vitest/utils": "2.1.9", + "chai": "^5.1.2", + "debug": "^4.3.7", + "expect-type": "^1.1.0", + "magic-string": "^0.30.12", + "pathe": "^1.1.2", + "std-env": "^3.8.0", + "tinybench": "^2.9.0", + "tinyexec": "^0.3.1", + "tinypool": "^1.0.1", + "tinyrainbow": "^1.2.0", + "vite": "^5.0.0", + "vite-node": "2.1.9", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^18.0.0 || >=20.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@types/node": "^18.0.0 || >=20.0.0", + "@vitest/browser": "2.1.9", + "@vitest/ui": "2.1.9", + "happy-dom": "*", + "jsdom": "*" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + } + } + }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true, + "license": "ISC" + } + } +} diff --git a/frontend/package.json b/frontend/package.json index b92490c..9b5f64b 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -1,22 +1,24 @@ { "name": "power-dashboard", "private": true, - "version": "1.0.0", + "version": "1.1.0", "type": "module", "scripts": { "dev": "vite", "build": "vite build", - "preview": "vite preview" + "preview": "vite preview", + "test": "vitest run" }, "dependencies": { + "lucide-react": "^0.379.0", "react": "^18.3.1", "react-dom": "^18.3.1", "react-router-dom": "^6.23.1", - "recharts": "^2.12.7", - "lucide-react": "^0.379.0" + "recharts": "^2.12.7" }, "devDependencies": { "@vitejs/plugin-react": "^4.3.0", - "vite": "^5.2.0" + "vite": "^5.2.0", + "vitest": "^2.1.9" } } diff --git a/frontend/src/App.css b/frontend/src/App.css index 2f2e98b..1c10069 100644 --- a/frontend/src/App.css +++ b/frontend/src/App.css @@ -1,430 +1,721 @@ -.app-layout { - display: flex; - height: 100vh; - overflow: hidden; -} +/* ===================================================================== */ +/* Layout */ +/* ===================================================================== */ + +.app-layout { display: flex; height: 100vh; height: 100dvh; overflow: hidden; } -/* Sidebar */ .sidebar { - width: 220px; + width: var(--sidebar-w); flex-shrink: 0; background: var(--bg-surface); border-right: 1px solid var(--border); display: flex; flex-direction: column; - overflow-y: auto; } - .sidebar-brand { - display: flex; - align-items: center; - gap: 10px; - padding: 20px 18px 16px; + display: flex; align-items: center; gap: 10px; + height: 56px; padding: 0 18px; border-bottom: 1px solid var(--border); } +.brand-icon { color: var(--accent); flex-shrink: 0; } +.brand-text { font-weight: 700; font-size: 15px; letter-spacing: 0.01em; white-space: nowrap; } -.brand-icon { color: var(--accent); } - -.brand-text { - font-weight: 700; - font-size: 16px; - letter-spacing: 0.08em; - color: var(--text-primary); -} - -.sidebar-nav { - display: flex; - flex-direction: column; - padding: 12px 8px; - gap: 2px; -} - +.sidebar-nav { display: flex; flex-direction: column; gap: 2px; padding: 12px 10px; flex: 1; } .nav-item { - display: flex; - align-items: center; - gap: 10px; + position: relative; + display: flex; align-items: center; gap: 11px; padding: 9px 12px; border-radius: var(--r-md); color: var(--text-secondary); - font-size: 13.5px; font-weight: 500; + border: 1px solid transparent; transition: background 0.15s, color 0.15s; - position: relative; -} - -.nav-item:hover { - background: var(--bg-elevated); - color: var(--text-primary); -} - -.nav-item.active { - background: var(--accent-dim); - color: var(--accent); - border: 1px solid var(--accent-border); } +.nav-item:hover { background: var(--bg-elevated); color: var(--text-primary); } +.nav-item.active { background: var(--accent-dim); color: var(--accent); border-color: var(--accent-border); } .nav-badge { margin-left: auto; - background: var(--status-crit); - color: white; - font-size: 10px; - font-weight: 700; - padding: 1px 6px; + min-width: 20px; padding: 1px 6px; border-radius: 10px; - min-width: 18px; - text-align: center; + font-size: 11px; font-weight: 700; text-align: center; + color: #0b0e12; background: var(--p4); +} +.nav-badge.p1 { background: var(--p1); color: #fff; } +.nav-badge.p2 { background: var(--p2); } +.nav-badge.p3 { background: var(--p3); } + +.sidebar-about { + display: flex; align-items: center; gap: 10px; + margin: 10px; padding: 9px 12px; + border-radius: var(--r-md); + background: none; border: 1px dashed var(--border-bright); + color: var(--text-secondary); font-weight: 500; } +.sidebar-about:hover { color: var(--text-primary); border-color: var(--accent-border); } + +.main-area { flex: 1; min-width: 0; display: flex; flex-direction: column; background: var(--bg-base); } -/* Topbar */ .topbar { - display: flex; - align-items: center; - justify-content: space-between; - padding: 0 24px; - height: 52px; + display: flex; align-items: center; justify-content: space-between; gap: 12px; + height: 56px; padding: 0 20px; background: var(--bg-surface); border-bottom: 1px solid var(--border); flex-shrink: 0; } - -.topbar-title { - font-size: 15px; - font-weight: 600; - color: var(--text-primary); - letter-spacing: 0.02em; -} - -.topbar-right { - display: flex; - align-items: center; - gap: 16px; +.topbar-left { display: flex; align-items: center; gap: 10px; min-width: 0; } +.topbar-title { font-size: 15px; font-weight: 600; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } +.topbar-right { display: flex; align-items: center; gap: 10px; } +.mobile-only { display: none; } + +.sim-pill { + display: inline-flex; align-items: center; gap: 6px; + padding: 4px 10px; border-radius: 999px; + background: var(--accent-dim); border: 1px solid var(--accent-border); + color: var(--accent); font-size: 12px; font-weight: 600; +} +.conn-pill { display: inline-flex; align-items: center; gap: 6px; font-size: 12px; font-weight: 600; } +.conn-pill.live { color: var(--ok); } +.conn-pill.down { color: var(--p2); } +.conn-dot { width: 8px; height: 8px; border-radius: 50%; background: currentColor; } +.conn-pill.live .conn-dot { box-shadow: 0 0 0 3px rgba(34, 197, 94, 0.18); } +.conn-pill.down .conn-dot { animation: alarm-flash 1s infinite; } + +.icon-btn { + display: inline-flex; align-items: center; justify-content: center; + width: 32px; height: 32px; border-radius: var(--r-md); + background: none; border: 1px solid transparent; color: var(--text-secondary); +} +.icon-btn:hover { background: var(--bg-elevated); color: var(--text-primary); } + +.page-content { flex: 1; overflow-y: auto; padding: 20px 24px 32px; } +.page-content.stale { opacity: 0.6; transition: opacity 0.3s; } +.page { max-width: 1440px; margin: 0 auto; display: flex; flex-direction: column; gap: 18px; } +.page-loading { padding: 60px; text-align: center; color: var(--text-muted); } + +.page-header { display: flex; justify-content: space-between; align-items: flex-start; gap: 12px; flex-wrap: wrap; } +.page-header h2 { font-size: 20px; font-weight: 650; } +.page-header p { color: var(--text-secondary); font-size: 13px; } + +.bottom-nav { display: none; } + +/* ===================================================================== */ +/* Banners */ +/* ===================================================================== */ + +.event-banner { + display: flex; align-items: center; gap: 8px; + padding: 7px 20px; + background: var(--accent-dim); color: var(--accent); + border-bottom: 1px solid var(--accent-border); + font-size: 13px; } - -.conn-status { - display: flex; - align-items: center; - gap: 5px; - font-size: 12px; - font-weight: 500; +.alarm-banner { + display: flex; align-items: center; gap: 10px; + padding: 8px 20px; + border-bottom: 1px solid var(--border); + background: var(--bg-surface); + border-left: 4px solid var(--p4); } -.conn-ok { color: var(--status-ok); } -.conn-err { color: var(--status-crit); } +.alarm-banner.p1 { border-left-color: var(--p1); background: linear-gradient(90deg, var(--p1-dim), var(--bg-surface) 60%); } +.alarm-banner.p2 { border-left-color: var(--p2); background: linear-gradient(90deg, var(--p2-dim), var(--bg-surface) 60%); } +.alarm-banner.p3 { border-left-color: var(--p3); background: linear-gradient(90deg, var(--p3-dim), var(--bg-surface) 60%); } +.alarm-banner-msg { flex: 1; min-width: 0; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } +.alarm-banner-actions { display: flex; gap: 6px; flex-shrink: 0; } -.topbar-alarm-count { - display: flex; - align-items: center; - gap: 5px; - font-size: 12px; - color: var(--status-warn); - font-weight: 500; -} +/* ===================================================================== */ +/* Common components */ +/* ===================================================================== */ -/* Main area */ -.main-area { - flex: 1; - display: flex; - flex-direction: column; - overflow: hidden; - background: var(--bg-base); +.card { + background: var(--bg-card); + border: 1px solid var(--border); + border-radius: var(--r-lg); + padding: 16px 18px; + min-width: 0; +} +.card-head { display: flex; align-items: center; justify-content: space-between; gap: 10px; margin-bottom: 12px; } +.card-head.wrap { flex-wrap: wrap; } +.card-head h3, .section-title { + display: flex; align-items: center; gap: 7px; + font-size: 12px; font-weight: 650; letter-spacing: 0.06em; text-transform: uppercase; + color: var(--text-secondary); } +.link { display: inline-flex; align-items: center; gap: 4px; color: var(--accent); font-size: 13px; font-weight: 500; } -.page-content { - flex: 1; - overflow-y: auto; - padding: 24px; -} +.btn { + display: inline-flex; align-items: center; justify-content: center; gap: 6px; + padding: 8px 14px; + border-radius: var(--r-md); + font-size: 13px; font-weight: 550; + border: 1px solid transparent; + transition: background 0.15s, border-color 0.15s, color 0.15s; + white-space: nowrap; +} +.btn-sm { padding: 5px 10px; font-size: 12px; } +.btn-primary { background: var(--accent); color: #0b0e12; } +.btn-primary:hover { filter: brightness(1.1); } +.btn-ghost { background: transparent; color: var(--text-secondary); border-color: var(--border-bright); } +.btn-ghost:hover { background: var(--bg-elevated); color: var(--text-primary); } +.btn-ack { background: var(--text-primary); color: #0b0e12; } +.btn-ack:hover { filter: brightness(0.9); } -/* Page header */ -.page-header { - margin-bottom: 24px; -} +.seg { display: inline-flex; gap: 2px; padding: 2px; background: var(--bg-elevated); border: 1px solid var(--border); border-radius: var(--r-md); } +.seg-btn { padding: 4px 11px; border-radius: 6px; background: none; border: none; color: var(--text-secondary); font-size: 12px; font-weight: 550; } +.seg-btn:hover { color: var(--text-primary); } +.seg-btn.active { background: var(--accent-dim); color: var(--accent); } -.page-header h2 { - font-size: 20px; - font-weight: 600; +select, input[type="text"] { + background: var(--bg-elevated); + border: 1px solid var(--border-bright); color: var(--text-primary); - margin-bottom: 4px; -} - -.page-header p { - color: var(--text-secondary); - font-size: 13px; + padding: 6px 10px; + border-radius: var(--r-md); + font: inherit; font-size: 13px; + outline: none; } - -/* Grid layouts */ -.grid-4 { display: grid; grid-template-columns: repeat(4, 1fr); gap: 16px; } -.grid-2 { display: grid; grid-template-columns: repeat(2, 1fr); gap: 16px; } -.grid-auto { display: grid; grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); gap: 16px; } - -/* Stat card */ +select:focus, input[type="text"]:focus { border-color: var(--accent); } + +.empty-state { padding: 32px 12px; text-align: center; color: var(--text-muted); display: flex; flex-direction: column; gap: 12px; align-items: center; } +.form-error { color: var(--p1); font-size: 12px; margin-top: 6px; } + +.status-dot { width: 8px; height: 8px; border-radius: 50%; display: inline-block; flex-shrink: 0; background: var(--energized); } +.status-dot.off { background: var(--dead); } +.status-dot.p1 { background: var(--p1); } +.status-dot.p2 { background: var(--p2); } +.status-dot.p3 { background: var(--p3); } +.status-dot.p4 { background: var(--p4); } + +/* Priority and state badges */ +.prio-badge { + display: inline-flex; align-items: center; + padding: 2px 7px; border-radius: var(--r-sm); + font-size: 11px; font-weight: 700; letter-spacing: 0.02em; + white-space: nowrap; border: 1px solid transparent; +} +.prio-badge.p1 { background: var(--p1-dim); color: var(--p1); border-color: rgba(239, 68, 68, 0.4); } +.prio-badge.p2 { background: var(--p2-dim); color: var(--p2); border-color: rgba(249, 115, 22, 0.4); } +.prio-badge.p3 { background: var(--p3-dim); color: var(--p3); border-color: rgba(234, 179, 8, 0.4); } +.prio-badge.p4 { background: var(--p4-dim); color: var(--p4); border-color: rgba(167, 139, 250, 0.4); } +.prio-label { font-weight: 600; margin-left: 5px; } + +.state-badge { + --c: var(--p4); + display: inline-flex; align-items: center; gap: 6px; + padding: 2px 8px; border-radius: 999px; + font-size: 11px; font-weight: 650; white-space: nowrap; + border: 1px solid var(--c); color: var(--c); +} +.state-badge.p1 { --c: var(--p1); } +.state-badge.p2 { --c: var(--p2); } +.state-badge.p3 { --c: var(--p3); } +.state-icon { width: 8px; height: 8px; border-radius: 50%; background: var(--c); } +/* Unacknowledged: solid and flashing */ +.state-badge.state-unack { background: var(--c); color: #0b0e12; } +.state-badge.state-unack .state-icon { background: #0b0e12; animation: alarm-flash 1s infinite; } +/* Acknowledged: steady outline */ +.state-badge.state-acked .state-icon { background: var(--c); } +/* Returned to normal, unacknowledged: dashed and flashing hollow */ +.state-badge.state-rtn_unack { border-style: dashed; color: var(--text-secondary); } +.state-badge.state-rtn_unack .state-icon { background: none; border: 2px solid var(--c); animation: alarm-flash 1s infinite; } +/* Out of the alarm list */ +.state-badge.state-shelved, .state-badge.state-suppressed, .state-badge.state-normal { --c: var(--text-muted); } +.state-badge.state-suppressed { font-style: italic; } + +/* Stat cards */ +.kpi-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: 14px; } .stat-card { background: var(--bg-card); border: 1px solid var(--border); border-radius: var(--r-lg); - padding: 20px; -} - -.stat-card-label { - font-size: 11px; - font-weight: 600; - text-transform: uppercase; - letter-spacing: 0.08em; + padding: 14px 16px; + min-width: 0; + display: flex; flex-direction: column; gap: 4px; + border-top: 3px solid var(--border); +} +.stat-card-label { font-size: 11px; font-weight: 650; text-transform: uppercase; letter-spacing: 0.07em; color: var(--text-muted); } +.stat-card-value { font-size: 26px; font-weight: 700; font-family: var(--font-mono); line-height: 1.15; } +.stat-card-value.small-value { font-size: 20px; } +.stat-card-unit { font-size: 13px; font-weight: 500; color: var(--text-muted); margin-left: 5px; } +.stat-card-sub { font-size: 12px; color: var(--text-secondary); } +.stat-card.tone-warn { border-top-color: var(--p3); } +.stat-card.tone-crit, .stat-card.tone-p1 { border-top-color: var(--p1); } +.stat-card.tone-p2 { border-top-color: var(--p2); } +.stat-card.tone-p3 { border-top-color: var(--p3); } +.stat-card.tone-p4 { border-top-color: var(--p4); } +.stat-card.tone-crit .stat-card-value, .stat-card.tone-p1 .stat-card-value { color: var(--p1); } + +.sparkline { width: 100%; display: block; margin-top: 6px; } +.spark-line { fill: none; stroke: var(--accent); stroke-width: 1.5; } +.spark-fill { fill: var(--accent-dim); stroke: none; } + +.area-chart-wrap { width: 100%; } +.area-chart { display: block; } +.area-line { fill: none; stroke: var(--accent); stroke-width: 1.8; } +.area-fill { fill: var(--accent-dim); } +.grid-line { stroke: var(--grid); stroke-width: 1; } +.axis-text { fill: var(--text-muted); font-size: 11px; font-family: var(--font-mono); } +.chart-empty { display: flex; align-items: center; justify-content: center; color: var(--text-muted); } + +.kv { display: flex; flex-direction: column; } +.kv > div { display: flex; justify-content: space-between; gap: 12px; padding: 7px 0; border-bottom: 1px solid var(--border); font-size: 13px; } +.kv > div:last-child { border-bottom: none; } +.kv dt { color: var(--text-secondary); } +.kv dd { text-align: right; overflow-wrap: anywhere; } + +.table-wrap { overflow-x: auto; } +.data-table { font-size: 13px; } +.data-table th { + padding: 8px 10px; + font-size: 11px; font-weight: 650; text-transform: uppercase; letter-spacing: 0.06em; color: var(--text-muted); - margin-bottom: 8px; -} - -.stat-card-value { - font-size: 28px; - font-weight: 700; - font-family: var(--font-mono); - color: var(--text-primary); - line-height: 1; - margin-bottom: 4px; -} - -.stat-card-sub { - font-size: 12px; - color: var(--text-secondary); + border-bottom: 1px solid var(--border); + white-space: nowrap; } +.data-table td { padding: 9px 10px; border-bottom: 1px solid var(--border); vertical-align: top; } +.data-table tbody tr:hover td { background: var(--bg-hover); } +.empty-cell { text-align: center; color: var(--text-muted); padding: 24px; } -/* Status dot */ -.status-dot { - width: 8px; - height: 8px; - border-radius: 50%; - display: inline-block; - flex-shrink: 0; -} -.status-dot.ok { background: var(--status-ok); box-shadow: 0 0 6px var(--status-ok); } -.status-dot.warn { background: var(--status-warn); box-shadow: 0 0 6px var(--status-warn); } -.status-dot.crit { background: var(--status-crit); box-shadow: 0 0 6px var(--status-crit); animation: pulse-crit 1.2s infinite; } -.status-dot.off { background: var(--status-off); } +/* ===================================================================== */ +/* Dashboard */ +/* ===================================================================== */ -@keyframes pulse-crit { - 0%, 100% { opacity: 1; } - 50% { opacity: 0.4; } -} - -/* Meter tile */ -.meter-tile { - background: var(--bg-card); - border: 1px solid var(--border); +.intro-card { + display: flex; gap: 14px; align-items: flex-start; + padding: 14px 16px; + background: linear-gradient(120deg, var(--accent-dim), transparent 70%), var(--bg-card); + border: 1px solid var(--accent-border); border-radius: var(--r-lg); - padding: 16px; - cursor: pointer; - transition: border-color 0.15s, background 0.15s; -} -.meter-tile:hover { background: var(--bg-hover); border-color: var(--border-bright); } -.meter-tile.status-ok { border-left: 3px solid var(--status-ok); } -.meter-tile.status-warn { border-left: 3px solid var(--status-warn); } -.meter-tile.status-crit { border-left: 3px solid var(--status-crit); } - -.meter-tile-header { - display: flex; - align-items: center; - justify-content: space-between; - margin-bottom: 12px; } +.intro-icon { color: var(--accent); flex-shrink: 0; margin-top: 2px; } +.intro-text { flex: 1; color: var(--text-secondary); } +.intro-text strong { color: var(--text-primary); } +.intro-actions { margin-top: 10px; display: flex; gap: 8px; flex-wrap: wrap; } -.meter-tile-name { - font-weight: 600; - font-size: 13px; -} +.dash-row { display: grid; grid-template-columns: minmax(0, 1.6fr) minmax(0, 1fr); gap: 14px; } +.alarm-card .empty-state { padding: 20px; } -.meter-tile-metrics { - display: grid; - grid-template-columns: repeat(3, 1fr); - gap: 8px; -} - -.metric-cell { - display: flex; - flex-direction: column; - gap: 2px; -} - -.metric-label { - font-size: 10px; - text-transform: uppercase; - letter-spacing: 0.06em; - color: var(--text-muted); -} - -.metric-value { - font-size: 15px; - font-weight: 600; - font-family: var(--font-mono); - color: var(--text-primary); -} - -.metric-unit { - font-size: 10px; - color: var(--text-muted); - font-family: var(--font-mono); -} - -/* Buttons */ -.btn { - display: inline-flex; - align-items: center; - gap: 6px; - padding: 7px 14px; +.mini-alarms { list-style: none; display: flex; flex-direction: column; gap: 6px; margin-bottom: 8px; } +.mini-alarms li { + display: flex; align-items: center; gap: 10px; + padding: 7px 10px; + background: var(--bg-elevated); border-radius: var(--r-md); + border-left: 3px solid var(--p4); font-size: 13px; - font-weight: 500; - border: none; - transition: background 0.15s, opacity 0.15s; -} - -.btn-primary { - background: var(--accent); - color: #000; } -.btn-primary:hover { opacity: 0.85; } - -.btn-ghost { - background: transparent; - color: var(--text-secondary); +.mini-alarms li.p1 { border-left-color: var(--p1); } +.mini-alarms li.p2 { border-left-color: var(--p2); } +.mini-alarms li.p3 { border-left-color: var(--p3); } +.mini-alarms li.more { border-left-color: transparent; justify-content: center; } +.mini-alarm-text { flex: 1; min-width: 0; } +.mini-alarm-text .muted { font-size: 12px; } + +.equip-group { display: flex; flex-direction: column; gap: 10px; } +.equip-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); gap: 12px; } +.equip-tile { + text-align: left; + display: flex; flex-direction: column; gap: 8px; + padding: 13px 14px; + background: var(--bg-card); border: 1px solid var(--border); -} -.btn-ghost:hover { background: var(--bg-elevated); color: var(--text-primary); } - -.btn-warn { - background: var(--status-warn-dim); - color: var(--status-warn); - border: 1px solid rgba(245,158,11,0.3); -} -.btn-warn:hover { background: rgba(245,158,11,0.2); } - -/* Table */ -.data-table { - width: 100%; - border-spacing: 0; -} - -.data-table th { - padding: 10px 14px; - font-size: 11px; - font-weight: 600; - text-transform: uppercase; - letter-spacing: 0.06em; - color: var(--text-muted); - border-bottom: 1px solid var(--border); - background: var(--bg-surface); -} - -.data-table td { - padding: 11px 14px; - font-size: 13px; - border-bottom: 1px solid var(--border); - color: var(--text-primary); -} - -.data-table tr:hover td { background: var(--bg-hover); } - -.data-table tr.priority-1 td { border-left: 3px solid var(--status-crit); } -.data-table tr.priority-2 td { border-left: 3px solid var(--status-warn); } -.data-table tr.priority-3 td { border-left: 3px solid var(--accent); } -.data-table tr.priority-4 td { border-left: 3px solid var(--border-bright); } - -/* Tab bar */ -.tab-bar { - display: flex; - gap: 4px; - border-bottom: 1px solid var(--border); - margin-bottom: 20px; -} - + border-left: 4px solid var(--border-bright); + border-radius: var(--r-lg); + transition: background 0.15s, border-color 0.15s; +} +.equip-tile:hover { background: var(--bg-hover); border-color: var(--border-bright); } +.equip-tile.off { border-left-color: var(--dead); } +.equip-tile.off .metric-value { color: var(--text-muted); } +.equip-tile.has-alarm.p1 { border-left-color: var(--p1); } +.equip-tile.has-alarm.p2 { border-left-color: var(--p2); } +.equip-tile.has-alarm.p3 { border-left-color: var(--p3); } +.equip-tile.has-alarm.p4 { border-left-color: var(--p4); } +.equip-head { display: flex; justify-content: space-between; align-items: flex-start; gap: 8px; } +.equip-name { font-weight: 600; } +.equip-id { font-size: 11px; color: var(--text-muted); } +.equip-status { font-size: 12px; color: var(--text-secondary); font-weight: 500; } +.status-on-battery, .status-failed { color: var(--p1) !important; font-weight: 650 !important; } +.status-on-generator, .status-sag, .status-running, .status-cranking, .status-restarting { color: var(--p3) !important; } +.status-de-energized, .status-standby, .status-comm-fail { color: var(--text-muted) !important; } +.equip-metrics { display: grid; grid-template-columns: repeat(3, 1fr); gap: 6px; } +.metric-value { font-family: var(--font-mono); font-weight: 600; font-size: 15px; } +.metric-unit { font-family: var(--font-mono); font-size: 11px; color: var(--text-muted); margin-left: 3px; } +.load-bar { height: 5px; background: var(--bg-elevated); border-radius: 3px; overflow: hidden; } +.load-bar-fill { height: 100%; background: var(--energized); border-radius: 3px; transition: width 0.4s; } +.load-bar-fill.p3 { background: var(--p3); } +.load-bar-fill.p2 { background: var(--p2); } +.equip-extra { display: flex; align-items: center; gap: 6px; font-size: 12px; color: var(--text-secondary); } +.equip-alarm { font-size: 12px; color: var(--text-primary); font-weight: 500; } + +/* ===================================================================== */ +/* Alarms */ +/* ===================================================================== */ + +.help-card { padding: 18px; } +.prio-counts { display: grid; grid-template-columns: repeat(4, 1fr); gap: 6px; margin-top: 4px; } +.prio-count { display: flex; flex-direction: column; align-items: center; padding: 4px 0; border-radius: var(--r-md); background: var(--bg-elevated); color: var(--text-muted); } +.prio-count .mono { font-size: 18px; font-weight: 700; } +.prio-count small { font-size: 10px; text-transform: uppercase; letter-spacing: 0.05em; } +.prio-count.on.p1 { color: var(--p1); background: var(--p1-dim); } +.prio-count.on.p2 { color: var(--p2); background: var(--p2-dim); } +.prio-count.on.p3 { color: var(--p3); background: var(--p3-dim); } +.prio-count.on.p4 { color: var(--p4); background: var(--p4-dim); } + +.tab-bar { display: flex; gap: 2px; border-bottom: 1px solid var(--border); margin: -4px -2px 14px; overflow-x: auto; scrollbar-width: none; } .tab-btn { - padding: 8px 16px; - background: none; - border: none; - color: var(--text-secondary); - font-size: 13px; - font-weight: 500; - border-bottom: 2px solid transparent; + display: inline-flex; align-items: center; gap: 6px; + padding: 8px 13px; + background: none; border: none; border-bottom: 2px solid transparent; + color: var(--text-secondary); font-weight: 550; white-space: nowrap; margin-bottom: -1px; - transition: color 0.15s, border-color 0.15s; } .tab-btn:hover { color: var(--text-primary); } .tab-btn.active { color: var(--accent); border-bottom-color: var(--accent); } +.tab-count { font-size: 11px; padding: 0 6px; border-radius: 8px; background: var(--bg-elevated); color: var(--text-secondary); } -/* Period selector */ -.period-selector { - display: flex; - gap: 4px; -} -.period-btn { - padding: 5px 12px; - border-radius: var(--r-sm); - background: var(--bg-elevated); - border: 1px solid var(--border); - color: var(--text-secondary); - font-size: 12px; - font-weight: 500; - transition: all 0.15s; -} -.period-btn:hover { color: var(--text-primary); } -.period-btn.active { - background: var(--accent-dim); - border-color: var(--accent-border); - color: var(--accent); -} - -/* Gauge */ -.gauge-container { - display: flex; - flex-direction: column; - align-items: center; - gap: 8px; -} - -.gauge-label { - font-size: 11px; - text-transform: uppercase; - letter-spacing: 0.06em; - color: var(--text-muted); -} - -.gauge-value { - font-size: 22px; - font-weight: 700; - font-family: var(--font-mono); -} +.list-toolbar { display: flex; justify-content: flex-end; margin-bottom: 10px; } -/* Select */ -select { +.alarm-list { display: flex; flex-direction: column; gap: 6px; } +.alarm-list-head, .alarm-main { + display: grid; + grid-template-columns: 28px 76px minmax(0, 1fr) 130px 104px 118px 190px; + grid-template-areas: "exp time desc val prio state act"; + align-items: center; gap: 10px; +} +.alarm-expand { grid-area: exp; } +.alarm-time { grid-area: time; } +.alarm-desc { grid-area: desc; } +.alarm-val { grid-area: val; } +.alarm-prio { grid-area: prio; } +.alarm-state { grid-area: state; } +.alarm-actions { grid-area: act; } +.alarm-list-head { padding: 0 12px 4px; font-size: 11px; font-weight: 650; text-transform: uppercase; letter-spacing: 0.06em; color: var(--text-muted); } +.alarm-row { background: var(--bg-elevated); border: 1px solid var(--border); - color: var(--text-primary); - padding: 6px 10px; + border-left: 4px solid var(--p4); border-radius: var(--r-md); - font-size: 13px; - font-family: var(--font-sans); - cursor: pointer; - outline: none; + padding: 9px 12px; } -select:focus { border-color: var(--accent); } - -/* Input */ -input[type="text"], input[type="date"] { - background: var(--bg-elevated); - border: 1px solid var(--border); - color: var(--text-primary); - padding: 6px 10px; - border-radius: var(--r-md); +.alarm-row.p1 { border-left-color: var(--p1); } +.alarm-row.p2 { border-left-color: var(--p2); } +.alarm-row.p3 { border-left-color: var(--p3); } +.alarm-row.state-unack.p1 { background: linear-gradient(90deg, var(--p1-dim), var(--bg-elevated) 50%); } +.alarm-row.state-rtn_unack, .alarm-row.state-shelved, .alarm-row.state-suppressed { border-left-style: dashed; } +.alarm-row.state-shelved, .alarm-row.state-suppressed { opacity: 0.85; } +.alarm-expand { width: 26px; height: 26px; display: inline-flex; align-items: center; justify-content: center; background: none; border: none; color: var(--text-muted); border-radius: 6px; } +.alarm-expand:hover { background: var(--bg-hover); color: var(--text-primary); } +.alarm-cell { display: flex; flex-direction: column; min-width: 0; } +.alarm-msg { font-weight: 600; } +.alarm-desc .muted { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } +.alarm-actions { flex-direction: row; gap: 6px; justify-content: flex-end; align-items: center; flex-wrap: wrap; } +.alarm-actions .muted { display: inline-flex; align-items: center; gap: 4px; } +.alarm-details { + display: grid; grid-template-columns: repeat(auto-fit, minmax(240px, 1fr)); gap: 10px 20px; + margin: 10px 0 2px 38px; padding-top: 10px; border-top: 1px solid var(--border); font-size: 13px; - font-family: var(--font-sans); - outline: none; } -input:focus { border-color: var(--accent); } - -/* Checkbox */ -input[type="checkbox"] { accent-color: var(--accent); cursor: pointer; } - -/* Value flash animation */ -@keyframes value-flash { - 0% { color: var(--accent); } - 100% { color: var(--text-primary); } +.alarm-details dt { font-size: 11px; text-transform: uppercase; letter-spacing: 0.05em; color: var(--text-muted); } + +.shelve-form { + display: flex; flex-wrap: wrap; align-items: center; gap: 8px; + margin: 10px 0 0 38px; padding: 10px; border-radius: var(--r-md); + background: var(--bg-card); border: 1px solid var(--border); +} +.shelve-form input { flex: 1; min-width: 200px; } +.shelve-label { font-size: 12px; color: var(--text-secondary); } +.shelve-actions { display: flex; gap: 6px; } +.shelve-form .small { flex-basis: 100%; } + +.journal-filter { margin-bottom: 12px; flex-wrap: wrap; } +.ev-tag { font-size: 11px; font-weight: 650; padding: 2px 7px; border-radius: var(--r-sm); background: var(--bg-elevated); color: var(--text-secondary); white-space: nowrap; } +.ev-tag.ev-alarm { color: var(--p1); background: var(--p1-dim); } +.ev-tag.ev-ack { color: var(--accent); background: var(--accent-dim); } +.ev-tag.ev-rtn { color: var(--ok); background: rgba(34, 197, 94, 0.12); } +.db-intro { margin-bottom: 12px; font-size: 13px; } + +/* State legend */ +.state-legend { display: flex; flex-direction: column; gap: 14px; } +.state-flow { display: flex; flex-direction: column; gap: 8px; overflow-x: auto; } +.flow-row { display: flex; align-items: center; gap: 6px; flex-wrap: wrap; } +.flow-node { padding: 4px 10px; border-radius: 999px; border: 1px solid var(--border-bright); font-size: 12px; font-weight: 600; white-space: nowrap; } +.flow-node.unack { background: var(--p2); color: #0b0e12; border-color: var(--p2); } +.flow-node.acked { border-color: var(--p2); color: var(--p2); } +.flow-node.rtn { border: 1px dashed var(--p2); color: var(--text-secondary); } +.flow-arrow { display: inline-flex; flex-direction: column; align-items: center; font-size: 10px; color: var(--text-muted); line-height: 1; } +.flow-arrow span { font-size: 16px; } +.state-list { display: flex; flex-direction: column; gap: 8px; } +.state-list-row { display: grid; grid-template-columns: 120px 1fr; gap: 12px; align-items: start; font-size: 13px; } +.state-list-row dd { color: var(--text-secondary); } + +/* ===================================================================== */ +/* One-line diagram */ +/* ===================================================================== */ + +.ol-layout { display: grid; grid-template-columns: minmax(0, 1fr) 300px; gap: 14px; align-items: start; } +.ol-layout:has(> :only-child) { grid-template-columns: minmax(0, 1fr); } +.ol-card { padding: 12px; } +.ol-legend { display: flex; gap: 16px; flex-wrap: wrap; font-size: 12px; color: var(--text-secondary); padding: 0 4px 8px; } +.ol-legend span { display: inline-flex; align-items: center; gap: 6px; } +.lg { display: inline-block; width: 22px; height: 0; border-top: 3px solid; } +.lg-flow { border-color: var(--flow); } +.lg-live { border-color: var(--energized); } +.lg-dead { border-color: var(--dead); border-top-style: dashed; } +.ol-scroll { overflow-x: auto; } +.ol-svg { display: block; width: 100%; max-width: 1000px; height: auto; margin: 0 auto; } +.ol-svg.ol-wide { min-width: 680px; } +.ol-svg.ol-narrow { max-width: 440px; } + +.wire { stroke-width: 3; stroke-linejoin: round; } +.wire-live { stroke: var(--energized); } +.wire-dead { stroke: var(--dead); stroke-dasharray: 6 5; } +.wire-flow { stroke: var(--flow); stroke-width: 3.5; } +.bus { stroke-width: 6; stroke-linecap: round; } +.bus-live { stroke: var(--flow); } +.bus-dead { stroke: var(--dead); } +.ol-wire-label { fill: var(--text-muted); font-size: 11px; font-family: var(--font-mono); } + +.ol-node { cursor: pointer; } +.ol-box { fill: var(--bg-elevated); stroke: var(--border-bright); stroke-width: 1.5; transition: stroke 0.2s; } +.ol-node:hover .ol-box { stroke: var(--text-secondary); } +.ol-node.selected .ol-box { stroke: var(--accent); stroke-width: 2.5; } +.ol-node.dead .ol-box { fill: var(--bg-card); } +.ol-node.alarmed.p1 .ol-box { stroke: var(--p1); stroke-width: 2.5; } +.ol-node.alarmed.p2 .ol-box { stroke: var(--p2); stroke-width: 2.5; } +.ol-node.alarmed.p3 .ol-box { stroke: var(--p3); stroke-width: 2.5; } +.ol-node.alarmed.p4 .ol-box { stroke: var(--p4); stroke-width: 2.5; } +.ol-name { fill: var(--text-primary); font-size: 13px; font-weight: 650; } +.ol-status { fill: var(--text-secondary); font-size: 11.5px; font-weight: 500; } +.ol-status.status-on-battery, .ol-status.status-failed { fill: var(--p1); font-weight: 700; } +.ol-status.status-on-generator, .ol-status.status-running, .ol-status.status-cranking, .ol-status.status-sag, .ol-status.status-restarting { fill: var(--p3); } +.ol-status.status-de-energized, .ol-status.status-standby { fill: var(--text-muted); } +.ol-values { fill: var(--text-secondary); font-size: 11.5px; font-family: var(--font-mono); white-space: pre; } +.ol-node.dead .ol-values, .ol-node.dead .ol-name { fill: var(--text-muted); } +.ol-alarm-dot { fill: var(--p4); } +.ol-node.p1 .ol-alarm-dot { fill: var(--p1); animation: alarm-flash 1s infinite; } +.ol-node.p2 .ol-alarm-dot { fill: var(--p2); } +.ol-node.p3 .ol-alarm-dot { fill: var(--p3); } +.ol-detail { display: flex; flex-direction: column; gap: 10px; position: sticky; top: 0; } + +/* ===================================================================== */ +/* Meters and trends */ +/* ===================================================================== */ + +.meters-layout { flex-direction: row; align-items: flex-start; } +.meter-list { width: 210px; flex-shrink: 0; position: sticky; top: 0; } +.meter-select-label, .meter-select { display: none; } +.meter-buttons { display: flex; flex-direction: column; gap: 3px; } +.meter-btn { + display: flex; align-items: center; gap: 9px; + padding: 8px 10px; border-radius: var(--r-md); + background: none; border: 1px solid transparent; + text-align: left; font-size: 13px; color: var(--text-secondary); +} +.meter-btn:hover { background: var(--bg-elevated); color: var(--text-primary); } +.meter-btn.active { background: var(--accent-dim); border-color: var(--accent-border); color: var(--accent); } +.meter-btn-name { flex: 1; } +.meter-detail { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 14px; } +.status-chip { padding: 4px 12px; border-radius: 999px; background: var(--bg-elevated); border: 1px solid var(--border-bright); font-size: 12px; font-weight: 600; } + +.gauges { display: grid; grid-template-columns: repeat(4, 1fr); gap: 8px; } +.gauge { display: flex; flex-direction: column; align-items: center; } +.gauge-svg { width: 100%; max-width: 150px; } +.gauge-track { fill: none; stroke: var(--bg-elevated); stroke-width: 9; stroke-linecap: round; } +.gauge-fill { fill: none; stroke: var(--energized); stroke-width: 9; stroke-linecap: round; transition: d 0.4s; } +.g-warn .gauge-fill { stroke: var(--p3); } +.g-crit .gauge-fill { stroke: var(--p2); } +.gauge-value { fill: var(--text-primary); font-family: var(--font-mono); font-size: 16px; font-weight: 700; } +.gauge-unit { fill: var(--text-muted); font-size: 9px; } +.gauge-label { font-size: 11px; font-weight: 650; text-transform: uppercase; letter-spacing: 0.06em; color: var(--text-muted); } + +.trend-controls { display: flex; gap: 8px; align-items: center; flex-wrap: wrap; } +.trend-card-controls { display: flex; flex-direction: column; gap: 14px; } +.control-row { display: flex; gap: 24px; flex-wrap: wrap; } +.control-group { display: flex; flex-direction: column; gap: 6px; } +.control-label { font-size: 11px; font-weight: 650; text-transform: uppercase; letter-spacing: 0.06em; color: var(--text-muted); } +.chip-row { display: flex; flex-wrap: wrap; gap: 6px; } +.chip { padding: 4px 11px; border-radius: 999px; background: var(--bg-elevated); border: 1px solid var(--border-bright); color: var(--text-secondary); font-size: 12px; font-weight: 550; } +.chip.on { background: var(--bg-card); } + +/* ===================================================================== */ +/* System page */ +/* ===================================================================== */ + +.settings-grid { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 14px; } +.settings-grid .span-2 { grid-column: span 2; } +.settings-grid .card > p { margin-top: 10px; } +.operator-form { display: flex; flex-direction: column; gap: 8px; } +.operator-row { display: flex; gap: 8px; } +.operator-row input { flex: 1; min-width: 0; } + +/* ===================================================================== */ +/* About modal */ +/* ===================================================================== */ + +.modal-backdrop { + position: fixed; inset: 0; z-index: 50; + background: rgba(5, 7, 10, 0.72); + display: flex; align-items: flex-start; justify-content: center; + padding: 5vh 16px; overflow-y: auto; +} +.modal { + width: 100%; max-width: 680px; + background: var(--bg-card); + border: 1px solid var(--border-bright); + border-radius: var(--r-lg); + box-shadow: 0 20px 60px rgba(0, 0, 0, 0.5); +} +.modal-head { display: flex; justify-content: space-between; align-items: center; padding: 16px 20px; border-bottom: 1px solid var(--border); } +.modal-head h2 { font-size: 18px; } +.modal-body { padding: 18px 20px 22px; display: flex; flex-direction: column; gap: 20px; } +.modal-body section { display: flex; flex-direction: column; gap: 8px; } +.modal-body h3 { font-size: 12px; font-weight: 650; text-transform: uppercase; letter-spacing: 0.07em; color: var(--text-muted); } +.modal-body p { color: var(--text-secondary); } +.modal-body .btn { align-self: flex-start; } +.sim-notice { + display: flex; gap: 12px; align-items: flex-start; + padding: 12px 14px; border-radius: var(--r-md); + background: var(--accent-dim); border: 1px solid var(--accent-border); color: var(--text-secondary); +} +.sim-notice svg { color: var(--accent); flex-shrink: 0; margin-top: 2px; } +.sim-notice strong { color: var(--text-primary); } + +.scenario-list { display: flex; flex-direction: column; gap: 8px; } +.scenario-btn { + display: flex; gap: 12px; align-items: flex-start; text-align: left; + padding: 10px 12px; border-radius: var(--r-md); + background: var(--bg-elevated); border: 1px solid var(--border-bright); +} +.scenario-btn:not(:disabled):hover { border-color: var(--accent); } +.scenario-btn svg { color: var(--accent); margin-top: 3px; flex-shrink: 0; } +.scenario-btn span { display: flex; flex-direction: column; } +.scenario-btn small { color: var(--text-secondary); font-size: 12px; } +.scenario-running { display: flex; align-items: center; gap: 8px; color: var(--accent); font-size: 13px; font-weight: 600; } +.pulse-dot { width: 8px; height: 8px; border-radius: 50%; background: var(--accent); animation: alarm-flash 1s infinite; } + +/* ===================================================================== */ +/* Wake screen (shown while the hosted server starts) */ +/* ===================================================================== */ + +.wake-screen { min-height: 100%; display: flex; align-items: center; justify-content: center; padding: 24px 16px; } +.wake-card { width: 100%; max-width: 560px; display: flex; flex-direction: column; gap: 14px; } +.wake-brand { display: flex; align-items: center; gap: 8px; color: var(--accent); font-weight: 700; font-size: 15px; } +.wake-card h1 { font-size: 24px; font-weight: 650; line-height: 1.25; } +.wake-text { color: var(--text-secondary); } +.wake-progress { height: 6px; background: var(--bg-elevated); border-radius: 3px; overflow: hidden; } +.wake-progress-bar { height: 100%; background: var(--accent); border-radius: 3px; transition: width 0.5s ease-out; } +.wake-elapsed { font-size: 12px; color: var(--text-muted); text-align: right; margin-top: -8px; } +.wake-steps { list-style: none; display: flex; flex-direction: column; gap: 8px; } +.wake-step { display: flex; align-items: center; gap: 10px; color: var(--text-muted); } +.wake-step.active { color: var(--text-primary); } +.wake-step.done { color: var(--text-secondary); } +.wake-step-icon { + width: 20px; height: 20px; border-radius: 50%; flex-shrink: 0; + display: inline-flex; align-items: center; justify-content: center; + border: 1px solid var(--border-bright); +} +.wake-step.done .wake-step-icon { background: var(--ok); border-color: var(--ok); color: #0b0e12; } +.wake-step.active .wake-step-icon { border-color: var(--accent); color: var(--accent); } +.wake-slow { padding: 12px 14px; border-radius: var(--r-md); background: var(--p3-dim); border: 1px solid rgba(234, 179, 8, 0.35); display: flex; flex-direction: column; gap: 10px; align-items: flex-start; } +.wake-preview { margin-top: 6px; padding: 14px 16px; border-radius: var(--r-lg); background: var(--bg-card); border: 1px solid var(--border); } +.wake-preview-title { font-size: 11px; font-weight: 650; text-transform: uppercase; letter-spacing: 0.07em; color: var(--text-muted); margin-bottom: 8px; } +.wake-preview ul { padding-left: 18px; color: var(--text-secondary); display: flex; flex-direction: column; gap: 4px; font-size: 13px; } + +/* ===================================================================== */ +/* Responsive */ +/* ===================================================================== */ + +@media (max-width: 1180px) { + .alarm-list-head { display: none; } + .alarm-main { + grid-template-columns: 28px 90px minmax(0, 1fr) auto; + grid-template-areas: + "exp desc desc prio" + "exp time val state" + "exp act act act"; + row-gap: 6px; + } + .alarm-main .alarm-expand { align-self: start; } + .alarm-main .alarm-prio, .alarm-main .alarm-state { align-items: flex-end; } + .alarm-main .alarm-val { flex-direction: row; gap: 8px; align-items: baseline; } + .alarm-main .alarm-actions { justify-content: flex-start; } + .alarm-main .alarm-actions:empty { display: none; } +} + +@media (max-width: 1100px) { + :root { --sidebar-w: 68px; } + .sidebar-brand { justify-content: center; padding: 0; } + .brand-text, .nav-label { display: none; } + .nav-item, .sidebar-about { justify-content: center; padding: 10px; } + .nav-badge { position: absolute; top: 2px; right: 2px; margin: 0; min-width: 18px; font-size: 10px; padding: 0 5px; } + .dash-row { grid-template-columns: minmax(0, 1fr); } + .ol-layout { grid-template-columns: minmax(0, 1fr); } + .ol-detail { position: static; } +} + +@media (max-width: 720px) { + .sidebar { display: none; } + .mobile-only { display: inline-block; } + .topbar { padding: 0 12px; height: 52px; } + .topbar-title { font-size: 14px; } + .sim-pill span, .conn-pill span:last-child { display: none; } + .sim-pill { padding: 5px 7px; } + .page-content { padding: 14px 12px calc(var(--bottom-nav-h) + 18px); } + .page { gap: 14px; } + .alarm-banner, .event-banner { padding-left: 12px; padding-right: 12px; } + .alarm-banner { flex-wrap: wrap; } + .alarm-banner-msg { flex-basis: calc(100% - 140px); } + .alarm-banner-actions { width: 100%; } + .alarm-banner-actions .btn { flex: 1; } + + .kpi-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 10px; grid-auto-flow: dense; } + .kpi-grid > .stat-card:last-child:nth-child(odd) { grid-column: span 2; } + .kpi-alarms > .stat-card:nth-child(2) { grid-column: span 2; } + .ol-card { padding: 8px; } + .stat-card { padding: 12px; } + .stat-card-value { font-size: 21px; } + .equip-grid { grid-template-columns: repeat(auto-fill, minmax(155px, 1fr)); gap: 10px; } + .equip-metrics .metric-value { font-size: 13px; } + + .meters-layout { flex-direction: column; align-items: stretch; } + .meter-list { width: 100%; position: static; display: flex; flex-direction: column; gap: 6px; } + .meter-buttons { display: none; } + .meter-select-label { display: block; font-size: 11px; font-weight: 650; text-transform: uppercase; letter-spacing: 0.06em; color: var(--text-muted); } + .meter-select { display: block; width: 100%; padding: 9px 10px; font-size: 14px; } + .gauges { grid-template-columns: repeat(2, 1fr); row-gap: 14px; } + + .settings-grid { grid-template-columns: minmax(0, 1fr); } + .settings-grid .span-2 { grid-column: auto; } + + .alarm-main { + grid-template-columns: 24px minmax(0, 1fr) auto; + grid-template-areas: + "exp desc prio" + "exp val state" + "act act act"; + column-gap: 8px; + } + .alarm-main .alarm-time { display: none; } + .alarm-main .alarm-val { flex-direction: column; gap: 0; } + .alarm-details, .shelve-form { margin-left: 0; } + .state-list-row { grid-template-columns: 1fr; gap: 4px; } + + .bottom-nav { + display: grid; grid-template-columns: repeat(6, 1fr); + position: fixed; left: 0; right: 0; bottom: 0; z-index: 40; + height: calc(var(--bottom-nav-h) + env(safe-area-inset-bottom)); + padding-bottom: env(safe-area-inset-bottom); + background: var(--bg-surface); + border-top: 1px solid var(--border); + } + .bottom-item { + display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 3px; + font-size: 10.5px; font-weight: 550; color: var(--text-muted); + } + .bottom-item.active { color: var(--accent); } + .bottom-icon { position: relative; display: inline-flex; } + .bottom-icon .nav-badge { position: absolute; top: -6px; right: -12px; margin: 0; min-width: 17px; font-size: 10px; padding: 0 4px; } + + .modal-backdrop { padding: 0; align-items: stretch; } + .modal { max-width: none; border-radius: 0; border: none; min-height: 100%; } } -.value-updated { animation: value-flash 0.8s ease-out; } diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 4bc8f2b..bddc2f7 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -1,99 +1,132 @@ -import React, { useState, useEffect, useCallback } from 'react' -import { BrowserRouter, Routes, Route, NavLink, useLocation } from 'react-router-dom' -import { LayoutDashboard, GitBranch, Gauge, Bell, TrendingUp, Settings, Zap, AlertTriangle, CheckCircle, XCircle } from 'lucide-react' +import React, { Suspense, lazy, useCallback, useState } from 'react' +import { HashRouter, Routes, Route, NavLink, Navigate } from 'react-router-dom' +import { LayoutDashboard, GitBranch, Gauge, Bell, TrendingUp, Settings, Zap, Info, FlaskConical } from 'lucide-react' +import { LiveFeedProvider, useFeed } from './services/LiveFeed' +import WakeScreen from './components/WakeScreen' +import AboutPanel from './components/AboutPanel' +import AlarmBanner from './components/AlarmBanner' import Dashboard from './pages/Dashboard' import OneLineDiagram from './pages/OneLineDiagram' -import Meters from './pages/Meters' import Alarms from './pages/Alarms' -import Trending from './pages/Trending' import SettingsPage from './pages/SettingsPage' -import { useWebSocket } from './services/websocket' import './App.css' -function AppShell() { - const { readings, connected } = useWebSocket() - const [alarmCount, setAlarmCount] = useState(0) - const location = useLocation() +// Chart heavy pages load on demand to keep the first load small +const Meters = lazy(() => import('./pages/Meters')) +const Trending = lazy(() => import('./pages/Trending')) - useEffect(() => { - fetch('/api/alarms') - .then(r => r.json()) - .then(d => setAlarmCount(d.length)) - .catch(() => {}) - }, [readings]) +const NAV = [ + { to: '/', icon: LayoutDashboard, label: 'Overview' }, + { to: '/one-line', icon: GitBranch, label: 'One-Line' }, + { to: '/meters', icon: Gauge, label: 'Meters' }, + { to: '/alarms', icon: Bell, label: 'Alarms', alarms: true }, + { to: '/trending', icon: TrendingUp, label: 'Trends' }, + { to: '/settings', icon: Settings, label: 'System' }, +] - const navItems = [ - { to: '/', icon: LayoutDashboard, label: 'Dashboard' }, - { to: '/one-line', icon: GitBranch, label: 'One-Line' }, - { to: '/meters', icon: Gauge, label: 'Meters' }, - { to: '/alarms', icon: Bell, label: 'Alarms', badge: alarmCount }, - { to: '/trending', icon: TrendingUp, label: 'Trending' }, - { to: '/settings', icon: Settings, label: 'Settings' }, - ] +function ConnectionPill({ phase }) { + const live = phase === 'live' + return ( +
+ + {live ? 'Live' : 'Reconnecting'} +
+ ) +} + +function Shell() { + const feed = useFeed() + const [aboutOpen, setAboutOpen] = useState(false) + const openAbout = useCallback(() => setAboutOpen(true), []) + const closeAbout = useCallback(() => setAboutOpen(false), []) + + if (!feed.hasData) { + return + } + + const unacked = feed.summary?.unacknowledged ?? 0 + const topPriority = feed.summary?.highest_unacked?.priority return (
- {/* Sidebar */} - {/* Main area */}
- {/* Top bar */}
-

Power Dashboard

+
+ +

Data Center Power Monitoring

+
-
- {connected ? : } - {connected ? 'Live' : 'Disconnected'} -
- {alarmCount > 0 && ( -
- - {alarmCount} active alarm{alarmCount !== 1 ? 's' : ''} -
+ {feed.system?.simulated && ( + )} + +
- {/* Page content */} -
- - } /> - } /> - } /> - } /> - } /> - } /> - + + +
+ Loading...
}> + + } /> + } /> + } /> + } /> + } /> + } /> + } /> + +
+ + + + ) } +// Hash based routing (/#/alarms) so any page can be refreshed or deep linked +// on a static host without a server side rewrite rule. export default function App() { return ( - - - + + + + + ) } diff --git a/frontend/src/components/AboutPanel.jsx b/frontend/src/components/AboutPanel.jsx new file mode 100644 index 0000000..61a0102 --- /dev/null +++ b/frontend/src/components/AboutPanel.jsx @@ -0,0 +1,136 @@ +import React, { useEffect, useRef, useState } from 'react' +import { X, FlaskConical, Play, Github } from 'lucide-react' +import { useFeed } from '../services/LiveFeed' +import { api } from '../services/api' +import StateLegend from './StateLegend' + +const REPO_URL = 'https://github.com/AddisonTech/power-dashboard' + +export function ScenarioButtons({ onDone }) { + const { demo, system } = useFeed() + const [busy, setBusy] = useState(null) + const [error, setError] = useState(null) + const running = system?.scenario + + if (!demo?.events_enabled) { + return

Demo events are disabled on this server.

+ } + + const fire = async (name) => { + setBusy(name) + setError(null) + try { + await api.triggerEvent(name) + onDone?.() + } catch (e) { + setError(e.message) + } finally { + setBusy(null) + } + } + + return ( +
+ {running && ( +
+ {running.label} in progress +
+ )} + {demo.scenarios.map(s => ( + + ))} + {error &&

{error}

} +
+ ) +} + +export default function AboutPanel({ open, onClose }) { + const closeRef = useRef(null) + + useEffect(() => { + if (!open) return undefined + closeRef.current?.focus() + const onKey = e => { if (e.key === 'Escape') onClose() } + window.addEventListener('keydown', onKey) + return () => window.removeEventListener('keydown', onKey) + }, [open, onClose]) + + if (!open) return null + + return ( +
+
e.stopPropagation()}> +
+

About this demo

+ +
+ +
+
+ +
+ All data on this site is simulated. No real facility, customer, or + equipment is connected. A Python simulator models a small data center and updates every + 2 seconds. +
+
+ +
+

What is modeled

+

+ A 2N data center of about 450 kW: a 480 V utility service and a 750 kW standby generator + feed an automatic transfer switch, then the main switchboard. Two 400 kVA UPS systems + (A side and B side) each feed two 150 kVA PDUs at 208 V, and the cooling plant runs from + its own panel. Values roll up from the IT load through transformer and UPS losses, so + the numbers add up the way a real site does. +

+
+ +
+

Try it

+

+ Events also happen on their own every few minutes. The event rate is faster than a real + site so there is always something to look at. +

+ +
+ +
+

ISA 18.2 alarm handling

+

+ Every alarm in the master alarm database is rationalized with a priority, a consequence, + and an operator response. Alarms follow the ISA 18.2 state model, use deadbands and + delays to prevent chatter, can be shelved for a limited time with a logged reason, and + some are suppressed by design (for example, generator alarms while the generator is off). + Every transition is written to the alarm journal. +

+ +
+ +
+

Real hardware

+

+ The same backend polls Modbus TCP meters when started with DATA_SOURCE=modbus and + a register map. The alarm engine, history, and this interface work the same either way. +

+
+ +
+

Stack

+

Python, FastAPI, WebSocket, SQLite, React, Vite. Hosted on Render.

+ + Source on GitHub + +
+
+
+
+ ) +} diff --git a/frontend/src/components/AlarmBanner.jsx b/frontend/src/components/AlarmBanner.jsx new file mode 100644 index 0000000..6416fff --- /dev/null +++ b/frontend/src/components/AlarmBanner.jsx @@ -0,0 +1,50 @@ +import React, { useEffect, useState } from 'react' +import { Link } from 'react-router-dom' +import { Activity } from 'lucide-react' +import { useFeed } from '../services/LiveFeed' +import { api } from '../services/api' +import { getOperator } from '../lib/operator' +import { ago } from '../lib/format' +import { PriorityBadge, StateBadge } from './Badges' + +// Highest priority unacknowledged alarm, always visible under the top bar +export default function AlarmBanner() { + const { summary, system } = useFeed() + const [, tick] = useState(0) + useEffect(() => { + const t = setInterval(() => tick(n => n + 1), 1000) + return () => clearInterval(t) + }, []) + + const top = summary?.highest_unacked + const scenario = system?.scenario + + return ( + <> + {scenario && ( +
+ + Demo event in progress: {scenario.label} +
+ )} + {top && ( +
+ + + + {top.message} + {top.meter_name}, {ago(top.activated_at)} + + + + + {summary.unacknowledged > 1 ? `All ${summary.unacknowledged}` : 'Alarms'} + + +
+ )} + + ) +} diff --git a/frontend/src/components/Badges.jsx b/frontend/src/components/Badges.jsx new file mode 100644 index 0000000..c3523b1 --- /dev/null +++ b/frontend/src/components/Badges.jsx @@ -0,0 +1,22 @@ +import React from 'react' +import { PRIORITY, STATES } from '../lib/alarms' + +export function PriorityBadge({ priority, compact = false }) { + const p = PRIORITY[priority] || PRIORITY[4] + return ( + + P{priority}{!compact && {p.label}} + + ) +} + +export function StateBadge({ state, priority }) { + const s = STATES[state] || STATES.NORMAL + const p = PRIORITY[priority] || PRIORITY[4] + return ( + + + ) +} diff --git a/frontend/src/components/Sparkline.jsx b/frontend/src/components/Sparkline.jsx new file mode 100644 index 0000000..b2f139c --- /dev/null +++ b/frontend/src/components/Sparkline.jsx @@ -0,0 +1,86 @@ +import React, { useEffect, useRef, useState } from 'react' + +// Lightweight SVG line, used where pulling in a chart library is not worth it +export function Sparkline({ data, height = 36, className = '' }) { + if (!data || data.length < 2) return
+ const min = Math.min(...data) + const max = Math.max(...data) + const span = max - min || 1 + const w = 100 + const pts = data.map((v, i) => [(i / (data.length - 1)) * w, height - 2 - ((v - min) / span) * (height - 4)]) + const line = pts.map(([x, y], i) => `${i ? 'L' : 'M'}${x.toFixed(2)},${y.toFixed(2)}`).join(' ') + return ( + + ) +} + +function useWidth() { + const ref = useRef(null) + const [width, setWidth] = useState(0) + useEffect(() => { + const el = ref.current + if (!el) return undefined + setWidth(el.clientWidth) + if (typeof ResizeObserver === 'undefined') return undefined + const ro = new ResizeObserver(([entry]) => setWidth(Math.round(entry.contentRect.width))) + ro.observe(el) + return () => ro.disconnect() + }, []) + return [ref, width] +} + +// Small time series chart with axis labels for the dashboard +export function AreaChart(props) { + const [ref, width] = useWidth() + return ( +
+ {width > 0 && } +
+ ) +} + +function AreaChartSvg({ points, width, height = 180, unit = '', format = v => v.toFixed(0) }) { + if (!points || points.length < 2) { + return
Collecting data...
+ } + const ys = points.map(p => p.v) + let min = Math.min(...ys) + let max = Math.max(...ys) + const pad = (max - min) * 0.15 || max * 0.05 || 1 + min = Math.max(0, min - pad) + max = max + pad + const t0 = points[0].t + const t1 = points[points.length - 1].t + const W = width + const H = height + const left = 44 + const bottom = 20 + const x = t => left + ((t - t0) / (t1 - t0 || 1)) * (W - left - 4) + const y = v => 6 + (1 - (v - min) / (max - min)) * (H - bottom - 10) + const line = points.map((p, i) => `${i ? 'L' : 'M'}${x(p.t).toFixed(1)},${y(p.v).toFixed(1)}`).join(' ') + const ticks = [min, (min + max) / 2, max] + const timeTicks = [t0, (t0 + t1) / 2, t1] + const fmtTime = t => new Date(t * 1000).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', hourCycle: 'h23' }) + + return ( + + {ticks.map((v, i) => ( + + + {format(v)}{unit} + + ))} + {timeTicks.map((t, i) => ( + {fmtTime(t)} + ))} + + + + ) +} diff --git a/frontend/src/components/StateLegend.jsx b/frontend/src/components/StateLegend.jsx new file mode 100644 index 0000000..538817b --- /dev/null +++ b/frontend/src/components/StateLegend.jsx @@ -0,0 +1,39 @@ +import React from 'react' +import { STATES } from '../lib/alarms' +import { StateBadge } from './Badges' + +// The ISA 18.2 alarm state model, in a form a visitor can follow +export default function StateLegend({ compact = false }) { + return ( +
+
+
+ Normal + condition→ + Unacknowledged + acknowledge→ + Acknowledged + clears→ + Normal +
+
+ Unacknowledged + clears→ + Returned to normal, unack + acknowledge→ + Normal +
+
+ {!compact && ( +
+ {['UNACK', 'ACKED', 'RTN_UNACK', 'SHELVED', 'SUPPRESSED'].map(s => ( +
+
+
{STATES[s].help}
+
+ ))} +
+ )} +
+ ) +} diff --git a/frontend/src/components/WakeScreen.jsx b/frontend/src/components/WakeScreen.jsx new file mode 100644 index 0000000..ac7c6c1 --- /dev/null +++ b/frontend/src/components/WakeScreen.jsx @@ -0,0 +1,84 @@ +import React, { useEffect, useState } from 'react' +import { Zap, Check, Loader2, RotateCcw } from 'lucide-react' + +const EXPECTED_S = 50 +const SLOW_S = 90 + +function Step({ done, active, children }) { + return ( +
  • + + {done ? : active ? : null} + + {children} +
  • + ) +} + +export default function WakeScreen({ serverAwake, startedAt, retry }) { + const [now, setNow] = useState(Date.now()) + useEffect(() => { + const t = setInterval(() => setNow(Date.now()), 500) + return () => clearInterval(t) + }, []) + + const elapsed = Math.max(0, Math.floor((now - startedAt) / 1000)) + const quick = elapsed < 4 && !serverAwake + const slow = elapsed >= SLOW_S && !serverAwake + const pct = serverAwake ? 92 : Math.min(88, (elapsed / EXPECTED_S) * 88) + + const title = quick + ? 'Connecting to the live system' + : serverAwake + ? 'Server is awake, starting the live feed' + : 'Waking up the demo server' + + return ( +
    +
    +
    + Power Dashboard +
    +

    {title}

    + + {!quick && ( +

    + This portfolio demo is hosted on a free tier that sleeps when nobody is using it. + The first visit after a quiet period takes about 30 to 60 seconds while the server + starts. After that, everything updates live every 2 seconds. +

    + )} + + +
    + ) +} diff --git a/frontend/src/index.css b/frontend/src/index.css index 616a929..23ce1c2 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -1,39 +1,53 @@ *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; } +/* + * HMI color philosophy (ISA-101 style): normal equipment is shown in neutral + * grays so that color is reserved for abnormal conditions and alarm priority. + */ :root { - --bg-base: #0a0a0a; - --bg-surface: #111111; - --bg-elevated: #1a1a1a; - --bg-card: #1e1e1e; - --bg-hover: #252525; - --border: #2a2a2a; - --border-bright: #383838; - - --text-primary: #e8e8e8; - --text-secondary: #a0a0a0; - --text-muted: #606060; - - --accent: #00b4d8; - --accent-dim: rgba(0, 180, 216, 0.15); - --accent-border: rgba(0, 180, 216, 0.3); - - --status-ok: #22c55e; - --status-ok-dim: rgba(34, 197, 94, 0.12); - --status-warn: #f59e0b; - --status-warn-dim: rgba(245, 158, 11, 0.12); - --status-crit: #ef4444; - --status-crit-dim: rgba(239, 68, 68, 0.12); - --status-off: #4b5563; - - --font-mono: 'JetBrains Mono', 'Courier New', monospace; - --font-sans: 'Inter', system-ui, sans-serif; + color-scheme: dark; + + --bg-base: #0b0e12; + --bg-surface: #10141a; + --bg-card: #151a21; + --bg-elevated: #1b212a; + --bg-hover: #1f2630; + --border: #262e3a; + --border-bright:#35404f; + --grid: #1f2630; + + --text-primary: #e7eaf0; + --text-secondary: #a3adbd; + --text-muted: #707a8b; + + --accent: #60a5fa; + --accent-dim: rgba(96, 165, 250, 0.14); + --accent-border: rgba(96, 165, 250, 0.38); + + /* Alarm priorities */ + --p1: #ef4444; + --p2: #f97316; + --p3: #eab308; + --p4: #a78bfa; + --p1-dim: rgba(239, 68, 68, 0.14); + --p2-dim: rgba(249, 115, 22, 0.14); + --p3-dim: rgba(234, 179, 8, 0.14); + --p4-dim: rgba(167, 139, 250, 0.14); + + --ok: #22c55e; + --energized: #9aa4b4; + --flow: #60a5fa; + --dead: #2c3440; + + --font-mono: 'JetBrains Mono', ui-monospace, 'SFMono-Regular', Menlo, Consolas, monospace; + --font-sans: 'Inter', system-ui, -apple-system, 'Segoe UI', Roboto, sans-serif; --r-sm: 4px; --r-md: 8px; --r-lg: 12px; - --shadow: 0 2px 8px rgba(0,0,0,0.4); - --shadow-lg: 0 4px 24px rgba(0,0,0,0.6); + --sidebar-w: 228px; + --bottom-nav-h: 62px; } html, body, #root { @@ -43,50 +57,35 @@ html, body, #root { font-family: var(--font-sans); font-size: 14px; line-height: 1.5; + -webkit-font-smoothing: antialiased; } -/* Scrollbar */ -::-webkit-scrollbar { width: 6px; height: 6px; } -::-webkit-scrollbar-track { background: var(--bg-base); } -::-webkit-scrollbar-thumb { background: var(--border-bright); border-radius: 3px; } -::-webkit-scrollbar-thumb:hover { background: #4a4a4a; } +::-webkit-scrollbar { width: 8px; height: 8px; } +::-webkit-scrollbar-track { background: transparent; } +::-webkit-scrollbar-thumb { background: var(--border-bright); border-radius: 4px; } + +button { cursor: pointer; font-family: inherit; font-size: inherit; color: inherit; } +button:disabled { cursor: not-allowed; opacity: 0.5; } +a { text-decoration: none; color: inherit; } +table { border-collapse: collapse; width: 100%; } +th { text-align: left; } +code { font-family: var(--font-mono); font-size: 0.92em; background: var(--bg-elevated); padding: 1px 5px; border-radius: 4px; } + +:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; } -/* Utility */ .mono { font-family: var(--font-mono); } -.text-ok { color: var(--status-ok); } -.text-warn { color: var(--status-warn); } -.text-crit { color: var(--status-crit); } -.text-muted { color: var(--text-muted); } -.text-secondary { color: var(--text-secondary); } - -.badge { - display: inline-flex; - align-items: center; - gap: 4px; - padding: 2px 8px; - border-radius: var(--r-sm); - font-size: 11px; - font-weight: 600; - text-transform: uppercase; - letter-spacing: 0.05em; -} -.badge-ok { background: var(--status-ok-dim); color: var(--status-ok); } -.badge-warn { background: var(--status-warn-dim); color: var(--status-warn); } -.badge-crit { background: var(--status-crit-dim); color: var(--status-crit); } - -.card { - background: var(--bg-card); - border: 1px solid var(--border); - border-radius: var(--r-lg); - padding: 20px; -} +.muted { color: var(--text-muted); } +.small { font-size: 12px; } +.nowrap { white-space: nowrap; } -button { - cursor: pointer; - font-family: var(--font-sans); -} +@keyframes spin { to { transform: rotate(360deg); } } +.spin { animation: spin 1s linear infinite; } -a { text-decoration: none; color: inherit; } +@keyframes alarm-flash { + 0%, 55% { opacity: 1; } + 56%, 100% { opacity: 0.25; } +} -table { border-collapse: collapse; width: 100%; } -th { text-align: left; } +@media (prefers-reduced-motion: reduce) { + *, *::before, *::after { animation: none !important; transition: none !important; } +} diff --git a/frontend/src/lib/alarms.js b/frontend/src/lib/alarms.js new file mode 100644 index 0000000..e79394f --- /dev/null +++ b/frontend/src/lib/alarms.js @@ -0,0 +1,29 @@ +export const PRIORITY = { + 1: { label: 'Critical', cls: 'p1' }, + 2: { label: 'High', cls: 'p2' }, + 3: { label: 'Medium', cls: 'p3' }, + 4: { label: 'Low', cls: 'p4' }, +} + +// ISA 18.2 alarm states +export const STATES = { + NORMAL: { label: 'Normal', short: 'Normal', help: 'Process normal. Nothing to acknowledge.' }, + UNACK: { label: 'Unacknowledged', short: 'Unack', help: 'Process is abnormal and no operator has acknowledged it yet. Flashes until acknowledged.' }, + ACKED: { label: 'Acknowledged', short: 'Acked', help: 'Process is still abnormal. The operator has acknowledged it and is responding.' }, + RTN_UNACK: { label: 'Returned to normal, unacknowledged', short: 'RTN unack', help: 'The process recovered before anyone acknowledged. Still needs an acknowledgement so the event is not missed.' }, + SHELVED: { label: 'Shelved', short: 'Shelved', help: 'Temporarily removed from the alarm list by the operator, for a limited time with a logged reason.' }, + SUPPRESSED: { label: 'Suppressed by design', short: 'Suppressed', help: 'Automatically hidden by a designed rule based on plant state, such as generator alarms while the generator is off.' }, +} + +export const EVENT_LABELS = { + ALARM: 'Alarm', + ACK: 'Acknowledged', + RTN: 'Return to normal', + SHELVE: 'Shelved', + UNSHELVE: 'Unshelved', + SUPPRESS: 'Suppressed', + UNSUPPRESS: 'Unsuppressed', +} + +export const ANNUNCIATED = ['UNACK', 'ACKED', 'RTN_UNACK'] +export const NEEDS_ACK = ['UNACK', 'RTN_UNACK'] diff --git a/frontend/src/lib/format.js b/frontend/src/lib/format.js new file mode 100644 index 0000000..14acf8b --- /dev/null +++ b/frontend/src/lib/format.js @@ -0,0 +1,48 @@ +export function num(v, digits = 1) { + return v == null || Number.isNaN(v) ? '--' : Number(v).toFixed(digits) +} + +export function kw(v) { + if (v == null) return '--' + return v >= 1000 ? `${(v / 1000).toFixed(2)} MW` : `${v.toFixed(0)} kW` +} + +export function timeOf(iso) { + if (!iso) return '--' + return new Date(iso).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit', hourCycle: 'h23' }) +} + +export function dateTimeOf(iso) { + if (!iso) return '--' + const d = new Date(iso) + return `${d.toLocaleDateString([], { month: 'short', day: 'numeric' })} ${timeOf(iso)}` +} + +export function ago(iso, now = Date.now()) { + if (!iso) return '' + const s = Math.max(0, Math.round((now - new Date(iso).getTime()) / 1000)) + if (s < 60) return `${s}s ago` + if (s < 3600) return `${Math.floor(s / 60)}m ago` + return `${Math.floor(s / 3600)}h ${Math.floor((s % 3600) / 60)}m ago` +} + +export function until(iso, now = Date.now()) { + if (!iso) return '' + const s = Math.max(0, Math.round((new Date(iso).getTime() - now) / 1000)) + const m = Math.floor(s / 60) + return m > 0 ? `${m}m ${s % 60}s` : `${s}s` +} + +export function alarmValue(a, v = a.value) { + if (v == null) return '--' + if (a.kind === 'DISCRETE') return v ? 'Active' : 'Normal' + const digits = a.unit === '' ? 3 : a.unit === 'Hz' ? 2 : 1 + return `${Number(v).toFixed(digits)}${a.unit ? ` ${a.unit}` : ''}` +} + +export function setpointText(a) { + if (a.kind === 'DISCRETE') return 'discrete' + const op = a.kind.startsWith('HI') ? '>' : '<' + const digits = a.unit === '' ? 2 : 1 + return `${op} ${Number(a.setpoint).toFixed(digits)}${a.unit ? ` ${a.unit}` : ''}` +} diff --git a/frontend/src/lib/operator.js b/frontend/src/lib/operator.js new file mode 100644 index 0000000..c3b7a0e --- /dev/null +++ b/frontend/src/lib/operator.js @@ -0,0 +1,24 @@ +// The name recorded in the alarm journal for acknowledge and shelve actions. +const KEY = 'pd.operator' + +export function getOperator() { + try { + return localStorage.getItem(KEY) || 'demo visitor' + } catch { + return 'demo visitor' + } +} + +export function setOperator(name) { + try { + localStorage.setItem(KEY, name) + } catch { /* storage unavailable */ } +} + +export function readFlag(key) { + try { return localStorage.getItem(key) === '1' } catch { return false } +} + +export function writeFlag(key, on) { + try { on ? localStorage.setItem(key, '1') : localStorage.removeItem(key) } catch { /* ignore */ } +} diff --git a/frontend/src/pages/Alarms.jsx b/frontend/src/pages/Alarms.jsx index 4b0bfb1..7d0a8f1 100644 --- a/frontend/src/pages/Alarms.jsx +++ b/frontend/src/pages/Alarms.jsx @@ -1,120 +1,326 @@ -import React, { useState, useEffect } from 'react' -import { Bell, CheckSquare, Clock, Filter } from 'lucide-react' +import React, { useCallback, useEffect, useState } from 'react' +import { ChevronDown, ChevronRight, Clock, HelpCircle } from 'lucide-react' +import { useFeed } from '../services/LiveFeed' import { api } from '../services/api' +import { PriorityBadge, StateBadge } from '../components/Badges' +import StateLegend from '../components/StateLegend' +import { ANNUNCIATED, NEEDS_ACK, EVENT_LABELS, PRIORITY, STATES } from '../lib/alarms' +import { alarmValue, setpointText, dateTimeOf, ago, until, timeOf } from '../lib/format' +import { getOperator } from '../lib/operator' -const PRIORITY_LABELS = { 1: 'Critical', 2: 'High', 3: 'Medium', 4: 'Low' } -const PRIORITY_CLASSES = { 1: 'crit', 2: 'warn', 3: 'accent', 4: 'off' } +const SHELVE_OPTIONS = [5, 15, 60] +const TABS = [ + { key: 'active', label: 'Alarm list' }, + { key: 'shelved', label: 'Shelved' }, + { key: 'suppressed', label: 'Suppressed' }, + { key: 'history', label: 'Journal' }, + { key: 'database', label: 'Alarm database' }, +] -function AlarmRow({ alarm, onAck }) { - const pClass = PRIORITY_CLASSES[alarm.priority] || 'off' +function useNow(ms = 1000) { + const [now, setNow] = useState(Date.now()) + useEffect(() => { + const t = setInterval(() => setNow(Date.now()), ms) + return () => clearInterval(t) + }, [ms]) + return now +} + +function ShelveForm({ alarm, onDone, onCancel }) { + const [minutes, setMinutes] = useState(15) + const [reason, setReason] = useState('') + const [error, setError] = useState(null) + const submit = async (e) => { + e.preventDefault() + try { + await api.shelve(alarm.tag, minutes, reason || 'Shelved from the demo', getOperator()) + onDone() + } catch (err) { + setError(err.message) + } + } return ( - - {new Date(alarm.raised_at).toLocaleString()} - {alarm.meter_name} - {alarm.condition} - {alarm.value?.toFixed(3)} - {alarm.threshold?.toFixed(3)} - - - P{alarm.priority} {PRIORITY_LABELS[alarm.priority]} - - - - {alarm.state === 'active' && !alarm.acknowledged ? ( - - ) : alarm.acknowledged ? ( - ACK - ) : ( - Cleared - )} - - + ))} +
    + setReason(e.target.value)} aria-label="Shelve reason" /> +
    + + +
    + {error &&

    {error}

    } +

    ISA 18.2 shelving is time limited. The alarm returns automatically when the timer runs out.

    + + ) +} + +function AlarmRow({ alarm, now }) { + const [open, setOpen] = useState(false) + const [shelving, setShelving] = useState(false) + const [error, setError] = useState(null) + const needsAck = NEEDS_ACK.includes(alarm.state) + + const act = useCallback(async (fn) => { + setError(null) + try { await fn() } catch (e) { setError(e.message) } + }, []) + + return ( +
    +
    + +
    + {timeOf(alarm.activated_at || alarm.shelved_at)} + {ago(alarm.activated_at || alarm.shelved_at, now)} +
    +
    +
    {alarm.message}
    +
    {alarm.tag}, {alarm.meter_name}
    + {alarm.state === 'SUPPRESSED' &&
    {alarm.suppress_reason}
    } +
    +
    + {alarmValue(alarm)} + {alarm.kind === 'DISCRETE' ? 'discrete signal' : `limit ${setpointText(alarm)}`} +
    +
    +
    +
    + {needsAck && ( + + )} + {ANNUNCIATED.includes(alarm.state) && ( + + )} + {alarm.state === 'SHELVED' && ( + <> + {until(alarm.shelved_until, now)} left + + + )} +
    +
    + {shelving && ( + setShelving(false)} onCancel={() => setShelving(false)} /> + )} + {error &&

    {error}

    } + {open && ( +
    +
    Consequence
    {alarm.consequence}
    +
    Operator response
    {alarm.response}
    +
    Value at alarm
    {alarmValue(alarm, alarm.alarm_value)}
    +
    Current value
    {alarmValue(alarm)}
    + {alarm.ack_at &&
    Acknowledged
    {dateTimeOf(alarm.ack_at)} by {alarm.ack_by}
    } + {alarm.rtn_at &&
    Returned to normal
    {dateTimeOf(alarm.rtn_at)}
    } + {alarm.shelve_reason &&
    Shelved
    {alarm.shelve_reason}, by {alarm.shelved_by}, until {timeOf(alarm.shelved_until)}
    } + {alarm.suppress_reason &&
    Suppression rule
    {alarm.suppress_reason}
    } +
    + )} +
    + ) +} + +function AlarmList({ alarms, empty, now }) { + if (alarms.length === 0) return

    {empty}

    + return ( +
    +
    + TimeAlarmValuePriorityStateAction +
    + {alarms.map(a => )} +
    + ) +} + +function Journal({ refreshKey }) { + const [rows, setRows] = useState(null) + const [filter, setFilter] = useState('ALL') + useEffect(() => { + let cancelled = false + api.getAlarmHistory(300).then(r => { if (!cancelled) setRows(r) }).catch(() => {}) + return () => { cancelled = true } + }, [refreshKey]) + + if (!rows) return

    Loading journal...

    + const shown = filter === 'ALL' ? rows : rows.filter(r => r.event === filter) + return ( + <> +
    + {['ALL', 'ALARM', 'ACK', 'RTN', 'SHELVE', 'SUPPRESS'].map(f => ( + + ))} +
    +
    + + + + + + {shown.map(e => ( + + + + + + + + + + ))} + {shown.length === 0 && } + +
    TimeEventAlarmPriorityValueState afterBy
    {dateTimeOf(e.timestamp)}{EVENT_LABELS[e.event] || e.event} +
    {e.message}
    +
    {e.tag}{e.note ? `, ${e.note}` : ''}
    +
    {e.value == null ? '--' : alarmValue({ unit: e.unit, kind: e.unit === '' && e.setpoint === 1 ? 'DISCRETE' : 'HI' }, e.value)}{STATES[e.state]?.short || e.state}{e.operator}
    No journal entries
    +
    + + ) +} + +function AlarmDatabase() { + const [defs, setDefs] = useState(null) + useEffect(() => { api.getAlarmDefs().then(setDefs).catch(() => {}) }, []) + if (!defs) return

    Loading alarm database...

    + return ( + <> +

    + The master alarm database. Each alarm was rationalized: it has one cause, a priority based on + consequence and time to respond, and a documented operator response. Deadbands and delays + keep noisy signals from chattering. +

    +
    + + + + + + {defs.map(d => ( + + + + + + + + + + ))} + +
    TagAlarmLimitDeadbandDelay on/offPriorityOperator response
    {d.tag} +
    {d.message}
    + {d.suppression &&
    Suppressed when: {d.suppression}
    } +
    {setpointText(d)}{d.kind === 'DISCRETE' ? '--' : `${d.deadband} ${d.unit}`}{d.on_delay} / {d.off_delay} scans{d.response}
    +
    + ) } export default function Alarms() { + const { alarms, summary } = useFeed() const [tab, setTab] = useState('active') - const [active, setActive] = useState([]) - const [history, setHistory] = useState([]) - const [loading, setLoading] = useState(false) - - const load = () => { - setLoading(true) - Promise.all([api.getAlarms(), api.getAlarmHistory()]) - .then(([a, h]) => { setActive(a); setHistory(h) }) - .finally(() => setLoading(false)) - } + const [showHelp, setShowHelp] = useState(false) + const now = useNow() - useEffect(() => { - load() - const t = setInterval(load, 10000) - return () => clearInterval(t) - }, []) + const active = alarms.filter(a => ANNUNCIATED.includes(a.state)) + const shelved = alarms.filter(a => a.state === 'SHELVED') + const suppressed = alarms.filter(a => a.state === 'SUPPRESSED') + const counts = { active: active.length, shelved: shelved.length, suppressed: suppressed.length } + const unackedActive = active.filter(a => NEEDS_ACK.includes(a.state)) - const ack = (id) => { - api.acknowledgeAlarm(id).then(load) + const ackAll = async () => { + for (const a of unackedActive) { + try { await api.acknowledge(a.tag, getOperator()) } catch { /* already handled */ } + } } - const displayed = tab === 'active' ? active : history + const rateTone = { Acceptable: '', Manageable: 'p3', 'Above target': 'p2', Flood: 'p1' }[summary?.rate_status] || '' return ( -
    +
    -

    Alarm Management

    -

    ISA 18.2 aligned alarm management system

    +
    +

    Alarms

    +

    Alarm management following ANSI/ISA-18.2

    +
    +
    - {/* Summary */} -
    - {[ - { label: 'Active Alarms', value: active.length, color: 'var(--status-crit)' }, - { label: 'Critical (P1)', value: active.filter(a=>a.priority===1).length, color: 'var(--status-crit)' }, - { label: 'High (P2)', value: active.filter(a=>a.priority===2).length, color: 'var(--status-warn)' }, - { label: 'Total History', value: history.length, color: 'var(--text-secondary)' }, - ].map(({ label, value, color }) => ( -
    -
    {label}
    -
    {value}
    + {showHelp &&
    } + +
    +
    +
    Unacknowledged
    +
    {summary?.unacknowledged ?? 0}
    +
    {summary?.rtn_unack ?? 0} returned to normal
    +
    +
    +
    Alarm list by priority
    +
    + {Object.entries(PRIORITY).map(([p, info]) => ( +
    + {summary?.by_priority?.[p] ?? 0} + {info.label} +
    + ))}
    - ))} +
    +
    +
    Alarm rate, last 10 min
    +
    {summary?.alarm_rate_10min ?? 0}
    +
    {summary?.rate_status} (ISA 18.2 target: about 1, flood at 10)
    +
    +
    +
    Out of the list
    +
    {(summary?.shelved ?? 0) + (summary?.suppressed ?? 0)}
    +
    {summary?.shelved ?? 0} shelved, {summary?.suppressed ?? 0} suppressed by design
    +
    -
    - - +
    + {TABS.map(t => ( + + ))}
    -
    - - - - - - - - - - - - - - {displayed.length === 0 ? ( - - ) : ( - displayed.map(a => ) - )} - -
    TimestampMeterConditionValueThresholdPriorityAction
    - {tab === 'active' ? 'No active alarms' : 'No alarm history'} -
    -
    + {tab === 'active' && ( + <> + {unackedActive.length > 1 && ( +
    + +
    + )} + + + )} + {tab === 'shelved' && } + {tab === 'suppressed' && ( + + )} + {tab === 'history' && } + {tab === 'database' && }
    ) diff --git a/frontend/src/pages/Dashboard.jsx b/frontend/src/pages/Dashboard.jsx index 7b4ee09..fa8e829 100644 --- a/frontend/src/pages/Dashboard.jsx +++ b/frontend/src/pages/Dashboard.jsx @@ -1,151 +1,198 @@ -import React, { useState, useEffect } from 'react' -import { useNavigate } from 'react-router-dom' -import { Zap, Activity, AlertTriangle, CheckCircle } from 'lucide-react' -import { LineChart, Line, ResponsiveContainer, Tooltip } from 'recharts' +import React, { useEffect, useMemo, useState } from 'react' +import { Link, useNavigate } from 'react-router-dom' +import { FlaskConical, X, ArrowRight, BatteryCharging, Plug, Server, Gauge as GaugeIcon, Bell } from 'lucide-react' +import { useFeed } from '../services/LiveFeed' +import { alarmsByMeter } from '../services/feed' import { api } from '../services/api' +import { Sparkline, AreaChart } from '../components/Sparkline' +import { PriorityBadge, StateBadge } from '../components/Badges' +import { ANNUNCIATED } from '../lib/alarms' +import { num, ago } from '../lib/format' +import { readFlag, writeFlag } from '../lib/operator' -function getStatus(reading) { - if (!reading) return 'off' - const v = reading.voltage - const nom = 480 - if (v > nom * 1.10 || v < nom * 0.90) return 'crit' - if (v > nom * 1.05 || v < nom * 0.95) return 'warn' - if (reading.power_factor < 0.75) return 'crit' - if (reading.power_factor < 0.85) return 'warn' - return 'ok' -} +const GROUPS = [ + { title: 'Sources', ids: ['UTIL-1', 'GEN-1', 'ATS-1'] }, + { title: 'Distribution', ids: ['MSB-1', 'MECH-1'] }, + { title: 'UPS', ids: ['UPS-A', 'UPS-B'] }, + { title: 'PDUs', ids: ['PDU-A1', 'PDU-A2', 'PDU-B1', 'PDU-B2'] }, +] + +const INTRO_KEY = 'pd.intro.dismissed' -function SparkLine({ data, color = '#00b4d8' }) { - if (!data || data.length < 2) return
    +function LoadBar({ pct }) { + const v = Math.max(0, Math.min(100, pct || 0)) + const cls = v >= 90 ? 'p2' : v >= 80 ? 'p3' : '' return ( - - - - - +
    +
    +
    ) } -function MeterTile({ meter, reading, onClick }) { - const status = getStatus(reading) - const statusClass = status === 'crit' ? 'status-crit' : status === 'warn' ? 'status-warn' : 'status-ok' - +function EquipmentTile({ meter, reading, alarm, onClick }) { + const r = reading || {} + const isUps = meter.type === 'ups' + const off = (r.voltage ?? 0) < 1 return ( -
    -
    - {meter.name} - -
    -
    -
    - Voltage - {reading?.voltage?.toFixed(1) ?? '--'} - V -
    -
    - Current - {reading?.current?.toFixed(1) ?? '--'} - A + + ) +} + +function Kpi({ label, value, unit, sub, children, tone }) { + return ( +
    +
    {label}
    +
    + {value}{unit && {unit}}
    + {sub &&
    {sub}
    } + {children}
    ) } -export default function Dashboard({ readings }) { +export default function Dashboard({ onAbout }) { + const { meters, meterById, readings, spark, system, alarms, summary } = useFeed() const navigate = useNavigate() - const [status, setStatus] = useState(null) - const [meters, setMeters] = useState([]) - const [history, setHistory] = useState([]) + const [introHidden, setIntroHidden] = useState(() => readFlag(INTRO_KEY)) + const [points, setPoints] = useState([]) + // Last hour of facility load, then extend with live scans useEffect(() => { - api.getSystemStatus().then(setStatus).catch(() => {}) - api.getMeters().then(setMeters).catch(() => {}) + let cancelled = false + api.getMeterHistory('MSB-1', '1h') + .then(rows => { if (!cancelled) setPoints(prev => [...rows.map(r => ({ t: r.epoch, v: r.power_kw })), ...prev]) }) + .catch(() => {}) + return () => { cancelled = true } }, []) - // Refresh status when readings update - useEffect(() => { - api.getSystemStatus().then(setStatus).catch(() => {}) - }, [readings]) - - // Build sparkline history from readings over time + const msb = readings['MSB-1'] useEffect(() => { - setHistory(prev => { - const totalPower = Object.values(readings).reduce((s, r) => s + (r.power_kw || 0), 0) - const next = [...prev, { v: totalPower, t: Date.now() }] - return next.slice(-60) + if (!msb) return + setPoints(prev => { + const last = prev[prev.length - 1] + if (last && msb.epoch - last.t < 10) return prev + return [...prev.filter(p => p.t > msb.epoch - 3600), { t: msb.epoch, v: msb.power_kw }] }) - }, [readings]) + }, [msb]) - const healthColor = status?.health === 'critical' ? 'var(--status-crit)' - : status?.health === 'warning' ? 'var(--status-warn)' - : 'var(--status-ok)' + const byMeter = useMemo(() => alarmsByMeter(alarms), [alarms]) + const annunciated = alarms.filter(a => ANNUNCIATED.includes(a.state)) - return ( -
    -
    -

    System Overview

    -

    Real-time electrical distribution monitoring

    -
    + const hideIntro = () => { setIntroHidden(true); writeFlag(INTRO_KEY, true) } - {/* Summary cards */} -
    -
    -
    Total Load
    -
    {status?.total_power_kw?.toFixed(1) ?? '--'}
    -
    kW
    - -
    -
    -
    Avg Power Factor
    -
    - {status?.avg_power_factor?.toFixed(3) ?? '--'} + const onGen = system?.power_source === 'GENERATOR' + const runtime = system?.ups_min_runtime_min + const anyOnBattery = readings['UPS-A']?.on_battery || readings['UPS-B']?.on_battery + + return ( +
    + {!introHidden && system?.simulated && ( +
    + +
    + Live demo on simulated data. This dashboard monitors a simulated 2N data + center in real time. Events like utility outages and PDU overloads happen every few minutes, + or you can trigger one and watch the ISA 18.2 alarm system respond. +
    + +
    -
    System average
    +
    -
    -
    Active Alarms
    -
    0 ? 'var(--status-crit)' : 'var(--status-ok)' - }}> - {status?.active_alarm_count ?? '--'} -
    -
    - {status?.critical_alarm_count > 0 - ? `${status.critical_alarm_count} critical` - : 'No critical alarms'} + )} + +
    + + + + + v + (spark['PDU-A2']?.[i] || 0) + (spark['PDU-B1']?.[i] || 0) + (spark['PDU-B2']?.[i] || 0))} /> + + + + +
    + +
    +
    +
    +

    Facility load, last hour

    + {num(system?.facility_kw, 0)} kW now
    +
    -
    -
    System Health
    -
    - {status?.health?.toUpperCase() ?? '--'} + +
    +
    +

    Alarm list

    + Manage
    -
    {status?.meters_online ?? 0} / {status?.meter_count ?? 0} meters online
    + {annunciated.length === 0 ? ( +
    +

    No active alarms.

    + +
    + ) : ( +
      + {annunciated.slice(0, 6).map(a => ( +
    • + +
      +
      {a.message}
      +
      {a.meter_name}, {ago(a.activated_at)}
      +
      + +
    • + ))} + {annunciated.length > 6 &&
    • +{annunciated.length - 6} more
    • } +
    + )}
    - {/* Meter grid */} -
    -

    METER OVERVIEW

    -
    -
    - {meters.map(m => ( - navigate(`/meters?id=${m.id}`)} - /> - ))} -
    + {GROUPS.map(g => ( +
    +

    + {g.title === 'Sources' ? : g.title === 'PDUs' ? : } + {g.title} +

    +
    + {g.ids.filter(id => meterById[id]).map(id => ( + navigate(`/meters?id=${id}`)} /> + ))} +
    +
    + ))} + {meters.length === 0 &&

    No meters configured.

    }
    ) } diff --git a/frontend/src/pages/Meters.jsx b/frontend/src/pages/Meters.jsx index 1074795..39f7b55 100644 --- a/frontend/src/pages/Meters.jsx +++ b/frontend/src/pages/Meters.jsx @@ -1,168 +1,180 @@ -import React, { useState, useEffect } from 'react' +import React, { useEffect, useMemo, useState } from 'react' import { useSearchParams } from 'react-router-dom' -import { - LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, ReferenceLine -} from 'recharts' +import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, ReferenceLine } from 'recharts' +import { useFeed } from '../services/LiveFeed' +import { alarmsByMeter } from '../services/feed' import { api } from '../services/api' +import { PriorityBadge, StateBadge } from '../components/Badges' +import { ANNUNCIATED } from '../lib/alarms' +import { num } from '../lib/format' -function GaugeArc({ value, min, max, warn, crit, label, unit, size = 120 }) { - const pct = Math.min(1, Math.max(0, (value - min) / (max - min))) - const angle = -210 + pct * 240 - const r = size / 2 - 12 - const cx = size / 2, cy = size / 2 - - const toXY = (deg, radius) => ({ - x: cx + radius * Math.cos((deg * Math.PI) / 180), - y: cy + radius * Math.sin((deg * Math.PI) / 180), - }) +const PERIODS = ['1h', '24h', '7d'] +const TREND_FIELDS = { + power_kw: { label: 'Power', unit: 'kW' }, + load_pct: { label: 'Load', unit: '%' }, + voltage: { label: 'Voltage', unit: 'V' }, + current: { label: 'Current', unit: 'A' }, + power_factor: { label: 'Power factor', unit: '' }, + frequency: { label: 'Frequency', unit: 'Hz' }, +} - const arcPath = (startDeg, endDeg, radius) => { - const s = toXY(startDeg, radius) - const e = toXY(endDeg, radius) - const large = endDeg - startDeg > 180 ? 1 : 0 - return `M${s.x},${s.y} A${radius},${radius} 0 ${large},1 ${e.x},${e.y}` +function Gauge({ value, min, max, warn, crit, low = false, label, unit, digits = 1 }) { + const v = value ?? min + const pct = Math.min(1, Math.max(0, (v - min) / (max - min))) + const size = 120 + const r = 46 + const cx = size / 2 + const cy = size / 2 + 6 + const start = -210 + const sweep = 240 + const pt = (deg, rad) => [cx + rad * Math.cos((deg * Math.PI) / 180), cy + rad * Math.sin((deg * Math.PI) / 180)] + const arc = (a0, a1) => { + const [x0, y0] = pt(a0, r) + const [x1, y1] = pt(a1, r) + return `M${x0},${y0} A${r},${r} 0 ${a1 - a0 > 180 ? 1 : 0},1 ${x1},${y1}` } - - const color = value >= crit ? '#ef4444' : value >= warn ? '#f59e0b' : '#22c55e' - + const abnormal = value != null && (low ? value <= crit : value >= crit) + const caution = value != null && !abnormal && (low ? value <= warn : value >= warn) + const cls = abnormal ? 'g-crit' : caution ? 'g-warn' : 'g-ok' return ( -
    - - - - - - - {value?.toFixed(unit === 'Hz' ? 3 : 1)} - - {unit} +
    + + + {pct > 0.002 && } + {num(value, digits)} + {unit}
    {label}
    ) } -export default function Meters({ readings }) { - const [searchParams, setSearchParams] = useSearchParams() - const [meters, setMeters] = useState([]) - const [history, setHistory] = useState([]) +export default function Meters() { + const { meters, meterById, readings, alarms } = useFeed() + const [params, setParams] = useSearchParams() + const selectedId = meterById[params.get('id')] ? params.get('id') : meters[0]?.id + const meter = meterById[selectedId] + const r = readings[selectedId] || {} const [period, setPeriod] = useState('1h') - const selectedId = parseInt(searchParams.get('id') || '1', 10) - - useEffect(() => { - api.getMeters().then(setMeters).catch(() => {}) - }, []) + const [field, setField] = useState('power_kw') + const [history, setHistory] = useState([]) + const byMeter = useMemo(() => alarmsByMeter(alarms), [alarms]) + const meterAlarms = alarms.filter(a => a.meter_id === selectedId && ANNUNCIATED.includes(a.state)) useEffect(() => { - api.getMeterHistory(selectedId, period).then(rows => { - setHistory(rows.map(r => ({ - ...r, - time: new Date(r.timestamp).toLocaleTimeString(), - }))) - }).catch(() => {}) + if (!selectedId) return undefined + let cancelled = false + api.getMeterHistory(selectedId, period) + .then(rows => { if (!cancelled) setHistory(rows) }) + .catch(() => {}) + return () => { cancelled = true } }, [selectedId, period]) - const meter = meters.find(m => m.id === selectedId) - const reading = readings[selectedId] || {} - const nomV = meter?.nominal_voltage || 480 - const nomI = meter?.nominal_current || 100 + if (!meter) return null + const nomV = meter.nominal_voltage + const tf = TREND_FIELDS[field] + const tickFmt = t => new Date(t * 1000).toLocaleTimeString([], period === '7d' + ? { month: 'short', day: 'numeric' } : { hour: '2-digit', minute: '2-digit', hourCycle: 'h23' }) return ( -
    - {/* Meter list */} -
    -
    - Meters +
    +
    + - {/* Detail panel */} -
    - {meter && ( - <> -
    -
    -

    {meter.name}

    -

    {meter.description}

    -
    -
    +
    +
    +
    +

    {meter.name} {meter.id}

    +

    {meter.description}

    +
    + {r.status || '--'} +
    - {/* Gauges */} -
    - - - - +
    + + + + +
    + +
    + {[ + ['Power', num(r.power_kw), 'kW'], + ['Current', num(r.current), 'A'], + ['Demand (15 min)', num(r.demand_kw), 'kW'], + ['Energy', r.energy_kwh != null ? Math.round(r.energy_kwh).toLocaleString() : '--', 'kWh'], + ...(meter.type === 'ups' ? [ + ['Battery', num(r.battery_soc), '%'], + ['Runtime', num(r.battery_runtime_min, 0), 'min'], + ] : []), + ...(meter.type === 'ats' ? [['Position', r.position === 'GENERATOR' ? 'Emergency' : 'Normal', '']] : []), + ...(meter.type === 'generator' ? [['Engine', r.gen_state ? r.gen_state.toLowerCase() : '--', '']] : []), + ].map(([label, value, unit]) => ( +
    +
    {label}
    +
    {value}{unit && {unit}}
    + ))} +
    - {/* Key metrics */} -
    - {[ - { label: 'Power', value: reading.power_kw?.toFixed(2), unit: 'kW' }, - { label: 'Demand', value: reading.demand_kw?.toFixed(2), unit: 'kW' }, - { label: 'Energy', value: reading.energy_kwh?.toFixed(1), unit: 'kWh' }, - { label: 'Load', value: ((reading.current / nomI) * 100)?.toFixed(1), unit: '%' }, - ].map(({ label, value, unit }) => ( -
    -
    {label}
    -
    {value ?? '--'}
    -
    {unit}
    -
    + {meterAlarms.length > 0 && ( +
    +

    Alarms on this meter

    +
      + {meterAlarms.map(a => ( +
    • + +
      {a.message}
      + +
    • ))} -
    + +
    + )} - {/* Trend chart */} -
    -
    - POWER TREND -
    - {['1h', '24h', '7d'].map(p => ( - - ))} -
    +
    +
    +

    Trend

    +
    + +
    + {PERIODS.map(p => ( + + ))}
    - - - - - - - - - -
    - - )} +
    + + + + + + new Date(t * 1000).toLocaleString()} formatter={v => [`${v} ${tf.unit}`, tf.label]} /> + {field === 'load_pct' && } + + + +
    ) diff --git a/frontend/src/pages/OneLineDiagram.jsx b/frontend/src/pages/OneLineDiagram.jsx index 6c9f743..f782fd7 100644 --- a/frontend/src/pages/OneLineDiagram.jsx +++ b/frontend/src/pages/OneLineDiagram.jsx @@ -1,168 +1,204 @@ -import React, { useState } from 'react' - -const LAYOUT = { - utility: { x: 400, y: 50, w: 120, h: 50 }, - switchgear: { x: 400, y: 160, w: 120, h: 50 }, - panelA: { x: 220, y: 280, w: 110, h: 50 }, - panelB: { x: 580, y: 280, w: 110, h: 50 }, - rowA1: { x: 130, y: 400, w: 100, h: 45 }, - rowA2: { x: 310, y: 400, w: 100, h: 45 }, - crac1: { x: 500, y: 400, w: 100, h: 45 }, - crac2: { x: 660, y: 400, w: 100, h: 45 }, +import React, { useEffect, useMemo, useState } from 'react' +import { Link } from 'react-router-dom' +import { X } from 'lucide-react' +import { useFeed } from '../services/LiveFeed' +import { alarmsByMeter } from '../services/feed' +import { PriorityBadge, StateBadge } from '../components/Badges' +import { ANNUNCIATED } from '../lib/alarms' +import { num } from '../lib/format' + +// Two layouts: landscape for desktop, portrait for phones. Positions are +// node center x and top y in SVG units. +const LAYOUTS = { + wide: { + width: 900, height: 560, busY: 318, busX: [150, 850], srcY: 100, pduY: 450, atsIn: 40, labelGap: 30, + nodes: { + 'UTIL-1': { x: 300, y: 16, w: 160 }, + 'GEN-1': { x: 620, y: 16, w: 160 }, + 'ATS-1': { x: 460, y: 128, w: 224 }, + 'MSB-1': { x: 460, y: 232, w: 190 }, + 'UPS-A': { x: 230, y: 350, w: 150 }, + 'UPS-B': { x: 530, y: 350, w: 150 }, + 'MECH-1': { x: 770, y: 350, w: 150 }, + 'PDU-A1': { x: 150, y: 482, w: 136 }, + 'PDU-A2': { x: 310, y: 482, w: 136 }, + 'PDU-B1': { x: 450, y: 482, w: 136 }, + 'PDU-B2': { x: 610, y: 482, w: 136 }, + }, + }, + narrow: { + width: 400, height: 672, busY: 312, busX: [10, 390], srcY: 96, pduY: 436, atsIn: 30, labelGap: 22, + nodes: { + 'UTIL-1': { x: 102, y: 10, w: 184 }, + 'GEN-1': { x: 298, y: 10, w: 184 }, + 'ATS-1': { x: 200, y: 122, w: 230 }, + 'MSB-1': { x: 200, y: 222, w: 230 }, + 'UPS-A': { x: 104, y: 334, w: 182 }, + 'UPS-B': { x: 296, y: 334, w: 182 }, + 'PDU-A1': { x: 54, y: 462, w: 96 }, + 'PDU-A2': { x: 151, y: 462, w: 96 }, + 'PDU-B1': { x: 249, y: 462, w: 96 }, + 'PDU-B2': { x: 346, y: 462, w: 96 }, + 'MECH-1': { x: 200, y: 598, w: 230 }, + }, + }, } - -const METER_KEYS = [null, 'utility', 'switchgear', 'panelA', 'panelB', 'rowA1', 'rowA2', 'crac1', 'crac2'] - -function getStatusColor(reading, nomV = 480, nomI = 100) { - if (!reading) return '#4b5563' - const v = reading.voltage - if (v > nomV * 1.10 || v < nomV * 0.90 || reading.power_factor < 0.75) return '#ef4444' - if (v > nomV * 1.05 || v < nomV * 0.95 || reading.power_factor < 0.85) return '#f59e0b' - return '#22c55e' +const H = 64 + +function useNarrow(query = '(max-width: 720px)') { + const get = () => typeof window !== 'undefined' && window.matchMedia?.(query).matches + const [narrow, setNarrow] = useState(get) + useEffect(() => { + const mq = window.matchMedia?.(query) + if (!mq) return undefined + const on = () => setNarrow(mq.matches) + mq.addEventListener('change', on) + return () => mq.removeEventListener('change', on) + }, [query]) + return narrow } -function MeterNode({ nodeKey, reading, meter, selected, onClick }) { - const pos = LAYOUT[nodeKey] - const color = getStatusColor(reading, meter?.nominal_voltage, meter?.nominal_current) - - return ( - - - - {meter?.name ?? nodeKey} - - - {reading ? `${reading.voltage?.toFixed(0)}V ${reading.current?.toFixed(0)}A` : 'No data'} - - - {reading ? `${reading.power_kw?.toFixed(1)}kW PF${reading.power_factor?.toFixed(2)}` : ''} - - - - ) +function Wire({ d, state }) { + return } -function Bus({ x, y1, y2, label }) { +function Node({ n, id, meter, reading, alarm, selected, onSelect }) { + const compact = n.w < 120 + const r = reading || {} + const dead = (r.voltage ?? 0) < 1 + const statusCls = (r.status || '').toLowerCase().replace(/\s+/g, '-') + let line2 = compact ? `${num(r.power_kw, 0)}kW ${num(r.load_pct, 0)}%` : `${num(r.power_kw, 0)} kW ${num(r.load_pct, 0)}%` + if (meter.type === 'ups') line2 = `${num(r.power_kw, 0)} kW batt ${num(r.battery_soc, 0)}%` + if (meter.type === 'generator' && dead) line2 = '0 kW' + if (meter.type === 'ats') line2 = `${num(r.power_kw, 0)} kW ${r.position === 'GENERATOR' ? 'E' : 'N'} closed` + return ( - - - - {label && ( - {label} - )} + onSelect(id)} role="button" tabIndex={0} + onKeyDown={e => (e.key === 'Enter' || e.key === ' ') && onSelect(id)} + aria-label={`${meter.name}, ${r.status || 'no data'}`}> + + {meter.name} + {r.status || '--'} + {line2} + {alarm && } ) } -export default function OneLineDiagram({ readings }) { +export default function OneLineDiagram() { + const { meterById, readings, alarms } = useFeed() const [selected, setSelected] = useState(null) - - const nodeKeys = ['utility', 'switchgear', 'panelA', 'panelB', 'rowA1', 'rowA2', 'crac1', 'crac2'] - const metersByKey = {} - METER_KEYS.forEach((key, idx) => { - if (key) metersByKey[key] = { - id: idx, - name: key === 'utility' ? 'Utility Feed' - : key === 'switchgear' ? 'Main Switchgear' - : key === 'panelA' ? 'Panel A' - : key === 'panelB' ? 'Panel B' - : key === 'rowA1' ? 'Server Row A1' - : key === 'rowA2' ? 'Server Row A2' - : key === 'crac1' ? 'CRAC Unit 1' - : 'CRAC Unit 2', - nominal_voltage: 480, - nominal_current: [800, 750, 400, 350, 120, 120, 180, 170][idx - 1] || 100, - } - }) - - const selectedReading = selected ? readings[METER_KEYS.indexOf(selected)] : null + const byMeter = useMemo(() => alarmsByMeter(alarms), [alarms]) + + const live = id => (readings[id]?.voltage ?? 0) > 50 + const onGen = readings['ATS-1']?.position === 'GENERATOR' + const msbLive = live('MSB-1') + const wire = (energized, carrying) => (!energized ? 'dead' : carrying ? 'flow' : 'live') + + const L = LAYOUTS[useNarrow() ? 'narrow' : 'wide'] + const NODES = L.nodes + const bottom = id => NODES[id].y + H + const ats = NODES['ATS-1'] + const { busY, srcY, pduY } = L + const select = id => setSelected(s => (s === id ? null : id)) + const sel = selected && meterById[selected] + const selReading = selected ? readings[selected] || {} : null + const selAlarms = selected ? alarms.filter(a => a.meter_id === selected && ANNUNCIATED.includes(a.state)) : [] return ( -
    -
    -
    - ELECTRICAL DISTRIBUTION ONE-LINE DIAGRAM +
    +
    +
    +

    One-line diagram

    +

    Live power path from the sources to the IT load. Select any equipment for details.

    - - {/* Background */} - - - {/* Buses */} - - - - {/* Panel connections */} - - - - - - - {/* Sub-meter connections */} - - - - - - - - - - - - - {/* Nodes */} - {nodeKeys.map((key, i) => ( - setSelected(selected === key ? null : key)} - /> - ))} - - {/* Labels */} - Power Dashboard - Electrical Distribution System -
    - {/* Side panel */} - {selected && selectedReading && ( -
    -
    - {metersByKey[selected]?.name?.toUpperCase() ?? selected.toUpperCase()} +
    +
    +
    + Carrying load + Energized + De-energized +
    +
    + + {/* Sources to ATS */} + + + N + E + + {/* ATS to switchboard to bus */} + + + + 480 V MAIN BUS + + {/* Bus to UPS and mechanical */} + {['UPS-A', 'UPS-B', 'MECH-1'].map(id => ( + 0)} /> + ))} + + {/* UPS to PDUs */} + {[['UPS-A', ['PDU-A1', 'PDU-A2']], ['UPS-B', ['PDU-B1', 'PDU-B2']]].map(([ups, pdus]) => ( + + + + {pdus.map(p => )} + + ))} + + {Object.keys(NODES).filter(id => meterById[id]).map(id => ( + + ))} +
    - {[ - ['Voltage', selectedReading.voltage?.toFixed(2), 'V'], - ['Current', selectedReading.current?.toFixed(2), 'A'], - ['Power', selectedReading.power_kw?.toFixed(2), 'kW'], - ['Power Factor', selectedReading.power_factor?.toFixed(4), ''], - ['Frequency', selectedReading.frequency?.toFixed(3), 'Hz'], - ['Demand', selectedReading.demand_kw?.toFixed(2), 'kW'], - ['Energy', selectedReading.energy_kwh?.toFixed(1), 'kWh'], - ].map(([label, val, unit]) => ( -
    - {label} - - {val} {unit} - -
    - ))} -
    - )} + + {sel && ( +
    +
    +

    {sel.name}

    + +
    +

    {sel.description}

    +
    + {[ + ['Status', selReading.status], + ['Voltage', `${num(selReading.voltage)} V`], + ['Current', `${num(selReading.current)} A`], + ['Power', `${num(selReading.power_kw)} kW`], + ['Power factor', num(selReading.power_factor, 3)], + ['Frequency', `${num(selReading.frequency, 2)} Hz`], + ['Load', `${num(selReading.load_pct)} %`], + ['Demand (15 min)', `${num(selReading.demand_kw)} kW`], + ...(sel.type === 'ups' ? [ + ['Battery charge', `${num(selReading.battery_soc)} %`], + ['Battery runtime', `${num(selReading.battery_runtime_min, 0)} min`], + ['Input power', `${num(selReading.input_kw)} kW`], + ] : []), + ].map(([k, v]) =>
    {k}
    {v}
    )} +
    + {selAlarms.length > 0 && ( +
      + {selAlarms.map(a => ( +
    • + +
      {a.message}
      + +
    • + ))} +
    + )} + Open meter detail +
    + )} +
    ) } diff --git a/frontend/src/pages/SettingsPage.jsx b/frontend/src/pages/SettingsPage.jsx index 4d1f044..c30a6de 100644 --- a/frontend/src/pages/SettingsPage.jsx +++ b/frontend/src/pages/SettingsPage.jsx @@ -1,79 +1,96 @@ -import React from 'react' -import { Settings, Database, Wifi, Bell } from 'lucide-react' +import React, { useEffect, useState } from 'react' +import { Wifi, Database, User, Cpu } from 'lucide-react' +import { useFeed } from '../services/LiveFeed' +import { api } from '../services/api' +import { API_URL, WS_URL } from '../services/endpoints' +import { getOperator, setOperator } from '../lib/operator' +import { PriorityBadge } from '../components/Badges' -const API_URL = import.meta.env.VITE_API_URL || 'http://localhost:8000' -const WS_URL = API_URL.replace(/^https:\/\//, 'wss://').replace(/^http:\/\//, 'ws://') + '/ws/live' +function Rows({ rows }) { + return ( +
    + {rows.map(([k, v]) =>
    {k}
    {v}
    )} +
    + ) +} export default function SettingsPage() { + const { system, demo } = useFeed() + const [cfg, setCfg] = useState(null) + const [name, setName] = useState(getOperator) + const [saved, setSaved] = useState(false) + + useEffect(() => { api.getConfig().then(setCfg).catch(() => {}) }, []) + + const save = (e) => { + e.preventDefault() + setOperator(name.trim() || 'demo visitor') + setSaved(true) + setTimeout(() => setSaved(false), 1500) + } + return ( -
    +
    -

    Settings

    -

    System configuration and preferences

    +
    +

    System

    +

    Data source, connection, and alarm configuration

    +
    -
    +
    -
    - CONNECTION -
    -
    - {[ - { label: 'API Endpoint', value: API_URL }, - { label: 'WebSocket', value: WS_URL }, - { label: 'Poll Interval', value: '5 seconds' }, - { label: 'WS Broadcast Rate', value: '2 seconds' }, - ].map(({ label, value }) => ( -
    - {label} - {value} -
    - ))} -
    +

    Data source

    + +

    + Real meters are polled over Modbus TCP when the backend runs with DATA_SOURCE=modbus and a + register map. See the README for setup. +

    -
    - ALARM THRESHOLDS -
    -
    - {[ - { label: 'Voltage High Critical', value: '110% of nominal' }, - { label: 'Voltage High Warning', value: '105% of nominal' }, - { label: 'Voltage Low Warning', value: '95% of nominal' }, - { label: 'Voltage Low Critical', value: '90% of nominal' }, - { label: 'Current High Critical', value: '100% of nominal' }, - { label: 'Current High Warning', value: '85% of nominal' }, - { label: 'Power Factor Warning', value: 'PF < 0.85' }, - { label: 'Power Factor Critical', value: 'PF < 0.75' }, - { label: 'Frequency Warning', value: '59.5 - 60.5 Hz' }, - { label: 'Frequency Critical', value: '59.0 - 61.0 Hz' }, - ].map(({ label, value }) => ( -
    - {label} - {value} -
    - ))} -
    +

    Connection

    +
    -
    - DATA RETENTION -
    -
    - {[ - { label: 'Reading Retention', value: '7 days' }, - { label: 'Alarm History', value: 'Indefinite' }, - { label: 'Database', value: 'SQLite (power_dashboard.db)' }, - { label: 'Prune Interval', value: 'Every 1 hour' }, - ].map(({ label, value }) => ( -
    - {label} - {value} -
    +

    Operator

    +
    + +
    + setName(e.target.value)} /> + +
    +
    +
    + +
    +

    Retention

    + +
    + +
    +

    Alarm priorities

    +

    Priority comes from the consequence of not responding and the time available to respond.

    +
    + {cfg && Object.entries(cfg.priorities).map(([p, info]) => ( +
    {info.response}
    ))} -
    +
    diff --git a/frontend/src/pages/Trending.jsx b/frontend/src/pages/Trending.jsx index 17aeac8..0a0411e 100644 --- a/frontend/src/pages/Trending.jsx +++ b/frontend/src/pages/Trending.jsx @@ -1,144 +1,129 @@ -import React, { useState, useEffect } from 'react' +import React, { useEffect, useMemo, useState } from 'react' import { Download } from 'lucide-react' -import { - LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, - Legend, ResponsiveContainer -} from 'recharts' +import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, Legend, ResponsiveContainer } from 'recharts' +import { useFeed } from '../services/LiveFeed' import { api } from '../services/api' -const COLORS = ['#00b4d8', '#22c55e', '#f59e0b', '#a78bfa', '#ef4444', '#fb923c', '#34d399', '#60a5fa'] -const PARAMETERS = ['power_kw', 'voltage', 'current', 'power_factor', 'demand_kw', 'frequency'] +const COLORS = ['#60a5fa', '#34d399', '#f59e0b', '#c084fc', '#f472b6', '#22d3ee', '#a3e635', '#fb923c'] +const PARAMETERS = { + power_kw: 'Power (kW)', + load_pct: 'Load (% of rating)', + current: 'Current (A)', + voltage: 'Voltage (V)', + power_factor: 'Power factor', + frequency: 'Frequency (Hz)', + demand_kw: 'Demand (kW)', +} export default function Trending() { - const [meters, setMeters] = useState([]) - const [selectedMeters, setSelectedMeters] = useState([1, 2]) + const { meters } = useFeed() + const [selected, setSelected] = useState(['UTIL-1', 'UPS-A', 'UPS-B', 'MECH-1']) const [parameter, setParameter] = useState('power_kw') const [period, setPeriod] = useState('1h') - const [chartData, setChartData] = useState([]) + const [series, setSeries] = useState({}) const [loading, setLoading] = useState(false) useEffect(() => { - api.getMeters().then(setMeters).catch(() => {}) - }, []) - - useEffect(() => { - if (selectedMeters.length === 0) return + let cancelled = false setLoading(true) - Promise.all(selectedMeters.map(id => api.getMeterHistory(id, period))) - .then(results => { - // Merge by timestamp index - const merged = {} - results.forEach((rows, idx) => { - rows.forEach(row => { - const key = row.timestamp - if (!merged[key]) merged[key] = { time: new Date(row.timestamp).toLocaleTimeString() } - merged[key][`m${selectedMeters[idx]}`] = row[parameter] - }) - }) - setChartData(Object.values(merged).sort((a, b) => a.time > b.time ? 1 : -1)) - }) - .finally(() => setLoading(false)) - }, [selectedMeters, parameter, period]) + Promise.all(selected.map(id => api.getMeterHistory(id, period).then(rows => [id, rows]).catch(() => [id, []]))) + .then(results => { if (!cancelled) setSeries(Object.fromEntries(results)) }) + .finally(() => { if (!cancelled) setLoading(false) }) + return () => { cancelled = true } + }, [selected, period]) - const toggleMeter = (id) => { - setSelectedMeters(prev => - prev.includes(id) ? prev.filter(x => x !== id) : [...prev, id] - ) - } + // History buckets share boundaries across meters, so merge on epoch + const data = useMemo(() => { + const merged = {} + for (const id of selected) { + for (const row of series[id] || []) { + merged[row.epoch] = merged[row.epoch] || { epoch: row.epoch } + merged[row.epoch][id] = row[parameter] + } + } + return Object.values(merged).sort((a, b) => a.epoch - b.epoch) + }, [series, selected, parameter]) + + const toggle = id => setSelected(prev => (prev.includes(id) ? prev.filter(x => x !== id) : [...prev, id])) const exportCSV = () => { - const headers = ['time', ...selectedMeters.map(id => `Meter_${id}_${parameter}`)] - const rows = chartData.map(row => headers.map(h => row[h] ?? '').join(',')) - const csv = [headers.join(','), ...rows].join('\n') - const blob = new Blob([csv], { type: 'text/csv' }) + const header = ['timestamp_utc', ...selected.map(id => `${id}_${parameter}`)] + const rows = data.map(r => [new Date(r.epoch * 1000).toISOString(), ...selected.map(id => r[id] ?? '')].join(',')) + const blob = new Blob([[header.join(','), ...rows].join('\n')], { type: 'text/csv' }) const url = URL.createObjectURL(blob) const a = document.createElement('a') - a.href = url; a.download = `trending_${parameter}_${period}.csv`; a.click() + a.href = url + a.download = `trend_${parameter}_${period}.csv` + a.click() URL.revokeObjectURL(url) } + const tickFmt = t => new Date(t * 1000).toLocaleString([], period === '7d' + ? { month: 'short', day: 'numeric' } : { hour: '2-digit', minute: '2-digit', hourCycle: 'h23' }) + return ( -
    +
    -

    Trending

    -

    Multi-meter historical comparison

    +
    +

    Trends

    +

    Compare meters over time. History is averaged into about 360 points per period.

    +
    +
    - {/* Controls */} -
    -
    -
    -
    - Meters -
    -
    - {meters.map(m => ( - - ))} -
    +
    +
    +
    Meters
    +
    + {meters.map(m => { + const idx = selected.indexOf(m.id) + return ( + + ) + })}
    - -
    -
    - Parameter -
    - setParameter(e.target.value)} aria-label="Parameter"> + {Object.entries(PARAMETERS).map(([k, v]) => )}
    - -
    -
    - Period -
    -
    +
    +
    Period
    +
    {['1h', '24h', '7d'].map(p => ( - + ))}
    - -
    - -
    - {/* Chart */}
    - {loading ? ( -
    Loading...
    + {selected.length === 0 ? ( +

    Select one or more meters.

    + ) : loading && !data.length ? ( +

    Loading...

    ) : ( - - - - - - - - {selectedMeters.map((id, i) => ( - m.id === id)?.name || `Meter ${id}`} - stroke={COLORS[i % COLORS.length]} - dot={false} - strokeWidth={1.5} - /> + + + + + + new Date(t * 1000).toLocaleString()} /> + + {selected.map((id, i) => ( + m.id === id)?.name || id} + stroke={COLORS[i % COLORS.length]} dot={false} strokeWidth={1.6} isAnimationActive={false} connectNulls /> ))} diff --git a/frontend/src/services/LiveFeed.jsx b/frontend/src/services/LiveFeed.jsx new file mode 100644 index 0000000..da7845d --- /dev/null +++ b/frontend/src/services/LiveFeed.jsx @@ -0,0 +1,132 @@ +import React, { createContext, useCallback, useContext, useEffect, useReducer, useRef, useState } from 'react' +import { api } from './api' +import { WS_URL } from './endpoints' +import { initialFeed, reduceFeed } from './feed' + +const FeedContext = createContext(null) + +const STALE_MS = 15000 // no message for this long means the socket is dead +const MAX_BACKOFF_MS = 8000 + +function feedReducer(state, msg) { + return reduceFeed(state, msg) +} + +/** + * Connection phases: + * connecting first attempt, nothing heard yet + * live receiving data + * reconnecting had data, lost the socket, retrying in the background + * + * Before the first snapshot the app shows the wake screen. The hosting free + * tier sleeps when idle, so the health check doubles as a wake up call. + */ +export function LiveFeedProvider({ children }) { + const [feed, dispatch] = useReducer(feedReducer, initialFeed) + const [phase, setPhase] = useState('connecting') + const [serverAwake, setServerAwake] = useState(false) + const [startedAt, setStartedAt] = useState(() => Date.now()) + const [attempt, setAttempt] = useState(0) + + const wsRef = useRef(null) + const timerRef = useRef(null) + const backoffRef = useRef(1000) + const lastMsgRef = useRef(0) + const hasDataRef = useRef(false) + const aliveRef = useRef(true) + + const connect = useCallback(() => { + if (!aliveRef.current) return + clearTimeout(timerRef.current) + let ws + try { + ws = new WebSocket(WS_URL) + } catch { + timerRef.current = setTimeout(connect, backoffRef.current) + return + } + wsRef.current = ws + setAttempt(a => a + 1) + + ws.onopen = () => { + backoffRef.current = 1000 + lastMsgRef.current = Date.now() + } + ws.onmessage = (evt) => { + let msg + try { msg = JSON.parse(evt.data) } catch { return } + lastMsgRef.current = Date.now() + dispatch(msg) + if (msg.type === 'snapshot') { + hasDataRef.current = true + setServerAwake(true) + setPhase('live') + } + } + ws.onclose = () => { + if (wsRef.current !== ws || !aliveRef.current) return + wsRef.current = null + setPhase(hasDataRef.current ? 'reconnecting' : 'connecting') + timerRef.current = setTimeout(connect, backoffRef.current) + backoffRef.current = Math.min(backoffRef.current * 1.6, MAX_BACKOFF_MS) + } + ws.onerror = () => ws.close() + }, []) + + // Open the socket and watch for a silent (stale) connection + useEffect(() => { + aliveRef.current = true + connect() + const watchdog = setInterval(() => { + const ws = wsRef.current + if (ws && ws.readyState === WebSocket.OPEN && Date.now() - lastMsgRef.current > STALE_MS) { + ws.close() + } + }, 5000) + return () => { + aliveRef.current = false + clearInterval(watchdog) + clearTimeout(timerRef.current) + wsRef.current?.close() + } + }, [connect]) + + // Wake the server with plain HTTP until the feed is up (a sleeping host + // starts on HTTP traffic; WebSocket attempts alone can fail fast) + useEffect(() => { + if (phase === 'live') return undefined + let cancelled = false + let timer + const poke = async () => { + try { + const h = await api.health(20000) + if (cancelled) return + if (h?.status === 'ok') { + setServerAwake(true) + // Server is up but the socket is still waiting on a retry: go now + if (!wsRef.current) { backoffRef.current = 1000; connect() } + } + } catch { /* still asleep or unreachable */ } + if (!cancelled) timer = setTimeout(poke, 4000) + } + poke() + return () => { cancelled = true; clearTimeout(timer) } + }, [phase, connect]) + + const retry = useCallback(() => { + setStartedAt(Date.now()) + backoffRef.current = 1000 + wsRef.current?.close() + wsRef.current = null + connect() + }, [connect]) + + const value = { ...feed, phase, serverAwake, startedAt, attempt, retry, hasData: !!feed.snapshotAt } + return {children} +} + +export function useFeed() { + const ctx = useContext(FeedContext) + if (!ctx) throw new Error('useFeed must be used inside LiveFeedProvider') + return ctx +} diff --git a/frontend/src/services/api.js b/frontend/src/services/api.js index 094de64..0988699 100644 --- a/frontend/src/services/api.js +++ b/frontend/src/services/api.js @@ -1,17 +1,34 @@ -const BASE = (import.meta.env.VITE_API_URL || 'http://localhost:8000') + '/api' +import { API_URL } from './endpoints' -async function request(path, options = {}) { - const res = await fetch(`${BASE}${path}`, options) - if (!res.ok) throw new Error(`API error ${res.status}`) - return res.json() +const BASE = `${API_URL}/api` + +async function request(path, { method = 'GET', body, timeout = 15000 } = {}) { + const ctrl = new AbortController() + const timer = setTimeout(() => ctrl.abort(), timeout) + try { + const res = await fetch(`${BASE}${path}`, { + method, + signal: ctrl.signal, + headers: body ? { 'Content-Type': 'application/json' } : undefined, + body: body ? JSON.stringify(body) : undefined, + }) + const data = await res.json().catch(() => null) + if (!res.ok) throw new Error(data?.detail || `Request failed (${res.status})`) + return data + } finally { + clearTimeout(timer) + } } export const api = { - getMeters: () => request('/meters'), - getMeter: (id) => request(`/meters/${id}`), - getMeterHistory: (id, period) => request(`/meters/${id}/history?period=${period}`), - getAlarms: () => request('/alarms'), - getAlarmHistory: (limit=200) => request(`/alarms/history?limit=${limit}`), - acknowledgeAlarm: (id) => request(`/alarms/${id}/acknowledge`, { method: 'POST' }), - getSystemStatus: () => request('/system/status'), + health: (timeout) => request('/health', { timeout }), + getConfig: () => request('/config'), + getMeterHistory: (id, period) => request(`/meters/${encodeURIComponent(id)}/history?period=${period}`), + getAlarmHistory: (limit = 200) => request(`/alarms/history?limit=${limit}`), + getAlarmDefs: () => request('/alarms/definitions'), + acknowledge: (tag, operator) => request(`/alarms/${encodeURIComponent(tag)}/acknowledge`, { method: 'POST', body: { operator } }), + shelve: (tag, minutes, reason, operator) => + request(`/alarms/${encodeURIComponent(tag)}/shelve`, { method: 'POST', body: { minutes, reason, operator } }), + unshelve: (tag, operator) => request(`/alarms/${encodeURIComponent(tag)}/unshelve`, { method: 'POST', body: { operator } }), + triggerEvent: (name) => request(`/demo/events/${encodeURIComponent(name)}`, { method: 'POST' }), } diff --git a/frontend/src/services/endpoints.js b/frontend/src/services/endpoints.js new file mode 100644 index 0000000..320d090 --- /dev/null +++ b/frontend/src/services/endpoints.js @@ -0,0 +1,22 @@ +// Backend location. +// VITE_API_URL set at build time use it +// production build without it use the live demo API +// dev server without it same origin (Vite proxies /api and /ws) +export const LIVE_API_URL = 'https://power-dashboard-api.onrender.com' + +export function resolveApiUrl(env = import.meta.env) { + const configured = (env.VITE_API_URL || '').trim().replace(/\/+$/, '') + if (configured) return configured + return env.PROD ? LIVE_API_URL : '' +} + +export const API_URL = resolveApiUrl() + +export function buildWsUrl(apiUrl = API_URL, loc = globalThis.location) { + if (apiUrl) return apiUrl.replace(/^http/, 'ws') + '/ws/live' + if (!loc) return '/ws/live' + const proto = loc.protocol === 'https:' ? 'wss:' : 'ws:' + return `${proto}//${loc.host}/ws/live` +} + +export const WS_URL = buildWsUrl() diff --git a/frontend/src/services/feed.js b/frontend/src/services/feed.js new file mode 100644 index 0000000..01c8c56 --- /dev/null +++ b/frontend/src/services/feed.js @@ -0,0 +1,79 @@ +// Pure state handling for the WebSocket feed (kept free of React so it is easy to test). + +export const SPARK_POINTS = 90 // 3 minutes at a 2 second scan + +export const initialFeed = { + meters: [], + meterById: {}, + readings: {}, + spark: {}, + system: null, + alarms: [], + summary: null, + demo: null, + snapshotAt: null, + lastMessageAt: null, +} + +function pushSpark(spark, readings) { + const next = { ...spark } + for (const r of readings) { + if (r.power_kw == null) continue + const arr = next[r.meter_id] ? next[r.meter_id].slice(-(SPARK_POINTS - 1)) : [] + arr.push(r.power_kw) + next[r.meter_id] = arr + } + return next +} + +function mergeReadings(prev, list) { + const next = { ...prev } + for (const r of list) next[r.meter_id] = r + return next +} + +export function reduceFeed(state, msg, now = Date.now()) { + if (!msg || typeof msg !== 'object') return state + const d = msg.data || {} + switch (msg.type) { + case 'snapshot': + return { + ...state, + meters: d.meters || [], + meterById: Object.fromEntries((d.meters || []).map(m => [m.id, m])), + readings: mergeReadings({}, d.readings || []), + spark: state.snapshotAt ? state.spark : pushSpark({}, d.readings || []), + system: d.system || null, + alarms: d.alarms || [], + summary: d.summary || null, + demo: d.demo || null, + snapshotAt: now, + lastMessageAt: now, + } + case 'readings': + return { + ...state, + readings: mergeReadings(state.readings, d.readings || []), + spark: pushSpark(state.spark, d.readings || []), + system: d.system || state.system, + lastMessageAt: now, + } + case 'alarms': + return { ...state, alarms: d.alarms || [], summary: d.summary || state.summary, lastMessageAt: now } + case 'pong': + return { ...state, lastMessageAt: now } + default: + return state + } +} + +// Highest priority (lowest number) annunciated alarm per meter +export function alarmsByMeter(alarms) { + const out = {} + for (const a of alarms) { + if (!['UNACK', 'ACKED', 'RTN_UNACK'].includes(a.state)) continue + const cur = out[a.meter_id] + if (!cur || a.priority < cur.priority) out[a.meter_id] = a + } + return out +} diff --git a/frontend/src/services/feed.test.js b/frontend/src/services/feed.test.js new file mode 100644 index 0000000..0a37baf --- /dev/null +++ b/frontend/src/services/feed.test.js @@ -0,0 +1,101 @@ +import { describe, expect, it } from 'vitest' +import { SPARK_POINTS, alarmsByMeter, initialFeed, reduceFeed } from './feed' +import { LIVE_API_URL, buildWsUrl, resolveApiUrl } from './endpoints' + +const meters = [{ id: 'UTIL-1', name: 'Utility' }, { id: 'UPS-A', name: 'UPS A' }] +const reading = (id, kw, epoch = 1) => ({ meter_id: id, power_kw: kw, epoch }) + +const snapshot = { + type: 'snapshot', + data: { + meters, + readings: [reading('UTIL-1', 400), reading('UPS-A', 140)], + system: { health: 'normal' }, + alarms: [], + summary: { unacknowledged: 0 }, + demo: { simulated: true }, + }, +} + +describe('reduceFeed', () => { + it('loads a snapshot', () => { + const s = reduceFeed(initialFeed, snapshot, 1000) + expect(s.meterById['UPS-A'].name).toBe('UPS A') + expect(s.readings['UTIL-1'].power_kw).toBe(400) + expect(s.spark['UTIL-1']).toEqual([400]) + expect(s.snapshotAt).toBe(1000) + expect(s.demo.simulated).toBe(true) + }) + + it('merges live readings and keeps a bounded sparkline', () => { + let s = reduceFeed(initialFeed, snapshot) + for (let i = 0; i < SPARK_POINTS + 20; i++) { + s = reduceFeed(s, { type: 'readings', data: { readings: [reading('UTIL-1', 400 + i, i)], system: { health: 'caution' } } }) + } + expect(s.readings['UTIL-1'].power_kw).toBe(400 + SPARK_POINTS + 19) + expect(s.readings['UPS-A'].power_kw).toBe(140) // untouched meter kept + expect(s.spark['UTIL-1']).toHaveLength(SPARK_POINTS) + expect(s.system.health).toBe('caution') + }) + + it('replaces the alarm list on an alarms message', () => { + const alarms = [{ tag: 'UPS-A.ONBATT', meter_id: 'UPS-A', priority: 1, state: 'UNACK' }] + const s = reduceFeed(reduceFeed(initialFeed, snapshot), { type: 'alarms', data: { alarms, summary: { unacknowledged: 1 } } }) + expect(s.alarms).toEqual(alarms) + expect(s.summary.unacknowledged).toBe(1) + }) + + it('a reconnect snapshot keeps the sparkline history', () => { + let s = reduceFeed(initialFeed, snapshot) + s = reduceFeed(s, { type: 'readings', data: { readings: [reading('UTIL-1', 410, 2)] } }) + s = reduceFeed(s, snapshot) + expect(s.spark['UTIL-1']).toEqual([400, 410]) + }) + + it('ignores unknown or malformed messages', () => { + const s = reduceFeed(initialFeed, snapshot) + expect(reduceFeed(s, { type: 'mystery' })).toBe(s) + expect(reduceFeed(s, null)).toBe(s) + }) +}) + +describe('alarmsByMeter', () => { + it('picks the highest priority annunciated alarm per meter', () => { + const out = alarmsByMeter([ + { meter_id: 'UPS-A', priority: 2, state: 'ACKED' }, + { meter_id: 'UPS-A', priority: 1, state: 'RTN_UNACK' }, + { meter_id: 'GEN-1', priority: 3, state: 'SUPPRESSED' }, + { meter_id: 'PDU-A1', priority: 3, state: 'SHELVED' }, + ]) + expect(out['UPS-A'].priority).toBe(1) + expect(out['GEN-1']).toBeUndefined() + expect(out['PDU-A1']).toBeUndefined() + }) +}) + +describe('resolveApiUrl', () => { + it('uses VITE_API_URL when set', () => { + expect(resolveApiUrl({ VITE_API_URL: 'https://api.example.com/', PROD: true })).toBe('https://api.example.com') + }) + + it('falls back to the live API in a production build', () => { + expect(resolveApiUrl({ PROD: true })).toBe(LIVE_API_URL) + expect(resolveApiUrl({ VITE_API_URL: ' ', PROD: true })).toBe(LIVE_API_URL) + }) + + it('uses the same origin on the dev server', () => { + expect(resolveApiUrl({ PROD: false })).toBe('') + }) +}) + +describe('buildWsUrl', () => { + it('derives the socket URL from the API URL', () => { + expect(buildWsUrl('https://api.example.com')).toBe('wss://api.example.com/ws/live') + expect(buildWsUrl('http://localhost:8000')).toBe('ws://localhost:8000/ws/live') + }) + + it('falls back to the page origin', () => { + expect(buildWsUrl('', { protocol: 'https:', host: 'demo.example.com' })).toBe('wss://demo.example.com/ws/live') + expect(buildWsUrl('', { protocol: 'http:', host: 'localhost:5173' })).toBe('ws://localhost:5173/ws/live') + }) +}) diff --git a/frontend/src/services/websocket.js b/frontend/src/services/websocket.js deleted file mode 100644 index 0076ace..0000000 --- a/frontend/src/services/websocket.js +++ /dev/null @@ -1,56 +0,0 @@ -import { useState, useEffect, useRef, useCallback } from 'react' - -const API_URL = import.meta.env.VITE_API_URL || 'http://localhost:8000' -const WS_URL = API_URL.replace(/^https:\/\//, 'wss://').replace(/^http:\/\//, 'ws://') + '/ws/live' -const RECONNECT_DELAY = 3000 - -export function useWebSocket() { - const [readings, setReadings] = useState({}) - const [connected, setConnected] = useState(false) - const wsRef = useRef(null) - const timerRef = useRef(null) - - const connect = useCallback(() => { - if (wsRef.current?.readyState === WebSocket.OPEN) return - - const ws = new WebSocket(WS_URL) - wsRef.current = ws - - ws.onopen = () => { - setConnected(true) - if (timerRef.current) clearTimeout(timerRef.current) - } - - ws.onmessage = (evt) => { - try { - const msg = JSON.parse(evt.data) - if (msg.type === 'readings') { - setReadings(prev => { - const next = { ...prev } - for (const r of msg.data) { - next[r.meter_id] = r - } - return next - }) - } - } catch (_) {} - } - - ws.onclose = () => { - setConnected(false) - timerRef.current = setTimeout(connect, RECONNECT_DELAY) - } - - ws.onerror = () => ws.close() - }, []) - - useEffect(() => { - connect() - return () => { - if (timerRef.current) clearTimeout(timerRef.current) - wsRef.current?.close() - } - }, [connect]) - - return { readings, connected } -} diff --git a/frontend/vite.config.js b/frontend/vite.config.js index 474ebd5..740f141 100644 --- a/frontend/vite.config.js +++ b/frontend/vite.config.js @@ -6,10 +6,10 @@ export default defineConfig({ server: { proxy: { '/api': 'http://localhost:8000', - '/ws': { - target: 'ws://localhost:8000', - ws: true, - }, + '/ws': { target: 'ws://localhost:8000', ws: true }, }, }, + test: { + environment: 'node', + }, }) diff --git a/render.yaml b/render.yaml new file mode 100644 index 0000000..414936a --- /dev/null +++ b/render.yaml @@ -0,0 +1,58 @@ +# Render Blueprint for Power Dashboard +# https://render.com/docs/blueprint-spec +# +# Two services deploy from main on every merge: +# power-dashboard static front end (always on, served from the CDN) +# power-dashboard-api FastAPI backend running the simulator +# +# The front end stays up while the free tier API sleeps, so visitors see the +# wake screen instead of a blank page. Build filters keep a docs only change +# from redeploying anything. + +services: + - type: web + name: power-dashboard-api + runtime: python + plan: free + branch: main + autoDeployTrigger: commit + rootDir: backend + buildCommand: pip install -r requirements.txt + startCommand: uvicorn main:app --host 0.0.0.0 --port $PORT + healthCheckPath: /api/health + buildFilter: + paths: + - backend/** + ignoredPaths: + - backend/tests/** + envVars: + - key: PYTHON_VERSION + value: 3.13.1 + - key: DATA_SOURCE + value: simulator + - key: CORS_ORIGINS + value: "*" + + - type: web + name: power-dashboard + runtime: static + branch: main + autoDeployTrigger: commit + rootDir: frontend + buildCommand: npm ci && npm run build + staticPublishPath: dist + buildFilter: + paths: + - frontend/** + ignoredPaths: + - frontend/src/**/*.test.js + # No rewrite rule needed: the app uses hash routes (/#/alarms), so every + # page refresh requests index.html. VITE_API_URL is optional; without it + # the build points at https://power-dashboard-api.onrender.com. + headers: + - path: /assets/* + name: Cache-Control + value: public, max-age=31536000, immutable + - path: /index.html + name: Cache-Control + value: no-cache diff --git a/screenshots/alarms.webp b/screenshots/alarms.webp index 7963399..da41d58 100644 Binary files a/screenshots/alarms.webp and b/screenshots/alarms.webp differ diff --git a/screenshots/dashboard.webp b/screenshots/dashboard.webp index facd7ec..d0aa475 100644 Binary files a/screenshots/dashboard.webp and b/screenshots/dashboard.webp differ diff --git a/screenshots/journal.webp b/screenshots/journal.webp new file mode 100644 index 0000000..329db8f Binary files /dev/null and b/screenshots/journal.webp differ diff --git a/screenshots/meters.webp b/screenshots/meters.webp index dd00d4c..a316626 100644 Binary files a/screenshots/meters.webp and b/screenshots/meters.webp differ diff --git a/screenshots/mobile.webp b/screenshots/mobile.webp new file mode 100644 index 0000000..21b5811 Binary files /dev/null and b/screenshots/mobile.webp differ diff --git a/screenshots/one-line.webp b/screenshots/one-line.webp index 7cffa08..783879e 100644 Binary files a/screenshots/one-line.webp and b/screenshots/one-line.webp differ diff --git a/screenshots/settings.webp b/screenshots/settings.webp deleted file mode 100644 index 6895c8c..0000000 Binary files a/screenshots/settings.webp and /dev/null differ diff --git a/screenshots/trending.webp b/screenshots/trending.webp index 017815d..0380c0c 100644 Binary files a/screenshots/trending.webp and b/screenshots/trending.webp differ