mirror of
https://github.com/ferdzo/iotDashboard.git
synced 2026-04-05 17:16:26 +00:00
Compare commits
17 Commits
feature/re
...
8c699bd121
| Author | SHA1 | Date | |
|---|---|---|---|
| 8c699bd121 | |||
| 7f2aec96dd | |||
| ed105fccd3 | |||
| 7921049f56 | |||
| 8e98f5ad7d | |||
| 391c08a738 | |||
| 153dca9d8f | |||
| d2b707ea5e | |||
| 212b8d39a9 | |||
| 96e2377073 | |||
| ddbc588c77 | |||
| 4df582b330 | |||
|
|
7446e9b4ac | ||
|
|
12d3720421 | ||
|
|
0b96c72f45 | ||
| 5028dae200 | |||
| 39a9f91cfc |
20
.env.sample
Normal file
20
.env.sample
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
# Django Settings
|
||||||
|
SECRET_KEY=your-secret-key-here
|
||||||
|
DEBUG=True
|
||||||
|
|
||||||
|
# Database
|
||||||
|
CONNECTION_STRING=postgresql://postgres:password@localhost:5432/iotdashboard
|
||||||
|
|
||||||
|
# Redis Configuration
|
||||||
|
REDIS_HOST=localhost
|
||||||
|
REDIS_PORT=6379
|
||||||
|
REDIS_DB=0
|
||||||
|
|
||||||
|
# MQTT Configuration
|
||||||
|
MQTT_BROKER=localhost
|
||||||
|
MQTT_PORT=1883
|
||||||
|
MQTT_USER=mqtt_user
|
||||||
|
MQTT_PASS=mqtt_password
|
||||||
|
|
||||||
|
# GPT/OpenAI (Optional)
|
||||||
|
OPENAI_API_KEY=your-openai-key-here
|
||||||
116
.github/copilot-instructions.md
vendored
Normal file
116
.github/copilot-instructions.md
vendored
Normal file
@@ -0,0 +1,116 @@
|
|||||||
|
The following concise instructions help AI coding agents become productive in this repository.
|
||||||
|
|
||||||
|
Purpose
|
||||||
|
- This repo is a microservices-based IoT platform for device management, data ingestion, and telemetry storage. The system uses MQTT with mTLS authentication, Redis streams for message queuing, and PostgreSQL/TimescaleDB for persistent storage.
|
||||||
|
|
||||||
|
Never forget to involve human developers for more complex tasks and decisions. You are encouraged to ask more.
|
||||||
|
|
||||||
|
Big Picture
|
||||||
|
- Architecture: Device → MQTT (mTLS) → mqtt_ingestion → Redis → db_write → PostgreSQL/TimescaleDB
|
||||||
|
- Components:
|
||||||
|
- `services/device_manager/` — FastAPI service for device registration, X.509 certificate issuance, and lifecycle management
|
||||||
|
- `services/mqtt_ingestion/` — MQTT client that subscribes to device topics and writes to single Redis stream `mqtt:ingestion`
|
||||||
|
- `services/db_write/` — Consumer service that reads from Redis streams and writes to database using consumer groups
|
||||||
|
- `db_migrations/` — Alembic migrations for schema management (SQLAlchemy models)
|
||||||
|
- `infrastructure/` — Docker Compose setup (PostgreSQL, Redis, Mosquitto MQTT broker)
|
||||||
|
- `iotDashboard/` — Legacy Django app (being phased out)
|
||||||
|
|
||||||
|
Key Files To Read First
|
||||||
|
- `db_migrations/models.py` — SQLAlchemy models: `Device`, `DeviceCertificate`, `Telemetry`. Canonical schema definition.
|
||||||
|
- `services/device_manager/app/app.py` — FastAPI endpoints for device registration, certificate management, revocation, renewal.
|
||||||
|
- `services/device_manager/app/cert_manager.py` — X.509 certificate generation, CA management, CRL generation.
|
||||||
|
- `services/mqtt_ingestion/src/mqtt_client.py` — MQTT subscriber that parses `devices/{device_id}/{metric}` topics.
|
||||||
|
- `services/mqtt_ingestion/src/redis_writer.py` — Writes to single stream `mqtt:ingestion` with device_id, metric, value, timestamp.
|
||||||
|
- `services/db_write/src/redis_reader.py` — Consumer group reader for `mqtt:ingestion` stream.
|
||||||
|
- `services/db_write/src/db_writer.py` — Batch writes to `telemetry` table using SQLAlchemy.
|
||||||
|
- `infrastructure/compose.yml` — Docker services: PostgreSQL/TimescaleDB, Redis, Mosquitto MQTT.
|
||||||
|
- `infrastructure/mosquitto/mosquitto.conf` — MQTT broker config with mTLS on port 8883, CRL checking enabled.
|
||||||
|
|
||||||
|
Important Conventions & Patterns
|
||||||
|
- **Single stream architecture**: All MQTT data flows through one Redis stream `mqtt:ingestion`. Each message contains `device_id`, `metric`, `value`, `timestamp`.
|
||||||
|
- **MQTT topics**: Standard format `devices/{device_id}/{metric}`. Examples: `devices/abc123/temperature`, `devices/xyz789/humidity`.
|
||||||
|
- **Certificate IDs**: Use certificate serial number (hex format) as primary key in `device_certificates` table. Multiple certificates per device supported.
|
||||||
|
- **Package manager**: All services use `uv` for dependency management (`pyproject.toml` not `requirements.txt`).
|
||||||
|
- **Database migrations**: Use Alembic for schema changes. Run migrations from `db_migrations/` directory.
|
||||||
|
- **Configuration**: All services use `.env` files. Never hardcode hosts/credentials.
|
||||||
|
- **Import organization**: Services have `app/` or `src/` package structure. Import as `from app.module import ...` or `from src.module import ...`.
|
||||||
|
- **Consumer groups**: `db_write` uses Redis consumer groups for at-least-once delivery. Consumer name must be unique per instance.
|
||||||
|
|
||||||
|
Developer Workflows (commands & notes)
|
||||||
|
- **Start infrastructure**: `cd infrastructure && docker compose up -d` (Postgres, Redis, Mosquitto)
|
||||||
|
- **Run database migrations**: `cd db_migrations && uv run alembic upgrade head`
|
||||||
|
- **Generate CA certificate**: `cd services/device_manager && ./generate_ca.sh` (first time only)
|
||||||
|
- **Run device_manager**: `cd services/device_manager && uv run uvicorn app.app:app --reload --port 8000`
|
||||||
|
- **Run mqtt_ingestion**: `cd services/mqtt_ingestion && uv run main.py`
|
||||||
|
- **Run db_write**: `cd services/db_write && uv run main.py`
|
||||||
|
- **Register device**: `curl -X POST http://localhost:8000/devices/register -H "Content-Type: application/json" -d '{"name":"test","location":"lab"}'`
|
||||||
|
- **Test MQTT with mTLS**: `mosquitto_pub --cafile ca.crt --cert device.crt --key device.key -h localhost -p 8883 -t "devices/abc123/temperature" -m "23.5"`
|
||||||
|
- **Inspect Redis stream**: `redis-cli XLEN mqtt:ingestion` and `redis-cli XRANGE mqtt:ingestion - + COUNT 10`
|
||||||
|
- **Check consumer group**: `redis-cli XINFO GROUPS mqtt:ingestion`
|
||||||
|
- **View CRL**: `openssl crl -in infrastructure/mosquitto/certs/ca.crl -text -noout`
|
||||||
|
|
||||||
|
Integration Points & Gotchas
|
||||||
|
- **Environment variables**: All services load from `.env` files. No defaults - service will fail if required vars missing. Copy `.env.example` first.
|
||||||
|
- **Certificate paths**: `device_manager` writes CRL to `infrastructure/mosquitto/certs/ca.crl`. Mosquitto must restart after CRL updates.
|
||||||
|
- **Database schema**: Schema changes require Alembic migration. Never modify tables manually. Use `alembic revision --autogenerate`.
|
||||||
|
- **MQTT topic parsing**: `mqtt_ingestion` expects exactly `devices/{device_id}/{metric}` (3 parts). Invalid topics are logged and dropped.
|
||||||
|
- **Redis stream format**: `mqtt:ingestion` messages must have `device_id`, `metric`, `value`, `timestamp` fields (all strings).
|
||||||
|
- **Consumer groups**: `db_write` creates consumer group `db_writer` automatically. Don't delete it manually.
|
||||||
|
- **Certificate serial numbers**: Used as primary key in `device_certificates.id`. Extract with `format(cert.serial_number, 'x')`.
|
||||||
|
- **TimescaleDB hypertables**: `telemetry` table is a hypertable. Don't add constraints that break time partitioning.
|
||||||
|
- **File permissions**: Mosquitto directories may be owned by UID 1883. Fix with `sudo chown -R $USER:$USER infrastructure/mosquitto/`.
|
||||||
|
|
||||||
|
What AI agents should do first
|
||||||
|
- **Read architecture first**: Check `README.md` for current architecture. System is microservices-based, not Django monolith.
|
||||||
|
- **Check database schema**: Always start with `db_migrations/models.py` to understand data model.
|
||||||
|
- **Don't change stream names**: Single stream `mqtt:ingestion` is used by mqtt_ingestion and db_write. Changing breaks both services.
|
||||||
|
- **Use proper imports**: Services use package structure. Import from `app.*` or `src.*`, not relative imports.
|
||||||
|
- **Create migrations**: Schema changes require `alembic revision --autogenerate`. Never modify models without migration.
|
||||||
|
- **Test with real infrastructure**: Use `docker compose up` for integration testing. Unit tests are insufficient for this architecture.
|
||||||
|
- **Check .env files**: Each service has `.env.example`. Copy and configure before running.
|
||||||
|
|
||||||
|
Examples (copyable snippets)
|
||||||
|
- **Write to single stream** (mqtt_ingestion):
|
||||||
|
```python
|
||||||
|
redis_client.xadd("mqtt:ingestion", {
|
||||||
|
"device_id": device_id,
|
||||||
|
"metric": sensor_type,
|
||||||
|
"value": str(value),
|
||||||
|
"timestamp": datetime.utcnow().isoformat()
|
||||||
|
})
|
||||||
|
```
|
||||||
|
|
||||||
|
- **Read from stream with consumer group** (db_write):
|
||||||
|
```python
|
||||||
|
results = redis_client.xreadgroup(
|
||||||
|
groupname="db_writer",
|
||||||
|
consumername="worker-01",
|
||||||
|
streams={"mqtt:ingestion": ">"},
|
||||||
|
count=100,
|
||||||
|
block=5000
|
||||||
|
)
|
||||||
|
```
|
||||||
|
|
||||||
|
- **Extract certificate serial number**:
|
||||||
|
```python
|
||||||
|
from cryptography import x509
|
||||||
|
cert = x509.load_pem_x509_certificate(cert_pem)
|
||||||
|
cert_id = format(cert.serial_number, 'x')
|
||||||
|
```
|
||||||
|
|
||||||
|
- **Query active certificates**:
|
||||||
|
```python
|
||||||
|
device_cert = db.query(DeviceCertificate).filter(
|
||||||
|
DeviceCertificate.device_id == device_id,
|
||||||
|
DeviceCertificate.revoked_at.is_(None)
|
||||||
|
).first()
|
||||||
|
```
|
||||||
|
|
||||||
|
If you add or change docs
|
||||||
|
- Update `README.md` for architecture changes
|
||||||
|
- Update `.github/copilot-instructions.md` for development workflow changes
|
||||||
|
- Update service-specific READMEs (`services/*/README.md`) for API or configuration changes
|
||||||
|
- Document environment variables in `.env.example` files
|
||||||
|
- Add migration notes to Alembic revision if schema change is complex
|
||||||
|
|
||||||
|
If anything in these instructions looks off or incomplete for your current refactor, tell me what you'd like to focus on and I'll iterate.
|
||||||
19
.github/prompts/planing.prompt.md
vendored
Normal file
19
.github/prompts/planing.prompt.md
vendored
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
---
|
||||||
|
mode: agent
|
||||||
|
description: 'Start planning'
|
||||||
|
tools: ['getNotebookSummary', 'readNotebookCellOutput', 'search', 'getTerminalOutput', 'terminalSelection', 'terminalLastCommand', 'usages', 'vscodeAPI', 'think', 'problems', 'changes', 'testFailure', 'fetch', 'githubRepo', 'todos', 'get_issue', 'get_issue_comments', 'get_me','haystackFiles','haystackSearch']
|
||||||
|
---
|
||||||
|
Your goal is to prepare a detailed plan to fix the bug or add the new feature, for this you first need to:
|
||||||
|
* Understand the context of the bug or feature by reading the issue description and comments.
|
||||||
|
* Understand the codebase by reading the relevant instruction files.
|
||||||
|
* If its a bug, then identify the root cause of the bug, and explain this to the user.
|
||||||
|
|
||||||
|
Based on your above understanding generate a plan to fix the bug or add the new feature.
|
||||||
|
Ensure the plan consists of a Markdown document that has the following sections:
|
||||||
|
|
||||||
|
* Overview: A brief description of the bug/feature.
|
||||||
|
* Root Cause: A detailed explanation of the root cause of the bug, including any relevant code snippets or references to the codebase. (only if it's a bug)
|
||||||
|
* Requirements: A list of requirements to resolve the bug or add the new feature.
|
||||||
|
* Implementation Steps: A detailed list of steps to implement the bug fix or new feature.
|
||||||
|
|
||||||
|
Remember, do not make any code edits, just generate a plan. Use thinking and reasoning skills to outline the steps needed to achieve the desired outcome.
|
||||||
5
.gitignore
vendored
5
.gitignore
vendored
@@ -8,3 +8,8 @@ demo.db-wal
|
|||||||
/iotDashboard/demo1.db
|
/iotDashboard/demo1.db
|
||||||
**/__pycache__/
|
**/__pycache__/
|
||||||
iotDashboard/db_create.py
|
iotDashboard/db_create.py
|
||||||
|
.venv/
|
||||||
|
.ruff_cache/
|
||||||
|
*.key
|
||||||
|
*.crt
|
||||||
|
*.csr
|
||||||
1
.python-version
Normal file
1
.python-version
Normal file
@@ -0,0 +1 @@
|
|||||||
|
3.13
|
||||||
23
.vscode/launch.json
vendored
Normal file
23
.vscode/launch.json
vendored
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
{
|
||||||
|
// Use IntelliSense to learn about possible attributes.
|
||||||
|
// Hover to view descriptions of existing attributes.
|
||||||
|
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
|
||||||
|
"version": "0.2.0",
|
||||||
|
"configurations": [
|
||||||
|
|
||||||
|
{
|
||||||
|
"name": "Python Debugger: FastAPI",
|
||||||
|
"type": "debugpy",
|
||||||
|
"request": "launch",
|
||||||
|
"program": "uv",
|
||||||
|
"cwd": "${workspaceFolder}/services/device_manager",
|
||||||
|
"args": [
|
||||||
|
"run",
|
||||||
|
"uvicorn",
|
||||||
|
"main:app",
|
||||||
|
"--reload"
|
||||||
|
],
|
||||||
|
"jinja": true
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
19
README.md
19
README.md
@@ -1,2 +1,17 @@
|
|||||||
# iotDashboard
|
# IoT Dashboard
|
||||||
iotDashboard - IOT dashboard with Django, TimescaleDB and Redis
|
|
||||||
|
Microservices-based IoT platform with device management, mTLS authentication, and time-series data storage.
|
||||||
|
|
||||||
|
**Architecture:** Device → MQTT (mTLS) → mqtt_ingestion → Redis → db_write → PostgreSQL/TimescaleDB
|
||||||
|
|
||||||
|
## Services
|
||||||
|
|
||||||
|
- **device_manager** - Device registration & X.509 certificates (FastAPI)
|
||||||
|
- **mqtt_ingestion** - MQTT → Redis pipeline
|
||||||
|
- **db_write** - Redis → PostgreSQL writer
|
||||||
|
- **infrastructure** - Docker Compose (PostgreSQL, Redis, Mosquitto)
|
||||||
|
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
MIT License
|
||||||
|
|||||||
1
db_migrations/.env.example
Normal file
1
db_migrations/.env.example
Normal file
@@ -0,0 +1 @@
|
|||||||
|
CONNECTION_STRING=postgresql://user:password@localhost:5432/iotdashboard
|
||||||
5
db_migrations/.gitignore
vendored
Normal file
5
db_migrations/.gitignore
vendored
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
__pycache__/
|
||||||
|
*.py[cod]
|
||||||
|
*.so
|
||||||
|
.Python
|
||||||
|
*.egg-info/
|
||||||
111
db_migrations/README.md
Normal file
111
db_migrations/README.md
Normal file
@@ -0,0 +1,111 @@
|
|||||||
|
# Database Migrations
|
||||||
|
|
||||||
|
This directory contains all database schema definitions and migrations for the IoT Dashboard project.
|
||||||
|
|
||||||
|
## Quick Start
|
||||||
|
|
||||||
|
### 1. Install Dependencies
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pip install alembic sqlalchemy python-dotenv psycopg2-binary
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Configure Database
|
||||||
|
|
||||||
|
Set `CONNECTION_STRING` or `DATABASE_URL` in the root `.env` file:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
CONNECTION_STRING=postgresql://user:password@localhost:5432/iotdashboard
|
||||||
|
```
|
||||||
|
|
||||||
|
### 3. Create Initial Migration
|
||||||
|
|
||||||
|
```bash
|
||||||
|
chmod +x migrate.sh
|
||||||
|
./migrate.sh create "initial schema"
|
||||||
|
```
|
||||||
|
|
||||||
|
### 4. Review Migration
|
||||||
|
|
||||||
|
Check the generated file in `alembic/versions/`
|
||||||
|
|
||||||
|
### 5. Apply Migration
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./migrate.sh upgrade
|
||||||
|
```
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
### Create a New Migration
|
||||||
|
|
||||||
|
After editing `models.py`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./migrate.sh create "add new column"
|
||||||
|
```
|
||||||
|
|
||||||
|
### Apply Migrations
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./migrate.sh upgrade
|
||||||
|
```
|
||||||
|
|
||||||
|
### Check Current Version
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./migrate.sh current
|
||||||
|
```
|
||||||
|
|
||||||
|
### View History
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./migrate.sh history
|
||||||
|
```
|
||||||
|
|
||||||
|
### Rollback
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./migrate.sh downgrade 1
|
||||||
|
```
|
||||||
|
|
||||||
|
## File Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
db_migrations/
|
||||||
|
├── models.py # SQLAlchemy models (schema definition)
|
||||||
|
├── alembic.ini # Alembic configuration
|
||||||
|
├── alembic/
|
||||||
|
│ ├── env.py # Migration environment
|
||||||
|
│ ├── script.py.mako # Migration template
|
||||||
|
│ └── versions/ # Generated migrations
|
||||||
|
├── migrate.sh # Helper script
|
||||||
|
└── README.md # This file
|
||||||
|
```
|
||||||
|
|
||||||
|
## Modifying Schema
|
||||||
|
|
||||||
|
1. Edit `models.py` to define your changes
|
||||||
|
2. Run `./migrate.sh create "description"`
|
||||||
|
3. Review the generated migration
|
||||||
|
4. Run `./migrate.sh upgrade`
|
||||||
|
|
||||||
|
## Using Models in Services
|
||||||
|
|
||||||
|
Services can import models from here:
|
||||||
|
|
||||||
|
```python
|
||||||
|
# In services/db_write/db_writer.py or any other service
|
||||||
|
import sys
|
||||||
|
sys.path.insert(0, '/path/to/db_migrations')
|
||||||
|
from models import SensorReading
|
||||||
|
```
|
||||||
|
|
||||||
|
Or better, use relative imports if properly structured.
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- The `.env` file should be in the project root (`../.env`)
|
||||||
|
- Migrations are applied to whatever database is in `CONNECTION_STRING`
|
||||||
|
- Always review generated migrations before applying
|
||||||
|
- Keep `models.py` as the single source of truth for schema
|
||||||
40
db_migrations/alembic.ini
Normal file
40
db_migrations/alembic.ini
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
# Alembic Configuration
|
||||||
|
|
||||||
|
[alembic]
|
||||||
|
script_location = alembic
|
||||||
|
file_template = %%(year)d%%(month).2d%%(day).2d_%%(hour).2d%%(minute).2d_%%(rev)s_%%(slug)s
|
||||||
|
timezone = UTC
|
||||||
|
|
||||||
|
[loggers]
|
||||||
|
keys = root,sqlalchemy,alembic
|
||||||
|
|
||||||
|
[handlers]
|
||||||
|
keys = console
|
||||||
|
|
||||||
|
[formatters]
|
||||||
|
keys = generic
|
||||||
|
|
||||||
|
[logger_root]
|
||||||
|
level = WARN
|
||||||
|
handlers = console
|
||||||
|
qualname =
|
||||||
|
|
||||||
|
[logger_sqlalchemy]
|
||||||
|
level = WARN
|
||||||
|
handlers =
|
||||||
|
qualname = sqlalchemy.engine
|
||||||
|
|
||||||
|
[logger_alembic]
|
||||||
|
level = INFO
|
||||||
|
handlers =
|
||||||
|
qualname = alembic
|
||||||
|
|
||||||
|
[handler_console]
|
||||||
|
class = StreamHandler
|
||||||
|
args = (sys.stderr,)
|
||||||
|
level = NOTSET
|
||||||
|
formatter = generic
|
||||||
|
|
||||||
|
[formatter_generic]
|
||||||
|
format = %(levelname)-5.5s [%(name)s] %(message)s
|
||||||
|
datefmt = %H:%M:%S
|
||||||
89
db_migrations/alembic/env.py
Normal file
89
db_migrations/alembic/env.py
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
from logging.config import fileConfig
|
||||||
|
from sqlalchemy import engine_from_config, pool
|
||||||
|
from alembic import context
|
||||||
|
import os
|
||||||
|
import sys
|
||||||
|
|
||||||
|
# Add parent directory to path so we can import models
|
||||||
|
sys.path.insert(0, os.path.dirname(os.path.dirname(__file__)))
|
||||||
|
|
||||||
|
# Load environment variables
|
||||||
|
from dotenv import load_dotenv
|
||||||
|
|
||||||
|
load_dotenv(os.path.join(os.path.dirname(__file__), "../.env"))
|
||||||
|
|
||||||
|
# Import your models
|
||||||
|
from models import Base
|
||||||
|
|
||||||
|
# this is the Alembic Config object, which provides
|
||||||
|
# access to the values within the .ini file in use.
|
||||||
|
config = context.config
|
||||||
|
|
||||||
|
# Set database URL from environment
|
||||||
|
database_url = os.getenv("CONNECTION_STRING") or os.getenv("DATABASE_URL")
|
||||||
|
if database_url:
|
||||||
|
config.set_main_option("sqlalchemy.url", database_url)
|
||||||
|
|
||||||
|
# Interpret the config file for Python logging.
|
||||||
|
# This line sets up loggers basically.
|
||||||
|
if config.config_file_name is not None:
|
||||||
|
fileConfig(config.config_file_name)
|
||||||
|
|
||||||
|
# add your model's MetaData object here
|
||||||
|
# for 'autogenerate' support
|
||||||
|
target_metadata = Base.metadata
|
||||||
|
|
||||||
|
# other values from the config, defined by the needs of env.py,
|
||||||
|
# can be acquired:
|
||||||
|
# my_important_option = config.get_main_option("my_important_option")
|
||||||
|
# ... etc.
|
||||||
|
|
||||||
|
|
||||||
|
def run_migrations_offline() -> None:
|
||||||
|
"""Run migrations in 'offline' mode.
|
||||||
|
|
||||||
|
This configures the context with just a URL
|
||||||
|
and not an Engine, though an Engine is acceptable
|
||||||
|
here as well. By skipping the Engine creation
|
||||||
|
we don't even need a DBAPI to be available.
|
||||||
|
|
||||||
|
Calls to context.execute() here emit the given string to the
|
||||||
|
script output.
|
||||||
|
|
||||||
|
"""
|
||||||
|
url = config.get_main_option("sqlalchemy.url")
|
||||||
|
context.configure(
|
||||||
|
url=url,
|
||||||
|
target_metadata=target_metadata,
|
||||||
|
literal_binds=True,
|
||||||
|
dialect_opts={"paramstyle": "named"},
|
||||||
|
)
|
||||||
|
|
||||||
|
with context.begin_transaction():
|
||||||
|
context.run_migrations()
|
||||||
|
|
||||||
|
|
||||||
|
def run_migrations_online() -> None:
|
||||||
|
"""Run migrations in 'online' mode.
|
||||||
|
|
||||||
|
In this scenario we need to create an Engine
|
||||||
|
and associate a connection with the context.
|
||||||
|
|
||||||
|
"""
|
||||||
|
connectable = engine_from_config(
|
||||||
|
config.get_section(config.config_ini_section, {}),
|
||||||
|
prefix="sqlalchemy.",
|
||||||
|
poolclass=pool.NullPool,
|
||||||
|
)
|
||||||
|
|
||||||
|
with connectable.connect() as connection:
|
||||||
|
context.configure(connection=connection, target_metadata=target_metadata)
|
||||||
|
|
||||||
|
with context.begin_transaction():
|
||||||
|
context.run_migrations()
|
||||||
|
|
||||||
|
|
||||||
|
if context.is_offline_mode():
|
||||||
|
run_migrations_offline()
|
||||||
|
else:
|
||||||
|
run_migrations_online()
|
||||||
28
db_migrations/alembic/script.py.mako
Normal file
28
db_migrations/alembic/script.py.mako
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
"""${message}
|
||||||
|
|
||||||
|
Revision ID: ${up_revision}
|
||||||
|
Revises: ${down_revision | comma,n}
|
||||||
|
Create Date: ${create_date}
|
||||||
|
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
${imports if imports else ""}
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision: str = ${repr(up_revision)}
|
||||||
|
down_revision: Union[str, Sequence[str], None] = ${repr(down_revision)}
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)}
|
||||||
|
depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)}
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
"""Upgrade schema."""
|
||||||
|
${upgrades if upgrades else "pass"}
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
"""Downgrade schema."""
|
||||||
|
${downgrades if downgrades else "pass"}
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
"""Initial
|
||||||
|
|
||||||
|
Revision ID: dae12d7f4ddf
|
||||||
|
Revises:
|
||||||
|
Create Date: 2025-10-28 22:06:45.637836+00:00
|
||||||
|
|
||||||
|
"""
|
||||||
|
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision: str = "dae12d7f4ddf"
|
||||||
|
down_revision: Union[str, Sequence[str], None] = None
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
"""Upgrade schema."""
|
||||||
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
|
op.create_table(
|
||||||
|
"devices",
|
||||||
|
sa.Column("id", sa.Text(), nullable=False),
|
||||||
|
sa.Column("name", sa.Text(), nullable=False),
|
||||||
|
sa.Column("location", sa.Text(), nullable=True),
|
||||||
|
sa.Column("is_active", sa.Boolean(), nullable=True),
|
||||||
|
sa.PrimaryKeyConstraint("id"),
|
||||||
|
)
|
||||||
|
op.create_table(
|
||||||
|
"telemetry",
|
||||||
|
sa.Column("time", sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.Column("device_id", sa.Text(), nullable=False),
|
||||||
|
sa.Column("metric", sa.Text(), nullable=False),
|
||||||
|
sa.Column("value", sa.Float(), nullable=False),
|
||||||
|
sa.Column("unit", sa.Text(), nullable=True),
|
||||||
|
sa.ForeignKeyConstraint(
|
||||||
|
["device_id"],
|
||||||
|
["devices.id"],
|
||||||
|
),
|
||||||
|
sa.PrimaryKeyConstraint("time", "device_id", "metric"),
|
||||||
|
)
|
||||||
|
op.create_index(
|
||||||
|
"idx_telemetry_device_time", "telemetry", ["device_id", "time"], unique=False
|
||||||
|
)
|
||||||
|
|
||||||
|
# Convert telemetry table to TimescaleDB hypertable
|
||||||
|
op.execute("SELECT create_hypertable('telemetry', 'time', if_not_exists => TRUE);")
|
||||||
|
# ### end Alembic commands ###
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
"""Downgrade schema."""
|
||||||
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
|
|
||||||
|
# Note: TimescaleDB hypertables cannot be easily converted back to regular tables
|
||||||
|
# The table will be dropped entirely, which removes the hypertable as well
|
||||||
|
op.drop_index("idx_telemetry_device_time", table_name="telemetry")
|
||||||
|
op.drop_table("telemetry")
|
||||||
|
op.drop_table("devices")
|
||||||
|
# ### end Alembic commands ###
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
"""add device_certificates table
|
||||||
|
|
||||||
|
Revision ID: f94393f57c35
|
||||||
|
Revises: dae12d7f4ddf
|
||||||
|
Create Date: 2025-10-29 21:57:58.983071+00:00
|
||||||
|
|
||||||
|
"""
|
||||||
|
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision: str = "f94393f57c35"
|
||||||
|
down_revision: Union[str, Sequence[str], None] = "dae12d7f4ddf"
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
"""Upgrade schema."""
|
||||||
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
|
op.create_table(
|
||||||
|
"device_certificates",
|
||||||
|
sa.Column("device_id", sa.Text(), nullable=False),
|
||||||
|
sa.Column("certificate_pem", sa.Text(), nullable=False),
|
||||||
|
sa.Column("private_key_pem", sa.Text(), nullable=True),
|
||||||
|
sa.Column("issued_at", sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.Column("expires_at", sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.Column("revoked_at", sa.DateTime(timezone=True), nullable=True),
|
||||||
|
sa.ForeignKeyConstraint(["device_id"], ["devices.id"], ondelete="CASCADE"),
|
||||||
|
sa.PrimaryKeyConstraint("device_id"),
|
||||||
|
)
|
||||||
|
op.add_column(
|
||||||
|
"devices",
|
||||||
|
sa.Column(
|
||||||
|
"created_at",
|
||||||
|
sa.DateTime(timezone=True),
|
||||||
|
server_default=sa.text("now()"),
|
||||||
|
nullable=True,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
op.drop_index(op.f("telemetry_time_idx"), table_name="telemetry")
|
||||||
|
# ### end Alembic commands ###
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
"""Downgrade schema."""
|
||||||
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
|
op.create_index(
|
||||||
|
op.f("telemetry_time_idx"),
|
||||||
|
"telemetry",
|
||||||
|
[sa.literal_column("time DESC")],
|
||||||
|
unique=False,
|
||||||
|
)
|
||||||
|
op.drop_column("devices", "created_at")
|
||||||
|
op.drop_table("device_certificates")
|
||||||
|
# ### end Alembic commands ###
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
"""add_certificate_id_and_indices
|
||||||
|
|
||||||
|
Revision ID: 4f152b34e800
|
||||||
|
Revises: f94393f57c35
|
||||||
|
Create Date: 2025-10-30 21:29:43.843375+00:00
|
||||||
|
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision: str = '4f152b34e800'
|
||||||
|
down_revision: Union[str, Sequence[str], None] = 'f94393f57c35'
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
"""Upgrade schema."""
|
||||||
|
# Step 1: Add id column as nullable first
|
||||||
|
op.add_column('device_certificates', sa.Column('id', sa.Text(), nullable=True))
|
||||||
|
|
||||||
|
# Step 2: Generate IDs for existing records (use device_id as temporary ID)
|
||||||
|
op.execute("""
|
||||||
|
UPDATE device_certificates
|
||||||
|
SET id = device_id || '-' || EXTRACT(EPOCH FROM issued_at)::text
|
||||||
|
WHERE id IS NULL
|
||||||
|
""")
|
||||||
|
|
||||||
|
# Step 3: Drop old primary key constraint
|
||||||
|
op.drop_constraint('device_certificates_pkey', 'device_certificates', type_='primary')
|
||||||
|
|
||||||
|
# Step 4: Make id NOT NULL now that all rows have values
|
||||||
|
op.alter_column('device_certificates', 'id', nullable=False)
|
||||||
|
|
||||||
|
# Step 5: Create new primary key on id
|
||||||
|
op.create_primary_key('device_certificates_pkey', 'device_certificates', ['id'])
|
||||||
|
|
||||||
|
# Step 6: Create indices
|
||||||
|
op.create_index('idx_device_certificates_active', 'device_certificates', ['device_id', 'revoked_at'], unique=False)
|
||||||
|
op.create_index('idx_device_certificates_device_id', 'device_certificates', ['device_id'], unique=False)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
"""Downgrade schema."""
|
||||||
|
# Drop indices
|
||||||
|
op.drop_index('idx_device_certificates_device_id', table_name='device_certificates')
|
||||||
|
op.drop_index('idx_device_certificates_active', table_name='device_certificates')
|
||||||
|
|
||||||
|
# Drop new primary key
|
||||||
|
op.drop_constraint('device_certificates_pkey', 'device_certificates', type_='primary')
|
||||||
|
|
||||||
|
# Recreate old primary key on device_id
|
||||||
|
op.create_primary_key('device_certificates_pkey', 'device_certificates', ['device_id'])
|
||||||
|
|
||||||
|
# Drop id column
|
||||||
|
op.drop_column('device_certificates', 'id')
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
"""add protocol and connection_config to devices
|
||||||
|
|
||||||
|
Revision ID: 4e405f1129b1
|
||||||
|
Revises: 4f152b34e800
|
||||||
|
Create Date: 2025-11-01 19:07:22.800918+00:00
|
||||||
|
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision: str = '4e405f1129b1'
|
||||||
|
down_revision: Union[str, Sequence[str], None] = '4f152b34e800'
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
"""Upgrade schema."""
|
||||||
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
|
op.create_table('device_credentials',
|
||||||
|
sa.Column('id', sa.Text(), nullable=False),
|
||||||
|
sa.Column('device_id', sa.Text(), nullable=False),
|
||||||
|
sa.Column('credential_type', sa.Text(), nullable=False),
|
||||||
|
sa.Column('credential_hash', sa.Text(), nullable=False),
|
||||||
|
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.Column('expires_at', sa.DateTime(timezone=True), nullable=True),
|
||||||
|
sa.Column('revoked_at', sa.DateTime(timezone=True), nullable=True),
|
||||||
|
sa.ForeignKeyConstraint(['device_id'], ['devices.id'], ondelete='CASCADE'),
|
||||||
|
sa.PrimaryKeyConstraint('id')
|
||||||
|
)
|
||||||
|
op.create_index('idx_device_credentials_active', 'device_credentials', ['device_id', 'revoked_at'], unique=False)
|
||||||
|
op.create_index('idx_device_credentials_device_id', 'device_credentials', ['device_id'], unique=False)
|
||||||
|
|
||||||
|
# Add protocol column as nullable first, set default for existing rows, then make NOT NULL
|
||||||
|
op.add_column('devices', sa.Column('protocol', sa.Text(), nullable=True))
|
||||||
|
op.execute("UPDATE devices SET protocol = 'mqtt' WHERE protocol IS NULL")
|
||||||
|
op.alter_column('devices', 'protocol', nullable=False)
|
||||||
|
|
||||||
|
op.add_column('devices', sa.Column('connection_config', sa.JSON(), nullable=True))
|
||||||
|
# ### end Alembic commands ###
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
"""Downgrade schema."""
|
||||||
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
|
op.drop_column('devices', 'connection_config')
|
||||||
|
op.drop_column('devices', 'protocol')
|
||||||
|
op.drop_index('idx_device_credentials_device_id', table_name='device_credentials')
|
||||||
|
op.drop_index('idx_device_credentials_active', table_name='device_credentials')
|
||||||
|
op.drop_table('device_credentials')
|
||||||
|
# ### end Alembic commands ###
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
"""Add device onboarding tokens for secure QR code onboarding
|
||||||
|
|
||||||
|
Revision ID: 0f2632e459d3
|
||||||
|
Revises: 4e405f1129b1
|
||||||
|
Create Date: 2025-11-13 22:08:31.765427+00:00
|
||||||
|
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision: str = '0f2632e459d3'
|
||||||
|
down_revision: Union[str, Sequence[str], None] = '4e405f1129b1'
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
"""Upgrade schema."""
|
||||||
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
|
op.create_table('device_onboarding_tokens',
|
||||||
|
sa.Column('token', sa.Text(), nullable=False),
|
||||||
|
sa.Column('device_id', sa.Text(), nullable=False),
|
||||||
|
sa.Column('certificate_id', sa.Text(), nullable=False),
|
||||||
|
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.Column('expires_at', sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.Column('used_at', sa.DateTime(timezone=True), nullable=True),
|
||||||
|
sa.ForeignKeyConstraint(['device_id'], ['devices.id'], ondelete='CASCADE'),
|
||||||
|
sa.PrimaryKeyConstraint('token')
|
||||||
|
)
|
||||||
|
op.create_index('idx_onboarding_tokens_device_id', 'device_onboarding_tokens', ['device_id'], unique=False)
|
||||||
|
op.create_index('idx_onboarding_tokens_expires', 'device_onboarding_tokens', ['expires_at'], unique=False)
|
||||||
|
# ### end Alembic commands ###
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
"""Downgrade schema."""
|
||||||
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
|
op.drop_index('idx_onboarding_tokens_expires', table_name='device_onboarding_tokens')
|
||||||
|
op.drop_index('idx_onboarding_tokens_device_id', table_name='device_onboarding_tokens')
|
||||||
|
op.drop_table('device_onboarding_tokens')
|
||||||
|
# ### end Alembic commands ###
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
"""add_cascade_delete_to_telemetry
|
||||||
|
|
||||||
|
Revision ID: 4b84a36e13f5
|
||||||
|
Revises: 0f2632e459d3
|
||||||
|
Create Date: 2025-11-13 23:18:36.029045+00:00
|
||||||
|
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision: str = '4b84a36e13f5'
|
||||||
|
down_revision: Union[str, Sequence[str], None] = '0f2632e459d3'
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
"""Upgrade schema: Add ON DELETE CASCADE to telemetry foreign key."""
|
||||||
|
# Drop existing foreign key constraint
|
||||||
|
op.drop_constraint('telemetry_device_id_fkey', 'telemetry', type_='foreignkey')
|
||||||
|
|
||||||
|
# Re-create foreign key with ON DELETE CASCADE
|
||||||
|
op.create_foreign_key(
|
||||||
|
'telemetry_device_id_fkey',
|
||||||
|
'telemetry',
|
||||||
|
'devices',
|
||||||
|
['device_id'],
|
||||||
|
['id'],
|
||||||
|
ondelete='CASCADE'
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
"""Downgrade schema: Remove CASCADE from telemetry foreign key."""
|
||||||
|
# Drop foreign key with CASCADE
|
||||||
|
op.drop_constraint('telemetry_device_id_fkey', 'telemetry', type_='foreignkey')
|
||||||
|
|
||||||
|
# Re-create foreign key without CASCADE (original state)
|
||||||
|
op.create_foreign_key(
|
||||||
|
'telemetry_device_id_fkey',
|
||||||
|
'telemetry',
|
||||||
|
'devices',
|
||||||
|
['device_id'],
|
||||||
|
['id']
|
||||||
|
)
|
||||||
120
db_migrations/models.py
Normal file
120
db_migrations/models.py
Normal file
@@ -0,0 +1,120 @@
|
|||||||
|
"""
|
||||||
|
Database models for the IoT Dashboard.
|
||||||
|
|
||||||
|
To modify schema:
|
||||||
|
1. Edit models here
|
||||||
|
2. Run: alembic revision --autogenerate -m "description"
|
||||||
|
3. Review the generated migration in alembic/versions/
|
||||||
|
4. Run: alembic upgrade head
|
||||||
|
"""
|
||||||
|
|
||||||
|
from sqlalchemy import Boolean, Column, Float, ForeignKey, Index, Text, DateTime, JSON
|
||||||
|
from sqlalchemy.ext.declarative import declarative_base
|
||||||
|
from sqlalchemy.sql import func
|
||||||
|
|
||||||
|
Base = declarative_base()
|
||||||
|
|
||||||
|
|
||||||
|
class Device(Base):
|
||||||
|
"""IoT devices registered in the system."""
|
||||||
|
|
||||||
|
__tablename__ = "devices"
|
||||||
|
|
||||||
|
id = Column(Text, primary_key=True)
|
||||||
|
name = Column(Text, nullable=False)
|
||||||
|
location = Column(Text)
|
||||||
|
protocol = Column(Text, nullable=False, default="mqtt")
|
||||||
|
connection_config = Column(JSON)
|
||||||
|
is_active = Column(Boolean, default=True)
|
||||||
|
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||||
|
|
||||||
|
def __repr__(self):
|
||||||
|
return f"<Device(id={self.id}, name={self.name}, protocol={self.protocol})>"
|
||||||
|
class DeviceCertificate(Base):
|
||||||
|
"""X.509 certificates issued to devices for mTLS authentication."""
|
||||||
|
|
||||||
|
__tablename__ = "device_certificates"
|
||||||
|
|
||||||
|
id = Column(Text, primary_key=True)
|
||||||
|
device_id = Column(
|
||||||
|
Text, ForeignKey("devices.id", ondelete="CASCADE"), nullable=False
|
||||||
|
)
|
||||||
|
certificate_pem = Column(Text, nullable=False)
|
||||||
|
private_key_pem = Column(Text)
|
||||||
|
issued_at = Column(DateTime(timezone=True), nullable=False)
|
||||||
|
expires_at = Column(DateTime(timezone=True), nullable=False)
|
||||||
|
revoked_at = Column(DateTime(timezone=True))
|
||||||
|
|
||||||
|
__table_args__ = (
|
||||||
|
Index("idx_device_certificates_device_id", "device_id"),
|
||||||
|
Index("idx_device_certificates_active", "device_id", "revoked_at"),
|
||||||
|
)
|
||||||
|
|
||||||
|
def __repr__(self):
|
||||||
|
return f"<DeviceCertificate(id={self.id}, device_id={self.device_id}, expires={self.expires_at})>"
|
||||||
|
|
||||||
|
|
||||||
|
class DeviceCredential(Base):
|
||||||
|
"""Authentication credentials for non-mTLS protocols (HTTP, webhook, etc)."""
|
||||||
|
|
||||||
|
__tablename__ = "device_credentials"
|
||||||
|
|
||||||
|
id = Column(Text, primary_key=True)
|
||||||
|
device_id = Column(
|
||||||
|
Text, ForeignKey("devices.id", ondelete="CASCADE"), nullable=False
|
||||||
|
)
|
||||||
|
credential_type = Column(Text, nullable=False)
|
||||||
|
credential_hash = Column(Text, nullable=False)
|
||||||
|
created_at = Column(DateTime(timezone=True), nullable=False)
|
||||||
|
expires_at = Column(DateTime(timezone=True))
|
||||||
|
revoked_at = Column(DateTime(timezone=True))
|
||||||
|
|
||||||
|
__table_args__ = (
|
||||||
|
Index("idx_device_credentials_device_id", "device_id"),
|
||||||
|
Index("idx_device_credentials_active", "device_id", "revoked_at"),
|
||||||
|
)
|
||||||
|
|
||||||
|
def __repr__(self):
|
||||||
|
return f"<DeviceCredential(id={self.id}, device_id={self.device_id}, type={self.credential_type})>"
|
||||||
|
|
||||||
|
|
||||||
|
class DeviceOnboardingToken(Base):
|
||||||
|
"""One-time tokens for secure device onboarding via QR code."""
|
||||||
|
|
||||||
|
__tablename__ = "device_onboarding_tokens"
|
||||||
|
|
||||||
|
token = Column(Text, primary_key=True)
|
||||||
|
device_id = Column(
|
||||||
|
Text, ForeignKey("devices.id", ondelete="CASCADE"), nullable=False
|
||||||
|
)
|
||||||
|
certificate_id = Column(Text, nullable=False)
|
||||||
|
created_at = Column(DateTime(timezone=True), nullable=False)
|
||||||
|
expires_at = Column(DateTime(timezone=True), nullable=False)
|
||||||
|
used_at = Column(DateTime(timezone=True))
|
||||||
|
|
||||||
|
__table_args__ = (
|
||||||
|
Index("idx_onboarding_tokens_device_id", "device_id"),
|
||||||
|
Index("idx_onboarding_tokens_expires", "expires_at"),
|
||||||
|
)
|
||||||
|
|
||||||
|
def __repr__(self):
|
||||||
|
return f"<DeviceOnboardingToken(device_id={self.device_id}, used={self.used_at is not None})>"
|
||||||
|
|
||||||
|
|
||||||
|
class Telemetry(Base):
|
||||||
|
"""
|
||||||
|
Time-series telemetry data from devices.
|
||||||
|
"""
|
||||||
|
|
||||||
|
__tablename__ = "telemetry"
|
||||||
|
|
||||||
|
time = Column(DateTime(timezone=True), primary_key=True, nullable=False)
|
||||||
|
device_id = Column(Text, ForeignKey("devices.id"), primary_key=True, nullable=False)
|
||||||
|
metric = Column(Text, primary_key=True, nullable=False)
|
||||||
|
value = Column(Float, nullable=False)
|
||||||
|
unit = Column(Text)
|
||||||
|
|
||||||
|
__table_args__ = (Index("idx_telemetry_device_time", "device_id", "time"),)
|
||||||
|
|
||||||
|
def __repr__(self):
|
||||||
|
return f"<Telemetry(device={self.device_id}, metric={self.metric}, value={self.value})>"
|
||||||
10
db_migrations/pyproject.toml
Normal file
10
db_migrations/pyproject.toml
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
[project]
|
||||||
|
name = "db-migrations"
|
||||||
|
version = "0.1.0"
|
||||||
|
description = "DB migration scripts"
|
||||||
|
readme = "README.md"
|
||||||
|
requires-python = ">=3.13"
|
||||||
|
dependencies = [
|
||||||
|
"alembic>=1.17.0",
|
||||||
|
"sqlalchemy>=2.0.44",
|
||||||
|
]
|
||||||
9
docs/introduction.md
Normal file
9
docs/introduction.md
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
# Introduction
|
||||||
|
|
||||||
|
## Overview
|
||||||
|
|
||||||
|
This is a bachelor's thesis project combining IoT, AI and much more.
|
||||||
|
It presents a smart IoT system designed to ingset, store and visualize telemetry data from distributed devices.
|
||||||
|
The project leverages microservices for modularity and good scalability, Redis as a message queue / buffer, and TimescaleDB
|
||||||
|
for fast and efficient time-series data storage.
|
||||||
|
|
||||||
8
frontend/.env.example
Normal file
8
frontend/.env.example
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
# API Configuration
|
||||||
|
VITE_API_URL=/api
|
||||||
|
# Django proxy URL (used for QR code API endpoint)
|
||||||
|
VITE_DEVICE_MANAGER_URL=http://10.10.1.114:3000/api
|
||||||
|
|
||||||
|
# MQTT Configuration
|
||||||
|
VITE_MQTT_BROKER=10.10.1.114
|
||||||
|
VITE_MQTT_PORT=8883
|
||||||
25
frontend/.gitignore
vendored
Normal file
25
frontend/.gitignore
vendored
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
# Logs
|
||||||
|
logs
|
||||||
|
*.log
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
pnpm-debug.log*
|
||||||
|
lerna-debug.log*
|
||||||
|
|
||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
dist-ssr
|
||||||
|
*.local
|
||||||
|
.env
|
||||||
|
|
||||||
|
# Editor directories and files
|
||||||
|
.vscode/*
|
||||||
|
!.vscode/extensions.json
|
||||||
|
.idea
|
||||||
|
.DS_Store
|
||||||
|
*.suo
|
||||||
|
*.ntvs*
|
||||||
|
*.njsproj
|
||||||
|
*.sln
|
||||||
|
*.sw?
|
||||||
73
frontend/README.md
Normal file
73
frontend/README.md
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
# React + TypeScript + Vite
|
||||||
|
|
||||||
|
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
|
||||||
|
|
||||||
|
Currently, two official plugins are available:
|
||||||
|
|
||||||
|
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Babel](https://babeljs.io/) (or [oxc](https://oxc.rs) when used in [rolldown-vite](https://vite.dev/guide/rolldown)) for Fast Refresh
|
||||||
|
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh
|
||||||
|
|
||||||
|
## React Compiler
|
||||||
|
|
||||||
|
The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).
|
||||||
|
|
||||||
|
## Expanding the ESLint configuration
|
||||||
|
|
||||||
|
If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules:
|
||||||
|
|
||||||
|
```js
|
||||||
|
export default defineConfig([
|
||||||
|
globalIgnores(['dist']),
|
||||||
|
{
|
||||||
|
files: ['**/*.{ts,tsx}'],
|
||||||
|
extends: [
|
||||||
|
// Other configs...
|
||||||
|
|
||||||
|
// Remove tseslint.configs.recommended and replace with this
|
||||||
|
tseslint.configs.recommendedTypeChecked,
|
||||||
|
// Alternatively, use this for stricter rules
|
||||||
|
tseslint.configs.strictTypeChecked,
|
||||||
|
// Optionally, add this for stylistic rules
|
||||||
|
tseslint.configs.stylisticTypeChecked,
|
||||||
|
|
||||||
|
// Other configs...
|
||||||
|
],
|
||||||
|
languageOptions: {
|
||||||
|
parserOptions: {
|
||||||
|
project: ['./tsconfig.node.json', './tsconfig.app.json'],
|
||||||
|
tsconfigRootDir: import.meta.dirname,
|
||||||
|
},
|
||||||
|
// other options...
|
||||||
|
},
|
||||||
|
},
|
||||||
|
])
|
||||||
|
```
|
||||||
|
|
||||||
|
You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules:
|
||||||
|
|
||||||
|
```js
|
||||||
|
// eslint.config.js
|
||||||
|
import reactX from 'eslint-plugin-react-x'
|
||||||
|
import reactDom from 'eslint-plugin-react-dom'
|
||||||
|
|
||||||
|
export default defineConfig([
|
||||||
|
globalIgnores(['dist']),
|
||||||
|
{
|
||||||
|
files: ['**/*.{ts,tsx}'],
|
||||||
|
extends: [
|
||||||
|
// Other configs...
|
||||||
|
// Enable lint rules for React
|
||||||
|
reactX.configs['recommended-typescript'],
|
||||||
|
// Enable lint rules for React DOM
|
||||||
|
reactDom.configs.recommended,
|
||||||
|
],
|
||||||
|
languageOptions: {
|
||||||
|
parserOptions: {
|
||||||
|
project: ['./tsconfig.node.json', './tsconfig.app.json'],
|
||||||
|
tsconfigRootDir: import.meta.dirname,
|
||||||
|
},
|
||||||
|
// other options...
|
||||||
|
},
|
||||||
|
},
|
||||||
|
])
|
||||||
|
```
|
||||||
23
frontend/eslint.config.js
Normal file
23
frontend/eslint.config.js
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
import js from '@eslint/js'
|
||||||
|
import globals from 'globals'
|
||||||
|
import reactHooks from 'eslint-plugin-react-hooks'
|
||||||
|
import reactRefresh from 'eslint-plugin-react-refresh'
|
||||||
|
import tseslint from 'typescript-eslint'
|
||||||
|
import { defineConfig, globalIgnores } from 'eslint/config'
|
||||||
|
|
||||||
|
export default defineConfig([
|
||||||
|
globalIgnores(['dist']),
|
||||||
|
{
|
||||||
|
files: ['**/*.{ts,tsx}'],
|
||||||
|
extends: [
|
||||||
|
js.configs.recommended,
|
||||||
|
tseslint.configs.recommended,
|
||||||
|
reactHooks.configs['recommended-latest'],
|
||||||
|
reactRefresh.configs.vite,
|
||||||
|
],
|
||||||
|
languageOptions: {
|
||||||
|
ecmaVersion: 2020,
|
||||||
|
globals: globals.browser,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
])
|
||||||
13
frontend/index.html
Normal file
13
frontend/index.html
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<!-- <link rel="icon" type="image/svg+xml" href="/vite.svg" /> -->
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>iotDashboard</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
5883
frontend/package-lock.json
generated
Normal file
5883
frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
30
frontend/package.json
Normal file
30
frontend/package.json
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
{
|
||||||
|
"name": "frontend",
|
||||||
|
"private": true,
|
||||||
|
"version": "0.0.0",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "tsc -b && vite build",
|
||||||
|
"lint": "eslint .",
|
||||||
|
"preview": "vite preview"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"react": "^19.1.1",
|
||||||
|
"react-dom": "^19.1.1"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@eslint/js": "^9.36.0",
|
||||||
|
"@types/node": "^24.6.0",
|
||||||
|
"@types/react": "^19.1.16",
|
||||||
|
"@types/react-dom": "^19.1.9",
|
||||||
|
"@vitejs/plugin-react": "^5.0.4",
|
||||||
|
"eslint": "^9.36.0",
|
||||||
|
"eslint-plugin-react-hooks": "^5.2.0",
|
||||||
|
"eslint-plugin-react-refresh": "^0.4.22",
|
||||||
|
"globals": "^16.4.0",
|
||||||
|
"typescript": "~5.9.3",
|
||||||
|
"typescript-eslint": "^8.45.0",
|
||||||
|
"vite": "^7.1.7"
|
||||||
|
}
|
||||||
|
}
|
||||||
165
frontend/src/App.css
Normal file
165
frontend/src/App.css
Normal file
@@ -0,0 +1,165 @@
|
|||||||
|
/* Custom styles for the IoT Dashboard */
|
||||||
|
|
||||||
|
#root {
|
||||||
|
width: 100%;
|
||||||
|
min-height: 100vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Custom scrollbar for the drawer */
|
||||||
|
.drawer-side::-webkit-scrollbar {
|
||||||
|
width: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.drawer-side::-webkit-scrollbar-track {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.drawer-side::-webkit-scrollbar-thumb {
|
||||||
|
background: hsl(var(--bc) / 0.2);
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.drawer-side::-webkit-scrollbar-thumb:hover {
|
||||||
|
background: hsl(var(--bc) / 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Smooth transitions for interactive elements */
|
||||||
|
.btn,
|
||||||
|
.card {
|
||||||
|
transition: all 0.2s ease-in-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card:hover {
|
||||||
|
transform: translateY(-2px);
|
||||||
|
box-shadow: 0 8px 16px rgba(0, 0, 0, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Badge animations */
|
||||||
|
.badge {
|
||||||
|
transition: all 0.2s ease-in-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Stats animation on load */
|
||||||
|
@keyframes fadeInUp {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(20px);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.stats {
|
||||||
|
animation: fadeInUp 0.5s ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Responsive table scrolling */
|
||||||
|
.overflow-x-auto {
|
||||||
|
scrollbar-width: thin;
|
||||||
|
scrollbar-color: hsl(var(--bc) / 0.2) transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.overflow-x-auto::-webkit-scrollbar {
|
||||||
|
height: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.overflow-x-auto::-webkit-scrollbar-track {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.overflow-x-auto::-webkit-scrollbar-thumb {
|
||||||
|
background: hsl(var(--bc) / 0.2);
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Loading spinner custom styles */
|
||||||
|
.loading {
|
||||||
|
animation: spin 1s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes spin {
|
||||||
|
from {
|
||||||
|
transform: rotate(0deg);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
transform: rotate(360deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* React Grid Layout Overrides */
|
||||||
|
.react-grid-layout {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.react-grid-item {
|
||||||
|
transition: all 200ms ease;
|
||||||
|
transition-property: left, top, width, height;
|
||||||
|
}
|
||||||
|
|
||||||
|
.react-grid-item img {
|
||||||
|
pointer-events: none;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.react-grid-item > .react-resizable-handle {
|
||||||
|
position: absolute;
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.react-grid-item > .react-resizable-handle::after {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
right: 3px;
|
||||||
|
bottom: 3px;
|
||||||
|
width: 5px;
|
||||||
|
height: 5px;
|
||||||
|
border-right: 2px solid hsl(var(--bc) / 0.4);
|
||||||
|
border-bottom: 2px solid hsl(var(--bc) / 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.react-resizable-hide > .react-resizable-handle {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.react-grid-item > .react-resizable-handle.react-resizable-handle-se {
|
||||||
|
bottom: 0;
|
||||||
|
right: 0;
|
||||||
|
cursor: se-resize;
|
||||||
|
}
|
||||||
|
|
||||||
|
.react-grid-item.resizing {
|
||||||
|
transition: none;
|
||||||
|
z-index: 100;
|
||||||
|
will-change: width, height;
|
||||||
|
}
|
||||||
|
|
||||||
|
.react-grid-item.react-draggable-dragging {
|
||||||
|
transition: none;
|
||||||
|
z-index: 100;
|
||||||
|
will-change: transform;
|
||||||
|
}
|
||||||
|
|
||||||
|
.react-grid-item.dropping {
|
||||||
|
visibility: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.react-grid-item.react-grid-placeholder {
|
||||||
|
background: hsl(var(--p) / 0.2);
|
||||||
|
opacity: 0.2;
|
||||||
|
transition-duration: 100ms;
|
||||||
|
z-index: 2;
|
||||||
|
-webkit-user-select: none;
|
||||||
|
-moz-user-select: none;
|
||||||
|
-ms-user-select: none;
|
||||||
|
-o-user-select: none;
|
||||||
|
user-select: none;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.react-grid-item > .react-resizable-handle::after {
|
||||||
|
border-right: 2px solid hsl(var(--p));
|
||||||
|
border-bottom: 2px solid hsl(var(--p));
|
||||||
|
}
|
||||||
97
frontend/src/App.tsx
Normal file
97
frontend/src/App.tsx
Normal file
@@ -0,0 +1,97 @@
|
|||||||
|
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||||
|
import { BrowserRouter, Routes, Route, Link, NavLink } from 'react-router-dom'
|
||||||
|
import { Toaster } from 'react-hot-toast'
|
||||||
|
import Dashboard from './pages/Dashboard'
|
||||||
|
import DeviceList from './pages/DeviceList'
|
||||||
|
import DeviceDetail from './pages/DeviceDetail'
|
||||||
|
import AddDevice from './pages/AddDevice'
|
||||||
|
import './App.css'
|
||||||
|
|
||||||
|
const queryClient = new QueryClient()
|
||||||
|
|
||||||
|
function AppLayout({ children }: { children: React.ReactNode }) {
|
||||||
|
return (
|
||||||
|
<div className="drawer lg:drawer-open">
|
||||||
|
<input id="main-drawer" type="checkbox" className="drawer-toggle" />
|
||||||
|
<div className="drawer-content flex flex-col">
|
||||||
|
{/* Navbar */}
|
||||||
|
<div className="navbar bg-base-300 lg:hidden">
|
||||||
|
<div className="flex-none">
|
||||||
|
<label htmlFor="main-drawer" className="btn btn-square btn-ghost">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" className="inline-block w-5 h-5 stroke-current">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M4 6h16M4 12h16M4 18h16"></path>
|
||||||
|
</svg>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div className="flex-1">
|
||||||
|
<span className="text-xl font-bold">IoT Dashboard</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Page content */}
|
||||||
|
<main className="flex-1 bg-base-200">
|
||||||
|
{children}
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Sidebar */}
|
||||||
|
<div className="drawer-side">
|
||||||
|
<label htmlFor="main-drawer" className="drawer-overlay"></label>
|
||||||
|
<aside className="bg-base-100 w-64 min-h-full">
|
||||||
|
<div className="p-4">
|
||||||
|
<Link to="/" className="flex items-center gap-2 text-2xl font-bold">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" className="h-8 w-8" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 3v2m6-2v2M9 19v2m6-2v2M5 9H3m2 6H3m18-6h-2m2 6h-2M7 19h10a2 2 0 002-2V7a2 2 0 00-2-2H7a2 2 0 00-2 2v10a2 2 0 002 2zM9 9h6v6H9V9z" />
|
||||||
|
</svg>
|
||||||
|
<span>IoT Dashboard</span>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ul className="menu p-4 space-y-2">
|
||||||
|
<li>
|
||||||
|
<NavLink
|
||||||
|
to="/"
|
||||||
|
className={({ isActive }) => isActive ? 'active' : ''}
|
||||||
|
end
|
||||||
|
>
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6" />
|
||||||
|
</svg>
|
||||||
|
Dashboard
|
||||||
|
</NavLink>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<NavLink
|
||||||
|
to="/devices"
|
||||||
|
className={({ isActive }) => isActive ? 'active' : ''}
|
||||||
|
>
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 3v2m6-2v2M9 19v2m6-2v2M5 9H3m2 6H3m18-6h-2m2 6h-2M7 19h10a2 2 0 002-2V7a2 2 0 00-2-2H7a2 2 0 00-2 2v10a2 2 0 002 2zM9 9h6v6H9V9z" />
|
||||||
|
</svg>
|
||||||
|
Devices
|
||||||
|
</NavLink>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</aside>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function App() {
|
||||||
|
return (
|
||||||
|
<QueryClientProvider client={queryClient}>
|
||||||
|
<BrowserRouter>
|
||||||
|
<Toaster position="top-right" />
|
||||||
|
<Routes>
|
||||||
|
<Route path="/" element={<AppLayout><Dashboard /></AppLayout>} />
|
||||||
|
<Route path="/devices" element={<AppLayout><DeviceList /></AppLayout>} />
|
||||||
|
<Route path="/devices/add" element={<AppLayout><AddDevice /></AppLayout>} />
|
||||||
|
<Route path="/devices/:id" element={<AppLayout><DeviceDetail /></AppLayout>} />
|
||||||
|
</Routes>
|
||||||
|
</BrowserRouter>
|
||||||
|
</QueryClientProvider>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default App
|
||||||
402
frontend/src/App.tsx.bak
Normal file
402
frontend/src/App.tsx.bak
Normal file
@@ -0,0 +1,402 @@
|
|||||||
|
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||||
|
import { BrowserRouter, Routes, Route, Link, NavLink } from 'react-router-dom'
|
||||||
|
import { Toaster } from 'react-hot-toast'
|
||||||
|
import Dashboard from './pages/Dashboard'
|
||||||
|
import DeviceList from './pages/DeviceList'
|
||||||
|
import DeviceDetail from './pages/DeviceDetail'
|
||||||
|
import AddDevice from './pages/AddDevice'
|
||||||
|
import './App.css'
|
||||||
|
|
||||||
|
const queryClient = new QueryClient()
|
||||||
|
|
||||||
|
function AppLayout({ children }: { children: React.ReactNode }) {
|
||||||
|
return (
|
||||||
|
<div className="drawer lg:drawer-open">
|
||||||
|
<input id="main-drawer" type="checkbox" className="drawer-toggle" />
|
||||||
|
<div className="drawer-content flex flex-col">
|
||||||
|
{/* Navbar */}
|
||||||
|
<div className="navbar bg-base-300 lg:hidden">
|
||||||
|
<div className="flex-none">
|
||||||
|
<label htmlFor="main-drawer" className="btn btn-square btn-ghost">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" className="inline-block w-5 h-5 stroke-current">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M4 6h16M4 12h16M4 18h16"></path>
|
||||||
|
</svg>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div className="flex-1">
|
||||||
|
<span className="text-xl font-bold">IoT Dashboard</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Page content */}
|
||||||
|
<main className="flex-1 bg-base-200">
|
||||||
|
{children}
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Sidebar */}
|
||||||
|
<div className="drawer-side">
|
||||||
|
<label htmlFor="main-drawer" className="drawer-overlay"></label>
|
||||||
|
<aside className="bg-base-100 w-64 min-h-full">
|
||||||
|
<div className="p-4">
|
||||||
|
<Link to="/" className="flex items-center gap-2 text-2xl font-bold">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" className="h-8 w-8" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 3v2m6-2v2M9 19v2m6-2v2M5 9H3m2 6H3m18-6h-2m2 6h-2M7 19h10a2 2 0 002-2V7a2 2 0 00-2-2H7a2 2 0 00-2 2v10a2 2 0 002 2zM9 9h6v6H9V9z" />
|
||||||
|
</svg>
|
||||||
|
<span>IoT Dashboard</span>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ul className="menu p-4 space-y-2">
|
||||||
|
<li>
|
||||||
|
<NavLink
|
||||||
|
to="/"
|
||||||
|
className={({ isActive }) => isActive ? 'active' : ''}
|
||||||
|
end
|
||||||
|
>
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6" />
|
||||||
|
</svg>
|
||||||
|
Dashboard
|
||||||
|
</NavLink>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<NavLink
|
||||||
|
to="/devices"
|
||||||
|
className={({ isActive }) => isActive ? 'active' : ''}
|
||||||
|
>
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 3v2m6-2v2M9 19v2m6-2v2M5 9H3m2 6H3m18-6h-2m2 6h-2M7 19h10a2 2 0 002-2V7a2 2 0 00-2-2H7a2 2 0 00-2 2v10a2 2 0 002 2zM9 9h6v6H9V9z" />
|
||||||
|
</svg>
|
||||||
|
Devices
|
||||||
|
</NavLink>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</aside>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function App() {
|
||||||
|
queryKey: ['dashboard'],
|
||||||
|
queryFn: async () => {
|
||||||
|
const response = await dashboardApi.getOverview()
|
||||||
|
return response.data
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const { data: devices, isLoading: devicesLoading } = useQuery({
|
||||||
|
queryKey: ['devices'],
|
||||||
|
queryFn: async () => {
|
||||||
|
const response = await devicesApi.getAll()
|
||||||
|
return response.data
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
if (overviewLoading || devicesLoading) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center min-h-screen">
|
||||||
|
<span className="loading loading-spinner loading-lg text-primary"></span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="drawer lg:drawer-open">
|
||||||
|
<input id="drawer" type="checkbox" className="drawer-toggle" />
|
||||||
|
|
||||||
|
<div className="drawer-content flex flex-col">
|
||||||
|
{/* Navbar */}
|
||||||
|
<div className="navbar bg-base-100 shadow-lg">
|
||||||
|
<div className="flex-none lg:hidden">
|
||||||
|
<label htmlFor="drawer" className="btn btn-square btn-ghost">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" className="inline-block w-6 h-6 stroke-current">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M4 6h16M4 12h16M4 18h16"></path>
|
||||||
|
</svg>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div className="flex-1">
|
||||||
|
<h1 className="text-2xl font-bold ml-2">IoT Dashboard</h1>
|
||||||
|
</div>
|
||||||
|
<div className="flex-none gap-2">
|
||||||
|
<div className="dropdown dropdown-end">
|
||||||
|
<div tabIndex={0} role="button" className="btn btn-ghost btn-circle avatar">
|
||||||
|
<div className="w-10 rounded-full bg-primary text-primary-content flex items-center justify-center">
|
||||||
|
<span className="text-xl">U</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Main Content */}
|
||||||
|
<div className="p-4 md:p-8">
|
||||||
|
{/* Breadcrumbs */}
|
||||||
|
<div className="text-sm breadcrumbs mb-4">
|
||||||
|
<ul>
|
||||||
|
<li><a>Home</a></li>
|
||||||
|
<li>Dashboard</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Page Header */}
|
||||||
|
<div className="mb-6">
|
||||||
|
<h2 className="text-3xl font-bold">Dashboard Overview</h2>
|
||||||
|
<p className="text-base-content/70 mt-1">
|
||||||
|
Office Environment Intelligence Platform
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Stats Cards */}
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4 mb-8">
|
||||||
|
<div className="stats shadow">
|
||||||
|
<div className="stat">
|
||||||
|
<div className="stat-figure text-primary">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" className="inline-block w-8 h-8 stroke-current">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div className="stat-title">Total Devices</div>
|
||||||
|
<div className="stat-value text-primary">{overview?.total_devices || 0}</div>
|
||||||
|
<div className="stat-desc">Registered in system</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="stats shadow">
|
||||||
|
<div className="stat">
|
||||||
|
<div className="stat-figure text-success">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" className="inline-block w-8 h-8 stroke-current">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div className="stat-title">Active Devices</div>
|
||||||
|
<div className="stat-value text-success">{overview?.active_devices || 0}</div>
|
||||||
|
<div className="stat-desc">Currently online</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="stats shadow">
|
||||||
|
<div className="stat">
|
||||||
|
<div className="stat-figure text-secondary">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" className="inline-block w-8 h-8 stroke-current">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M8.111 16.404a5.5 5.5 0 017.778 0M12 20h.01m-7.08-7.071c3.904-3.905 10.236-3.905 14.141 0M1.394 9.393c5.857-5.857 15.355-5.857 21.213 0" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div className="stat-title">MQTT Devices</div>
|
||||||
|
<div className="stat-value text-secondary">{overview?.mqtt_devices || 0}</div>
|
||||||
|
<div className="stat-desc">Using mTLS</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="stats shadow">
|
||||||
|
<div className="stat">
|
||||||
|
<div className="stat-figure text-warning">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" className="inline-block w-8 h-8 stroke-current">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div className="stat-title">Expiring Soon</div>
|
||||||
|
<div className="stat-value text-warning">{overview?.certificates_expiring_soon || 0}</div>
|
||||||
|
<div className="stat-desc">Certificates need renewal</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Devices Section */}
|
||||||
|
<div className="card bg-base-100 shadow-xl">
|
||||||
|
<div className="card-body">
|
||||||
|
<div className="flex justify-between items-center mb-4">
|
||||||
|
<h2 className="card-title text-2xl">Devices</h2>
|
||||||
|
<button className="btn btn-primary btn-sm">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M12 4v16m8-8H4" />
|
||||||
|
</svg>
|
||||||
|
Add Device
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{devices && devices.length > 0 ? (
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||||
|
{devices.map((device: Device) => (
|
||||||
|
<div key={device.id} className="card bg-base-200 shadow-md hover:shadow-xl transition-shadow">
|
||||||
|
<div className="card-body">
|
||||||
|
<div className="flex justify-between items-start">
|
||||||
|
<h3 className="card-title text-lg">{device.name}</h3>
|
||||||
|
<div className={`badge ${device.is_active ? 'badge-success' : 'badge-ghost'}`}>
|
||||||
|
{device.is_active ? 'Active' : 'Inactive'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2 text-sm">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="font-semibold">ID:</span>
|
||||||
|
<code className="bg-base-300 px-2 py-1 rounded">{device.id}</code>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="font-semibold">Protocol:</span>
|
||||||
|
<div className="badge badge-outline">{device.protocol.toUpperCase()}</div>
|
||||||
|
</div>
|
||||||
|
{device.location && (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="font-semibold">Location:</span>
|
||||||
|
<span>{device.location}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{device.certificate_status && (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="font-semibold">Certificate:</span>
|
||||||
|
<div className={`badge ${
|
||||||
|
device.certificate_status === 'Valid' ? 'badge-success' :
|
||||||
|
device.certificate_status === 'Expiring Soon' ? 'badge-warning' :
|
||||||
|
'badge-error'
|
||||||
|
}`}>
|
||||||
|
{device.certificate_status}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="card-actions justify-end mt-4">
|
||||||
|
<button className="btn btn-sm btn-ghost">View</button>
|
||||||
|
<button className="btn btn-sm btn-primary">Manage</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="alert">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" className="stroke-info shrink-0 w-6 h-6">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"></path>
|
||||||
|
</svg>
|
||||||
|
<span>No devices registered yet. Add your first device to get started!</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Recent Telemetry */}
|
||||||
|
{overview && overview.recent_telemetry.length > 0 && (
|
||||||
|
<div className="card bg-base-100 shadow-xl mt-8">
|
||||||
|
<div className="card-body">
|
||||||
|
<h2 className="card-title text-2xl mb-4">Recent Telemetry</h2>
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="table table-zebra">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Device</th>
|
||||||
|
<th>Metric</th>
|
||||||
|
<th>Value</th>
|
||||||
|
<th>Time</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{overview.recent_telemetry.map((t: { device_name: string; device_id: string; metric: string; value: number; unit?: string; time: string }, idx: number) => (
|
||||||
|
<tr key={idx} className="hover">
|
||||||
|
<td>
|
||||||
|
<div className="font-bold">{t.device_name}</div>
|
||||||
|
<div className="text-sm opacity-50">{t.device_id}</div>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<div className="badge badge-ghost">{t.metric}</div>
|
||||||
|
</td>
|
||||||
|
<td className="font-mono font-semibold">
|
||||||
|
{t.value} {t.unit || ''}
|
||||||
|
</td>
|
||||||
|
<td className="text-sm opacity-70">
|
||||||
|
{new Date(t.time).toLocaleString()}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Drawer Sidebar */}
|
||||||
|
<div className="drawer-side z-10">
|
||||||
|
<label htmlFor="drawer" className="drawer-overlay"></label>
|
||||||
|
<aside className="bg-base-200 w-64 min-h-screen">
|
||||||
|
<div className="p-4">
|
||||||
|
<h2 className="text-xl font-bold mb-4">IoT Dashboard</h2>
|
||||||
|
</div>
|
||||||
|
<ul className="menu p-4 text-base-content">
|
||||||
|
<li>
|
||||||
|
<a className="active">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6" />
|
||||||
|
</svg>
|
||||||
|
Dashboard
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a>
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z" />
|
||||||
|
</svg>
|
||||||
|
Devices
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a>
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z" />
|
||||||
|
</svg>
|
||||||
|
Analytics
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a>
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M8 10h.01M12 10h.01M16 10h.01M9 16H5a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v8a2 2 0 01-2 2h-5l-5 5v-5z" />
|
||||||
|
</svg>
|
||||||
|
AI Assistant
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li className="menu-title">
|
||||||
|
<span>Management</span>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a>
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
|
||||||
|
</svg>
|
||||||
|
Reports
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a>
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" />
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||||
|
</svg>
|
||||||
|
Settings
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</aside>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function App() {
|
||||||
|
return (
|
||||||
|
<QueryClientProvider client={queryClient}>
|
||||||
|
<BrowserRouter>
|
||||||
|
<Toaster position="top-right" />
|
||||||
|
<Routes>
|
||||||
|
<Route path="/" element={<AppLayout><Dashboard /></AppLayout>} />
|
||||||
|
<Route path="/devices" element={<AppLayout><DeviceList /></AppLayout>} />
|
||||||
|
<Route path="/devices/add" element={<AppLayout><AddDevice /></AppLayout>} />
|
||||||
|
<Route path="/devices/:id" element={<AppLayout><DeviceDetail /></AppLayout>} />
|
||||||
|
</Routes>
|
||||||
|
</BrowserRouter>
|
||||||
|
</QueryClientProvider>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default App
|
||||||
148
frontend/src/api/index.ts
Normal file
148
frontend/src/api/index.ts
Normal file
@@ -0,0 +1,148 @@
|
|||||||
|
import apiClient from '../lib/api-client';
|
||||||
|
import type {
|
||||||
|
Device,
|
||||||
|
DeviceRegistrationRequest,
|
||||||
|
DeviceRegistrationResponse,
|
||||||
|
Telemetry,
|
||||||
|
DashboardOverview,
|
||||||
|
} from '../types/api';
|
||||||
|
|
||||||
|
// Paginated response type from Django REST Framework
|
||||||
|
interface PaginatedResponse<T> {
|
||||||
|
count: number;
|
||||||
|
next: string | null;
|
||||||
|
previous: string | null;
|
||||||
|
results: T[];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Device API
|
||||||
|
export const devicesApi = {
|
||||||
|
getAll: async () => {
|
||||||
|
const response = await apiClient.get<Device[] | PaginatedResponse<Device>>('/devices/');
|
||||||
|
// Handle both paginated and non-paginated responses
|
||||||
|
if (Array.isArray(response.data)) {
|
||||||
|
// Non-paginated response - wrap it
|
||||||
|
return {
|
||||||
|
...response,
|
||||||
|
data: {
|
||||||
|
count: response.data.length,
|
||||||
|
next: null,
|
||||||
|
previous: null,
|
||||||
|
results: response.data,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
// Already paginated
|
||||||
|
return response as typeof response & { data: PaginatedResponse<Device> };
|
||||||
|
},
|
||||||
|
|
||||||
|
getOne: (id: string) => apiClient.get<Device>(`/devices/${id}/`),
|
||||||
|
|
||||||
|
create: (data: DeviceRegistrationRequest) =>
|
||||||
|
apiClient.post<DeviceRegistrationResponse>('/devices/', data),
|
||||||
|
|
||||||
|
delete: (id: string) => apiClient.delete(`/devices/${id}/`),
|
||||||
|
|
||||||
|
revoke: (id: string) => apiClient.post(`/devices/${id}/revoke/`),
|
||||||
|
|
||||||
|
renew: (id: string) =>
|
||||||
|
apiClient.post<DeviceRegistrationResponse>(`/devices/${id}/renew/`),
|
||||||
|
|
||||||
|
getTelemetry: (id: string, params?: {
|
||||||
|
metric?: string;
|
||||||
|
hours?: number;
|
||||||
|
limit?: number;
|
||||||
|
}) => apiClient.get<Telemetry[]>(`/devices/${id}/telemetry/`, { params }),
|
||||||
|
|
||||||
|
getMetrics: (id: string) =>
|
||||||
|
apiClient.get<{ device_id: string; device_name: string; metrics: string[] }>(
|
||||||
|
`/devices/${id}/metrics/`
|
||||||
|
),
|
||||||
|
|
||||||
|
getComfortIndex: (id: string) =>
|
||||||
|
apiClient.get<{
|
||||||
|
device_id: string;
|
||||||
|
device_name: string;
|
||||||
|
overall_score: number;
|
||||||
|
rating: string;
|
||||||
|
components: {
|
||||||
|
temperature: number;
|
||||||
|
humidity: number;
|
||||||
|
air_quality: number;
|
||||||
|
acoustic: number;
|
||||||
|
light: number;
|
||||||
|
};
|
||||||
|
suggestions: string[];
|
||||||
|
raw_readings: Record<string, number>;
|
||||||
|
}>(`/devices/${id}/comfort_index/`),
|
||||||
|
};
|
||||||
|
|
||||||
|
// Telemetry API
|
||||||
|
export const telemetryApi = {
|
||||||
|
query: (params?: {
|
||||||
|
device_id?: string;
|
||||||
|
metric?: string;
|
||||||
|
hours?: number;
|
||||||
|
start_time?: string;
|
||||||
|
end_time?: string;
|
||||||
|
page_size?: number;
|
||||||
|
page?: number;
|
||||||
|
}) => apiClient.get<PaginatedResponse<Telemetry>>('/telemetry/', { params }),
|
||||||
|
|
||||||
|
getLatest: (params?: { limit?: number }) =>
|
||||||
|
apiClient.get<PaginatedResponse<Telemetry>>('/telemetry/latest/', { params }),
|
||||||
|
|
||||||
|
getMetrics: () => apiClient.get<{ metrics: string[] }>('/telemetry/metrics/'),
|
||||||
|
|
||||||
|
analyze: (data: {
|
||||||
|
device_id: string;
|
||||||
|
metric?: string;
|
||||||
|
hours?: number;
|
||||||
|
limit?: number;
|
||||||
|
prompt_type?: 'anomaly_detection' | 'trend_summary' | 'custom';
|
||||||
|
custom_prompt?: string;
|
||||||
|
}) => apiClient.post<{
|
||||||
|
analysis: string;
|
||||||
|
prompt_type: string;
|
||||||
|
data_points_analyzed: number;
|
||||||
|
}>('/telemetry/analyze/', data),
|
||||||
|
};
|
||||||
|
|
||||||
|
// Dashboard API
|
||||||
|
export const dashboardApi = {
|
||||||
|
getOverview: () => apiClient.get<DashboardOverview>('/dashboard/overview/'),
|
||||||
|
};
|
||||||
|
|
||||||
|
// Weather API
|
||||||
|
export const weatherApi = {
|
||||||
|
getCurrent: (params: { city?: string; lat?: number; lon?: number }) =>
|
||||||
|
apiClient.get<{
|
||||||
|
location: string;
|
||||||
|
temperature: number;
|
||||||
|
apparent_temperature: number;
|
||||||
|
humidity: number;
|
||||||
|
weather_description: string;
|
||||||
|
weather_code: number;
|
||||||
|
precipitation: number;
|
||||||
|
rain: number;
|
||||||
|
cloud_cover: number;
|
||||||
|
wind_speed: number;
|
||||||
|
wind_direction: number;
|
||||||
|
time: string;
|
||||||
|
timezone: string;
|
||||||
|
}>('/weather/current/', { params }),
|
||||||
|
|
||||||
|
getAirQuality: (city: string) =>
|
||||||
|
apiClient.get<{
|
||||||
|
city: string;
|
||||||
|
measurements: Record<string, {
|
||||||
|
average: number;
|
||||||
|
min: number;
|
||||||
|
max: number;
|
||||||
|
count: number;
|
||||||
|
}>;
|
||||||
|
status: string;
|
||||||
|
timestamp: string;
|
||||||
|
sensor_count: number;
|
||||||
|
}>('/weather/air_quality/', { params: { city } }),
|
||||||
|
};
|
||||||
1
frontend/src/assets/react.svg
Normal file
1
frontend/src/assets/react.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>
|
||||||
|
After Width: | Height: | Size: 4.0 KiB |
501
frontend/src/components/AddWidgetModal.tsx
Normal file
501
frontend/src/components/AddWidgetModal.tsx
Normal file
@@ -0,0 +1,501 @@
|
|||||||
|
import { useState, useEffect } from 'react'
|
||||||
|
import { useQuery } from '@tanstack/react-query'
|
||||||
|
import { devicesApi } from '../api'
|
||||||
|
import type { WidgetType, WidgetConfig } from '../hooks'
|
||||||
|
import { createDefaultWidgetTitle } from '../utils/formatters'
|
||||||
|
|
||||||
|
interface AddWidgetModalProps {
|
||||||
|
isOpen: boolean
|
||||||
|
onClose: () => void
|
||||||
|
onAdd: (widget: WidgetConfig) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function AddWidgetModal({ isOpen, onClose, onAdd }: AddWidgetModalProps) {
|
||||||
|
const [step, setStep] = useState(1)
|
||||||
|
const [widgetType, setWidgetType] = useState<WidgetType>('stat')
|
||||||
|
const [title, setTitle] = useState('')
|
||||||
|
const [selectedDevices, setSelectedDevices] = useState<string[]>([])
|
||||||
|
const [selectedMetrics, setSelectedMetrics] = useState<string[]>([])
|
||||||
|
const [timeframeHours, setTimeframeHours] = useState(24)
|
||||||
|
const [widgetWidth, setWidgetWidth] = useState(1)
|
||||||
|
const [widgetHeight, setWidgetHeight] = useState(2)
|
||||||
|
const [city, setCity] = useState('Skopje')
|
||||||
|
|
||||||
|
// Fetch devices
|
||||||
|
const { data: devicesData } = useQuery({
|
||||||
|
queryKey: ['devices'],
|
||||||
|
queryFn: async () => {
|
||||||
|
const response = await devicesApi.getAll()
|
||||||
|
return response.data
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
// Fetch metrics for selected device(s)
|
||||||
|
const { data: deviceMetricsData } = useQuery({
|
||||||
|
queryKey: ['device-metrics', selectedDevices[0]],
|
||||||
|
queryFn: async () => {
|
||||||
|
if (selectedDevices.length === 0) return []
|
||||||
|
const response = await devicesApi.getMetrics(selectedDevices[0])
|
||||||
|
return response.data.metrics
|
||||||
|
},
|
||||||
|
enabled: selectedDevices.length > 0,
|
||||||
|
})
|
||||||
|
|
||||||
|
const devices = devicesData?.results || []
|
||||||
|
const availableMetrics = deviceMetricsData || []
|
||||||
|
|
||||||
|
// Reset form when modal opens
|
||||||
|
useEffect(() => {
|
||||||
|
if (isOpen) {
|
||||||
|
setStep(1)
|
||||||
|
setWidgetType('stat')
|
||||||
|
setTitle('')
|
||||||
|
setSelectedDevices([])
|
||||||
|
setSelectedMetrics([])
|
||||||
|
setTimeframeHours(24)
|
||||||
|
setWidgetWidth(1)
|
||||||
|
setWidgetHeight(2)
|
||||||
|
}
|
||||||
|
}, [isOpen])
|
||||||
|
|
||||||
|
// Reset metrics when device changes
|
||||||
|
useEffect(() => {
|
||||||
|
setSelectedMetrics([])
|
||||||
|
}, [selectedDevices])
|
||||||
|
|
||||||
|
const handleSubmit = () => {
|
||||||
|
// Weather and air-quality widgets don't need device/metric validation
|
||||||
|
if (widgetType !== 'weather' && widgetType !== 'air-quality') {
|
||||||
|
if (selectedDevices.length === 0 || selectedMetrics.length === 0) {
|
||||||
|
alert('Please select at least one device and one metric')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create title
|
||||||
|
let defaultTitle = ''
|
||||||
|
if (widgetType === 'weather') {
|
||||||
|
defaultTitle = `Weather - ${city}`
|
||||||
|
} else if (widgetType === 'air-quality') {
|
||||||
|
defaultTitle = `Air Quality - ${city}`
|
||||||
|
} else {
|
||||||
|
const selectedDevice = devices.find(d => d.id === selectedDevices[0])
|
||||||
|
defaultTitle = createDefaultWidgetTitle(widgetType, selectedDevice?.name, selectedMetrics)
|
||||||
|
}
|
||||||
|
|
||||||
|
const newWidget: WidgetConfig = {
|
||||||
|
id: `widget-${Date.now()}`,
|
||||||
|
type: widgetType,
|
||||||
|
title: title || defaultTitle,
|
||||||
|
deviceIds: widgetType === 'weather' || widgetType === 'air-quality' ? [] : selectedDevices,
|
||||||
|
metricIds: widgetType === 'weather' || widgetType === 'air-quality' ? [] : selectedMetrics,
|
||||||
|
timeframe: {
|
||||||
|
hours: timeframeHours,
|
||||||
|
},
|
||||||
|
visualization: {
|
||||||
|
showLegend: true,
|
||||||
|
showGrid: true,
|
||||||
|
height: widgetType === 'line-chart' ? 300 : undefined,
|
||||||
|
city: widgetType === 'weather' || widgetType === 'air-quality' ? city : undefined,
|
||||||
|
},
|
||||||
|
position: {
|
||||||
|
x: 0,
|
||||||
|
y: 0,
|
||||||
|
w: widgetWidth,
|
||||||
|
h: widgetHeight,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
onAdd(newWidget)
|
||||||
|
onClose()
|
||||||
|
}
|
||||||
|
|
||||||
|
const toggleDevice = (deviceId: string) => {
|
||||||
|
// Only allow single device selection for better UX
|
||||||
|
setSelectedDevices([deviceId])
|
||||||
|
}
|
||||||
|
|
||||||
|
const toggleMetric = (metric: string) => {
|
||||||
|
// Stat and gauge widgets only allow one metric
|
||||||
|
const singleMetricWidgets = ['stat', 'gauge']
|
||||||
|
const maxMetrics = singleMetricWidgets.includes(widgetType) ? 1 : 5
|
||||||
|
|
||||||
|
setSelectedMetrics((prev) => {
|
||||||
|
if (prev.includes(metric)) {
|
||||||
|
return prev.filter((m) => m !== metric)
|
||||||
|
}
|
||||||
|
|
||||||
|
// If adding would exceed max, replace last or prevent
|
||||||
|
if (prev.length >= maxMetrics) {
|
||||||
|
if (maxMetrics === 1) {
|
||||||
|
return [metric] // Replace for single-metric widgets
|
||||||
|
}
|
||||||
|
return prev // Don't add more for multi-metric widgets
|
||||||
|
}
|
||||||
|
|
||||||
|
return [...prev, metric]
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isOpen) return null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="modal modal-open">
|
||||||
|
<div className="modal-box max-w-2xl">
|
||||||
|
<h3 className="font-bold text-lg mb-4">Add Widget</h3>
|
||||||
|
|
||||||
|
{/* Progress steps */}
|
||||||
|
<ul className="steps w-full mb-6">
|
||||||
|
<li className={`step ${step >= 1 ? 'step-primary' : ''}`}>Type</li>
|
||||||
|
<li className={`step ${step >= 2 ? 'step-primary' : ''}`}>Data Source</li>
|
||||||
|
<li className={`step ${step >= 3 ? 'step-primary' : ''}`}>Configure</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
{/* Step 1: Widget Type */}
|
||||||
|
{step === 1 && (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="form-control">
|
||||||
|
<label className="label">
|
||||||
|
<span className="label-text font-semibold">Select Widget Type</span>
|
||||||
|
</label>
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<button
|
||||||
|
className={`btn ${widgetType === 'stat' ? 'btn-primary' : 'btn-outline'} justify-start`}
|
||||||
|
onClick={() => setWidgetType('stat')}
|
||||||
|
>
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M7 12l3-3 3 3 4-4M8 21l4-4 4 4M3 4h18M4 4h16v12a1 1 0 01-1 1H5a1 1 0 01-1-1V4z" />
|
||||||
|
</svg>
|
||||||
|
<div className="text-left">
|
||||||
|
<div className="font-semibold">Stat Card</div>
|
||||||
|
<div className="text-xs opacity-70">Min/Max/Avg</div>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
className={`btn ${widgetType === 'line-chart' ? 'btn-primary' : 'btn-outline'} justify-start`}
|
||||||
|
onClick={() => setWidgetType('line-chart')}
|
||||||
|
>
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z" />
|
||||||
|
</svg>
|
||||||
|
<div className="text-left">
|
||||||
|
<div className="font-semibold">Line Chart</div>
|
||||||
|
<div className="text-xs opacity-70">Time series</div>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
className={`btn ${widgetType === 'gauge' ? 'btn-primary' : 'btn-outline'} justify-start`}
|
||||||
|
onClick={() => setWidgetType('gauge')}
|
||||||
|
>
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||||
|
</svg>
|
||||||
|
<div className="text-left">
|
||||||
|
<div className="font-semibold">Gauge</div>
|
||||||
|
<div className="text-xs opacity-70">Circular meter</div>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
className={`btn ${widgetType === 'ai-insight' ? 'btn-primary' : 'btn-outline'} justify-start`}
|
||||||
|
onClick={() => setWidgetType('ai-insight')}
|
||||||
|
>
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9.663 17h4.673M12 3v1m6.364 1.636l-.707.707M21 12h-1M4 12H3m3.343-5.657l-.707-.707m2.828 9.9a5 5 0 117.072 0l-.548.547A3.374 3.374 0 0014 18.469V19a2 2 0 11-4 0v-.531c0-.895-.356-1.754-.988-2.386l-.548-.547z" />
|
||||||
|
</svg>
|
||||||
|
<div className="text-left">
|
||||||
|
<div className="font-semibold">AI Insight</div>
|
||||||
|
<div className="text-xs opacity-70">GPT analysis</div>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
className={`btn ${widgetType === 'weather' ? 'btn-primary' : 'btn-outline'} justify-start`}
|
||||||
|
onClick={() => setWidgetType('weather')}
|
||||||
|
>
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 15a4 4 0 004 4h9a5 5 0 10-.1-9.999 5.002 5.002 0 10-9.78 2.096A4.001 4.001 0 003 15z" />
|
||||||
|
</svg>
|
||||||
|
<div className="text-left">
|
||||||
|
<div className="font-semibold">Weather</div>
|
||||||
|
<div className="text-xs opacity-70">Open-Meteo</div>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
className={`btn ${widgetType === 'air-quality' ? 'btn-primary' : 'btn-outline'} justify-start`}
|
||||||
|
onClick={() => setWidgetType('air-quality')}
|
||||||
|
>
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 15a4 4 0 004 4h9a5 5 0 10-.1-9.999 5.002 5.002 0 10-9.78 2.096A4.001 4.001 0 003 15z" />
|
||||||
|
</svg>
|
||||||
|
<div className="text-left">
|
||||||
|
<div className="font-semibold">Air Quality</div>
|
||||||
|
<div className="text-xs opacity-70">Pulse.eco</div>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
className={`btn ${widgetType === 'comfort-index' ? 'btn-primary' : 'btn-outline'} justify-start`}
|
||||||
|
onClick={() => setWidgetType('comfort-index')}
|
||||||
|
>
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M14.828 14.828a4 4 0 01-5.656 0M9 10h.01M15 10h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||||
|
</svg>
|
||||||
|
<div className="text-left">
|
||||||
|
<div className="font-semibold">Comfort Index</div>
|
||||||
|
<div className="text-xs opacity-70">Multi-factor</div>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="modal-action">
|
||||||
|
<button className="btn btn-ghost" onClick={onClose}>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="btn btn-primary"
|
||||||
|
onClick={() => {
|
||||||
|
// Skip data source step for weather and air quality widgets
|
||||||
|
if (widgetType === 'weather' || widgetType === 'air-quality') {
|
||||||
|
setStep(3)
|
||||||
|
} else {
|
||||||
|
setStep(2)
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
Next
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Step 2: Data Source */}
|
||||||
|
{step === 2 && widgetType !== 'weather' && widgetType !== 'air-quality' && (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="form-control">
|
||||||
|
<label className="label">
|
||||||
|
<span className="label-text font-semibold">Select Device</span>
|
||||||
|
<span className="label-text-alt">{selectedDevices.length} selected</span>
|
||||||
|
</label>
|
||||||
|
<div className="border rounded-lg p-2 max-h-48 overflow-y-auto space-y-1">
|
||||||
|
{devices.length === 0 ? (
|
||||||
|
<div className="text-center text-base-content/60 py-4">No devices found</div>
|
||||||
|
) : (
|
||||||
|
devices.map((device) => (
|
||||||
|
<label key={device.id} className="flex items-center gap-2 p-2 hover:bg-base-200 rounded cursor-pointer">
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
name="device"
|
||||||
|
className="radio radio-sm"
|
||||||
|
checked={selectedDevices.includes(device.id)}
|
||||||
|
onChange={() => toggleDevice(device.id)}
|
||||||
|
/>
|
||||||
|
<div className="flex-1">
|
||||||
|
<div className="font-medium">{device.name}</div>
|
||||||
|
<div className="text-xs text-base-content/60">{device.location || 'No location'}</div>
|
||||||
|
</div>
|
||||||
|
<div className="badge badge-sm">{device.protocol}</div>
|
||||||
|
</label>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="form-control">
|
||||||
|
<label className="label">
|
||||||
|
<span className="label-text font-semibold">Select Metric(s)</span>
|
||||||
|
<span className="label-text-alt">{selectedMetrics.length} selected</span>
|
||||||
|
</label>
|
||||||
|
{selectedDevices.length === 0 ? (
|
||||||
|
<div className="border rounded-lg p-4 text-center text-base-content/60">
|
||||||
|
Please select a device first
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{(['stat', 'gauge'].includes(widgetType)) && (
|
||||||
|
<div className="alert alert-info mb-2">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" className="stroke-current shrink-0 w-5 h-5">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"></path>
|
||||||
|
</svg>
|
||||||
|
<span className="text-sm">This widget type supports only one metric</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="border rounded-lg p-2 max-h-48 overflow-y-auto space-y-1">
|
||||||
|
{availableMetrics.length === 0 ? (
|
||||||
|
<div className="text-center text-base-content/60 py-4">
|
||||||
|
No metrics found for this device
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
availableMetrics.map((metric) => (
|
||||||
|
<label key={metric} className="flex items-center gap-2 p-2 hover:bg-base-200 rounded cursor-pointer">
|
||||||
|
<input
|
||||||
|
type={(['stat', 'gauge'].includes(widgetType)) ? 'radio' : 'checkbox'}
|
||||||
|
name={(['stat', 'gauge'].includes(widgetType)) ? 'single-metric' : undefined}
|
||||||
|
className={(['stat', 'gauge'].includes(widgetType)) ? 'radio radio-sm' : 'checkbox checkbox-sm'}
|
||||||
|
checked={selectedMetrics.includes(metric)}
|
||||||
|
onChange={() => toggleMetric(metric)}
|
||||||
|
/>
|
||||||
|
<span className="capitalize">{metric.replace(/_/g, ' ')}</span>
|
||||||
|
</label>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="modal-action">
|
||||||
|
<button className="btn btn-ghost" onClick={() => setStep(1)}>
|
||||||
|
Back
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="btn btn-primary"
|
||||||
|
onClick={() => setStep(3)}
|
||||||
|
disabled={selectedDevices.length === 0 || selectedMetrics.length === 0}
|
||||||
|
>
|
||||||
|
Next
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Step 3: Configure */}
|
||||||
|
{step === 3 && (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* City input for weather and air-quality widgets */}
|
||||||
|
{(widgetType === 'weather' || widgetType === 'air-quality') ? (
|
||||||
|
<>
|
||||||
|
<div className="form-control">
|
||||||
|
<label className="label">
|
||||||
|
<span className="label-text font-semibold">City</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className="input input-bordered"
|
||||||
|
placeholder="Enter city name (e.g., Skopje)"
|
||||||
|
value={city}
|
||||||
|
onChange={(e) => setCity(e.target.value)}
|
||||||
|
/>
|
||||||
|
<label className="label">
|
||||||
|
<span className="label-text-alt">
|
||||||
|
{widgetType === 'air-quality'
|
||||||
|
? 'Available cities: Skopje, Bitola, Veles, Tetovo, etc.'
|
||||||
|
: 'Enter any city name for weather data'}
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="form-control">
|
||||||
|
<label className="label">
|
||||||
|
<span className="label-text font-semibold">Widget Title (Optional)</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className="input input-bordered"
|
||||||
|
placeholder={widgetType === 'weather' ? `Weather - ${city}` : `Air Quality - ${city}`}
|
||||||
|
value={title}
|
||||||
|
onChange={(e) => setTitle(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
// Original configuration for sensor-based widgets
|
||||||
|
<>
|
||||||
|
<div className="form-control">
|
||||||
|
<label className="label">
|
||||||
|
<span className="label-text font-semibold">Widget Title (Optional)</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className="input input-bordered"
|
||||||
|
placeholder="Auto-generated if empty"
|
||||||
|
value={title}
|
||||||
|
onChange={(e) => setTitle(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="form-control">
|
||||||
|
<label className="label">
|
||||||
|
<span className="label-text font-semibold">Time Range</span>
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
className="select select-bordered"
|
||||||
|
value={timeframeHours}
|
||||||
|
onChange={(e) => setTimeframeHours(Number(e.target.value))}
|
||||||
|
>
|
||||||
|
<option value={1}>Last 1 hour</option>
|
||||||
|
<option value={6}>Last 6 hours</option>
|
||||||
|
<option value={24}>Last 24 hours</option>
|
||||||
|
<option value={168}>Last 7 days</option>
|
||||||
|
<option value={720}>Last 30 days</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div className="form-control">
|
||||||
|
<label className="label">
|
||||||
|
<span className="label-text font-semibold">Width</span>
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
className="select select-bordered"
|
||||||
|
value={widgetWidth}
|
||||||
|
onChange={(e) => setWidgetWidth(Number(e.target.value))}
|
||||||
|
>
|
||||||
|
<option value={1}>Small (1 column)</option>
|
||||||
|
<option value={2}>Medium (2 columns)</option>
|
||||||
|
<option value={3}>Large (3 columns)</option>
|
||||||
|
<option value={4}>Full Width (4 columns)</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="form-control">
|
||||||
|
<label className="label">
|
||||||
|
<span className="label-text font-semibold">Height</span>
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
className="select select-bordered"
|
||||||
|
value={widgetHeight}
|
||||||
|
onChange={(e) => setWidgetHeight(Number(e.target.value))}
|
||||||
|
>
|
||||||
|
<option value={1}>Short</option>
|
||||||
|
<option value={2}>Medium</option>
|
||||||
|
<option value={3}>Tall</option>
|
||||||
|
<option value={4}>Extra Tall</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="alert">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" className="stroke-info shrink-0 w-6 h-6">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"></path>
|
||||||
|
</svg>
|
||||||
|
<div className="text-sm">
|
||||||
|
<div className="font-semibold">Widget Summary</div>
|
||||||
|
<div>Type: <span className="badge badge-sm">{widgetType}</span></div>
|
||||||
|
<div>Device: {devices.find(d => d.id === selectedDevices[0])?.name}</div>
|
||||||
|
<div>Metrics: {selectedMetrics.join(', ')}</div>
|
||||||
|
<div>Size: {widgetWidth} × {widgetHeight}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="modal-action">
|
||||||
|
<button className="btn btn-ghost" onClick={() => setStep(2)}>
|
||||||
|
Back
|
||||||
|
</button>
|
||||||
|
<button className="btn btn-primary" onClick={handleSubmit}>
|
||||||
|
Add Widget
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="modal-backdrop" onClick={onClose}></div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
192
frontend/src/components/CredentialsViewer.tsx
Normal file
192
frontend/src/components/CredentialsViewer.tsx
Normal file
@@ -0,0 +1,192 @@
|
|||||||
|
import { useState } from 'react'
|
||||||
|
import { QRCodeSVG } from 'qrcode.react'
|
||||||
|
import toast from 'react-hot-toast'
|
||||||
|
import type { DeviceRegistrationResponse } from '../types/api'
|
||||||
|
|
||||||
|
interface CredentialsViewerProps {
|
||||||
|
credentials: DeviceRegistrationResponse
|
||||||
|
deviceId?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
const downloadFile = (content: string, filename: string) => {
|
||||||
|
const blob = new Blob([content], { type: 'text/plain' })
|
||||||
|
const url = URL.createObjectURL(blob)
|
||||||
|
const link = document.createElement('a')
|
||||||
|
link.href = url
|
||||||
|
link.download = filename
|
||||||
|
document.body.appendChild(link)
|
||||||
|
link.click()
|
||||||
|
document.body.removeChild(link)
|
||||||
|
URL.revokeObjectURL(url)
|
||||||
|
toast.success(`${filename} downloaded`)
|
||||||
|
}
|
||||||
|
|
||||||
|
const copyToClipboard = (content: string, label: string) => {
|
||||||
|
navigator.clipboard.writeText(content)
|
||||||
|
toast.success(`${label} copied to clipboard`)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function CredentialsViewer({ credentials, deviceId }: CredentialsViewerProps) {
|
||||||
|
const resolvedDeviceId = credentials.device_id || deviceId || 'device'
|
||||||
|
const expiresAt = credentials.expires_at ? new Date(credentials.expires_at).toLocaleString() : null
|
||||||
|
const [showQR, setShowQR] = useState(false)
|
||||||
|
|
||||||
|
// Read configuration from environment variables
|
||||||
|
const deviceManagerUrl = import.meta.env.VITE_DEVICE_MANAGER_URL || 'http://localhost:8000'
|
||||||
|
const mqttBroker = import.meta.env.VITE_MQTT_BROKER || 'localhost'
|
||||||
|
const mqttPort = import.meta.env.VITE_MQTT_PORT || '8883'
|
||||||
|
|
||||||
|
const qrData = credentials.onboarding_token ? JSON.stringify({
|
||||||
|
type: 'iot_device_onboarding',
|
||||||
|
device_id: resolvedDeviceId,
|
||||||
|
token: credentials.onboarding_token,
|
||||||
|
api_url: deviceManagerUrl,
|
||||||
|
broker: mqttBroker,
|
||||||
|
port: parseInt(mqttPort, 10),
|
||||||
|
}) : null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* Secure QR Code for Mobile Onboarding */}
|
||||||
|
{qrData && (
|
||||||
|
<div className="rounded-lg bg-success/10 border border-success/30 p-4">
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" className="h-6 w-6 text-success shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" />
|
||||||
|
</svg>
|
||||||
|
<div className="flex-1">
|
||||||
|
<h3 className="font-semibold mb-1">Secure Mobile Onboarding</h3>
|
||||||
|
<p className="text-sm opacity-80 mb-2">
|
||||||
|
Scan this QR code with your mobile app to securely fetch certificates. Token expires in <strong>15 minutes</strong> and can only be used <strong>once</strong>.
|
||||||
|
</p>
|
||||||
|
<div className="alert alert-warning alert-sm mb-3">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" className="stroke-current shrink-0 h-5 w-5" fill="none" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
|
||||||
|
</svg>
|
||||||
|
<span className="text-xs">This QR code will not be shown again. Scan it now!</span>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
className="btn btn-sm btn-success"
|
||||||
|
onClick={() => setShowQR(!showQR)}
|
||||||
|
>
|
||||||
|
{showQR ? 'Hide QR Code' : 'Show QR Code'}
|
||||||
|
</button>
|
||||||
|
{showQR && (
|
||||||
|
<div className="mt-4 flex justify-center p-6 bg-white rounded-lg border-2 border-success">
|
||||||
|
<QRCodeSVG
|
||||||
|
value={qrData}
|
||||||
|
size={280}
|
||||||
|
level="H"
|
||||||
|
includeMargin={true}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{(credentials.certificate_id || expiresAt) && (
|
||||||
|
<div className="rounded-lg bg-base-200 p-4 text-sm">
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
{credentials.certificate_id && (
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="font-semibold">Certificate ID</span>
|
||||||
|
<code className="bg-base-100 px-2 py-1 rounded">
|
||||||
|
{credentials.certificate_id}
|
||||||
|
</code>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{expiresAt && (
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="font-semibold">Expires At</span>
|
||||||
|
<span>{expiresAt}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{credentials.ca_certificate_pem && (
|
||||||
|
<div>
|
||||||
|
<label className="label">
|
||||||
|
<span className="label-text font-semibold">CA Certificate</span>
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
className="textarea textarea-bordered w-full font-mono text-xs h-32"
|
||||||
|
value={credentials.ca_certificate_pem}
|
||||||
|
readOnly
|
||||||
|
/>
|
||||||
|
<div className="flex gap-2 mt-2">
|
||||||
|
<button
|
||||||
|
className="btn btn-sm btn-outline"
|
||||||
|
onClick={() => copyToClipboard(credentials.ca_certificate_pem!, 'CA certificate')}
|
||||||
|
>
|
||||||
|
Copy
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="btn btn-sm btn-outline"
|
||||||
|
onClick={() => downloadFile(credentials.ca_certificate_pem!, 'ca.crt')}
|
||||||
|
>
|
||||||
|
Download
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{credentials.certificate_pem && (
|
||||||
|
<div>
|
||||||
|
<label className="label">
|
||||||
|
<span className="label-text font-semibold">Device Certificate</span>
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
className="textarea textarea-bordered w-full font-mono text-xs h-32"
|
||||||
|
value={credentials.certificate_pem}
|
||||||
|
readOnly
|
||||||
|
/>
|
||||||
|
<div className="flex gap-2 mt-2">
|
||||||
|
<button
|
||||||
|
className="btn btn-sm btn-outline"
|
||||||
|
onClick={() => copyToClipboard(credentials.certificate_pem!, 'Device certificate')}
|
||||||
|
>
|
||||||
|
Copy
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="btn btn-sm btn-outline"
|
||||||
|
onClick={() => downloadFile(credentials.certificate_pem!, `${resolvedDeviceId}.crt`)}
|
||||||
|
>
|
||||||
|
Download
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{credentials.private_key_pem && (
|
||||||
|
<div>
|
||||||
|
<label className="label">
|
||||||
|
<span className="label-text font-semibold">Private Key</span>
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
className="textarea textarea-bordered w-full font-mono text-xs h-32"
|
||||||
|
value={credentials.private_key_pem}
|
||||||
|
readOnly
|
||||||
|
/>
|
||||||
|
<div className="flex gap-2 mt-2">
|
||||||
|
<button
|
||||||
|
className="btn btn-sm btn-outline"
|
||||||
|
onClick={() => copyToClipboard(credentials.private_key_pem!, 'Private key')}
|
||||||
|
>
|
||||||
|
Copy
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="btn btn-sm btn-outline"
|
||||||
|
onClick={() => downloadFile(credentials.private_key_pem!, `${resolvedDeviceId}.key`)}
|
||||||
|
>
|
||||||
|
Download
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
76
frontend/src/components/DeleteDeviceDialog.tsx
Normal file
76
frontend/src/components/DeleteDeviceDialog.tsx
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
import * as AlertDialog from '@radix-ui/react-alert-dialog'
|
||||||
|
import { useMutation, useQueryClient } from '@tanstack/react-query'
|
||||||
|
import { devicesApi } from '../api'
|
||||||
|
import toast from 'react-hot-toast'
|
||||||
|
import type { AxiosError } from 'axios'
|
||||||
|
import type { Device } from '../types/api'
|
||||||
|
|
||||||
|
interface DeleteDeviceDialogProps {
|
||||||
|
device: Device
|
||||||
|
open: boolean
|
||||||
|
onOpenChange: (open: boolean) => void
|
||||||
|
onDeleted?: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function DeleteDeviceDialog({ device, open, onOpenChange, onDeleted }: DeleteDeviceDialogProps) {
|
||||||
|
const queryClient = useQueryClient()
|
||||||
|
|
||||||
|
const deleteMutation = useMutation({
|
||||||
|
mutationFn: () => devicesApi.delete(device.id),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['devices'] })
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['device', device.id] })
|
||||||
|
toast.success(`Device "${device.name}" deleted successfully`)
|
||||||
|
onDeleted?.()
|
||||||
|
onOpenChange(false)
|
||||||
|
},
|
||||||
|
onError: (error) => {
|
||||||
|
const axiosError = error as AxiosError<{ detail?: string }>
|
||||||
|
const message = axiosError.response?.data?.detail || axiosError.message
|
||||||
|
toast.error(`Failed to delete device: ${message}`)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AlertDialog.Root open={open} onOpenChange={onOpenChange}>
|
||||||
|
<AlertDialog.Portal>
|
||||||
|
<AlertDialog.Overlay className="fixed inset-0 bg-black/50 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0" />
|
||||||
|
<AlertDialog.Content className="fixed left-[50%] top-[50%] z-50 max-h-[85vh] w-[90vw] max-w-[500px] translate-x-[-50%] translate-y-[-50%] rounded-lg bg-base-100 p-6 shadow-xl">
|
||||||
|
<AlertDialog.Title className="text-2xl font-bold mb-2">
|
||||||
|
Delete Device
|
||||||
|
</AlertDialog.Title>
|
||||||
|
<AlertDialog.Description className="text-base-content/70 mb-6">
|
||||||
|
Are you sure you want to delete <strong>{device.name}</strong>? This action cannot be undone.
|
||||||
|
All associated telemetry data and certificates will be permanently removed.
|
||||||
|
</AlertDialog.Description>
|
||||||
|
<div className="flex justify-end gap-3">
|
||||||
|
<AlertDialog.Cancel asChild>
|
||||||
|
<button className="btn btn-ghost" disabled={deleteMutation.isPending}>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
</AlertDialog.Cancel>
|
||||||
|
<AlertDialog.Action asChild>
|
||||||
|
<button
|
||||||
|
className="btn btn-error"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.preventDefault()
|
||||||
|
deleteMutation.mutate()
|
||||||
|
}}
|
||||||
|
disabled={deleteMutation.isPending}
|
||||||
|
>
|
||||||
|
{deleteMutation.isPending ? (
|
||||||
|
<>
|
||||||
|
<span className="loading loading-spinner loading-sm"></span>
|
||||||
|
Deleting...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
'Delete Device'
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</AlertDialog.Action>
|
||||||
|
</div>
|
||||||
|
</AlertDialog.Content>
|
||||||
|
</AlertDialog.Portal>
|
||||||
|
</AlertDialog.Root>
|
||||||
|
)
|
||||||
|
}
|
||||||
40
frontend/src/components/DeviceCredentialsDialog.tsx
Normal file
40
frontend/src/components/DeviceCredentialsDialog.tsx
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
import * as Dialog from '@radix-ui/react-dialog'
|
||||||
|
import CredentialsViewer from './CredentialsViewer'
|
||||||
|
import type { DeviceRegistrationResponse } from '../types/api'
|
||||||
|
|
||||||
|
interface DeviceCredentialsDialogProps {
|
||||||
|
open: boolean
|
||||||
|
onOpenChange: (open: boolean) => void
|
||||||
|
credentials: DeviceRegistrationResponse | null
|
||||||
|
deviceName?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function DeviceCredentialsDialog({ open, onOpenChange, credentials, deviceName }: DeviceCredentialsDialogProps) {
|
||||||
|
if (!credentials) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog.Root open={open} onOpenChange={onOpenChange}>
|
||||||
|
<Dialog.Portal>
|
||||||
|
<Dialog.Overlay className="fixed inset-0 bg-black/50 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0" />
|
||||||
|
<Dialog.Content className="fixed left-1/2 top-1/2 z-50 max-h-[85vh] w-[90vw] max-w-[600px] -translate-x-1/2 -translate-y-1/2 rounded-lg bg-base-100 p-6 shadow-xl overflow-y-auto">
|
||||||
|
<Dialog.Title className="text-2xl font-bold mb-4">
|
||||||
|
{deviceName ? `${deviceName} Credentials` : 'Device Credentials'}
|
||||||
|
</Dialog.Title>
|
||||||
|
<Dialog.Description className="text-base-content/70 mb-4">
|
||||||
|
Store these credentials securely. They are only shown once after issuing the certificate.
|
||||||
|
</Dialog.Description>
|
||||||
|
|
||||||
|
<CredentialsViewer credentials={credentials} deviceId={credentials.device_id} />
|
||||||
|
|
||||||
|
<div className="flex justify-end mt-6">
|
||||||
|
<Dialog.Close asChild>
|
||||||
|
<button className="btn btn-primary">Done</button>
|
||||||
|
</Dialog.Close>
|
||||||
|
</div>
|
||||||
|
</Dialog.Content>
|
||||||
|
</Dialog.Portal>
|
||||||
|
</Dialog.Root>
|
||||||
|
)
|
||||||
|
}
|
||||||
221
frontend/src/components/EditWidgetModal.tsx
Normal file
221
frontend/src/components/EditWidgetModal.tsx
Normal file
@@ -0,0 +1,221 @@
|
|||||||
|
import { useState, useEffect } from 'react'
|
||||||
|
import { useQuery } from '@tanstack/react-query'
|
||||||
|
import { devicesApi } from '../api'
|
||||||
|
import type { WidgetConfig } from '../hooks'
|
||||||
|
|
||||||
|
interface EditWidgetModalProps {
|
||||||
|
isOpen: boolean
|
||||||
|
widget: WidgetConfig | null
|
||||||
|
onClose: () => void
|
||||||
|
onSave: (widgetId: string, updates: Partial<WidgetConfig>) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function EditWidgetModal({ isOpen, widget, onClose, onSave }: EditWidgetModalProps) {
|
||||||
|
const [title, setTitle] = useState('')
|
||||||
|
const [selectedMetrics, setSelectedMetrics] = useState<string[]>([])
|
||||||
|
const [timeframeHours, setTimeframeHours] = useState(24)
|
||||||
|
const [widgetWidth, setWidgetWidth] = useState(1)
|
||||||
|
const [widgetHeight, setWidgetHeight] = useState(2)
|
||||||
|
|
||||||
|
// Fetch metrics for the widget's device
|
||||||
|
const { data: deviceMetricsData } = useQuery({
|
||||||
|
queryKey: ['device-metrics', widget?.deviceIds[0]],
|
||||||
|
queryFn: async () => {
|
||||||
|
if (!widget?.deviceIds[0]) return []
|
||||||
|
const response = await devicesApi.getMetrics(widget.deviceIds[0])
|
||||||
|
return response.data.metrics
|
||||||
|
},
|
||||||
|
enabled: !!widget?.deviceIds[0] && isOpen,
|
||||||
|
})
|
||||||
|
|
||||||
|
const availableMetrics = deviceMetricsData || []
|
||||||
|
|
||||||
|
// Load widget data when modal opens
|
||||||
|
useEffect(() => {
|
||||||
|
if (isOpen && widget) {
|
||||||
|
setTitle(widget.title || '')
|
||||||
|
setSelectedMetrics(widget.metricIds || [])
|
||||||
|
setTimeframeHours(widget.timeframe?.hours || 24)
|
||||||
|
setWidgetWidth(widget.position?.w || 1)
|
||||||
|
setWidgetHeight(widget.position?.h || 2)
|
||||||
|
}
|
||||||
|
}, [isOpen, widget])
|
||||||
|
|
||||||
|
const handleSubmit = () => {
|
||||||
|
if (!widget || selectedMetrics.length === 0) {
|
||||||
|
alert('Please select at least one metric')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
onSave(widget.id, {
|
||||||
|
title,
|
||||||
|
metricIds: selectedMetrics,
|
||||||
|
timeframe: {
|
||||||
|
hours: timeframeHours,
|
||||||
|
},
|
||||||
|
position: {
|
||||||
|
...widget.position,
|
||||||
|
x: widget.position?.x || 0,
|
||||||
|
y: widget.position?.y || 0,
|
||||||
|
w: widgetWidth,
|
||||||
|
h: widgetHeight,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
onClose()
|
||||||
|
}
|
||||||
|
|
||||||
|
const toggleMetric = (metric: string) => {
|
||||||
|
// Stat and gauge widgets only allow one metric
|
||||||
|
const singleMetricWidgets = ['stat', 'gauge']
|
||||||
|
const maxMetrics = widget && singleMetricWidgets.includes(widget.type) ? 1 : 5
|
||||||
|
|
||||||
|
setSelectedMetrics((prev) => {
|
||||||
|
if (prev.includes(metric)) {
|
||||||
|
return prev.filter((m) => m !== metric)
|
||||||
|
}
|
||||||
|
|
||||||
|
// If adding would exceed max, replace last or prevent
|
||||||
|
if (prev.length >= maxMetrics) {
|
||||||
|
if (maxMetrics === 1) {
|
||||||
|
return [metric] // Replace for single-metric widgets
|
||||||
|
}
|
||||||
|
return prev // Don't add more for multi-metric widgets
|
||||||
|
}
|
||||||
|
|
||||||
|
return [...prev, metric]
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isOpen || !widget) return null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="modal modal-open">
|
||||||
|
<div className="modal-box max-w-2xl">
|
||||||
|
<h3 className="font-bold text-lg mb-4">Edit Widget</h3>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* Widget Type (read-only) */}
|
||||||
|
<div className="form-control">
|
||||||
|
<label className="label">
|
||||||
|
<span className="label-text font-semibold">Widget Type</span>
|
||||||
|
</label>
|
||||||
|
<div className="badge badge-lg badge-primary">{widget.type}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Title */}
|
||||||
|
<div className="form-control">
|
||||||
|
<label className="label">
|
||||||
|
<span className="label-text font-semibold">Widget Title</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
className="input input-bordered"
|
||||||
|
placeholder="Auto-generated if empty"
|
||||||
|
value={title}
|
||||||
|
onChange={(e) => setTitle(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Metrics */}
|
||||||
|
<div className="form-control">
|
||||||
|
<label className="label">
|
||||||
|
<span className="label-text font-semibold">Select Metric(s)</span>
|
||||||
|
<span className="label-text-alt">{selectedMetrics.length} selected</span>
|
||||||
|
</label>
|
||||||
|
{(['stat', 'gauge'].includes(widget.type)) && (
|
||||||
|
<div className="alert alert-info mb-2">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" className="stroke-current shrink-0 w-5 h-5">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"></path>
|
||||||
|
</svg>
|
||||||
|
<span className="text-sm">This widget type supports only one metric</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="border rounded-lg p-2 max-h-48 overflow-y-auto space-y-1">
|
||||||
|
{availableMetrics.length === 0 ? (
|
||||||
|
<div className="text-center text-base-content/60 py-4">
|
||||||
|
Loading metrics...
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
availableMetrics.map((metric) => (
|
||||||
|
<label key={metric} className="flex items-center gap-2 p-2 hover:bg-base-200 rounded cursor-pointer">
|
||||||
|
<input
|
||||||
|
type={(['stat', 'gauge'].includes(widget.type)) ? 'radio' : 'checkbox'}
|
||||||
|
name={(['stat', 'gauge'].includes(widget.type)) ? 'single-metric' : undefined}
|
||||||
|
className={(['stat', 'gauge'].includes(widget.type)) ? 'radio radio-sm' : 'checkbox checkbox-sm'}
|
||||||
|
checked={selectedMetrics.includes(metric)}
|
||||||
|
onChange={() => toggleMetric(metric)}
|
||||||
|
/>
|
||||||
|
<span className="capitalize">{metric.replace(/_/g, ' ')}</span>
|
||||||
|
</label>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Time Range */}
|
||||||
|
<div className="form-control">
|
||||||
|
<label className="label">
|
||||||
|
<span className="label-text font-semibold">Time Range</span>
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
className="select select-bordered"
|
||||||
|
value={timeframeHours}
|
||||||
|
onChange={(e) => setTimeframeHours(Number(e.target.value))}
|
||||||
|
>
|
||||||
|
<option value={1}>Last 1 hour</option>
|
||||||
|
<option value={6}>Last 6 hours</option>
|
||||||
|
<option value={24}>Last 24 hours</option>
|
||||||
|
<option value={168}>Last 7 days</option>
|
||||||
|
<option value={720}>Last 30 days</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Size */}
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div className="form-control">
|
||||||
|
<label className="label">
|
||||||
|
<span className="label-text font-semibold">Width</span>
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
className="select select-bordered"
|
||||||
|
value={widgetWidth}
|
||||||
|
onChange={(e) => setWidgetWidth(Number(e.target.value))}
|
||||||
|
>
|
||||||
|
<option value={1}>Small (1 column)</option>
|
||||||
|
<option value={2}>Medium (2 columns)</option>
|
||||||
|
<option value={3}>Large (3 columns)</option>
|
||||||
|
<option value={4}>Full Width (4 columns)</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="form-control">
|
||||||
|
<label className="label">
|
||||||
|
<span className="label-text font-semibold">Height</span>
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
className="select select-bordered"
|
||||||
|
value={widgetHeight}
|
||||||
|
onChange={(e) => setWidgetHeight(Number(e.target.value))}
|
||||||
|
>
|
||||||
|
<option value={1}>Short</option>
|
||||||
|
<option value={2}>Medium</option>
|
||||||
|
<option value={3}>Tall</option>
|
||||||
|
<option value={4}>Extra Tall</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="modal-action">
|
||||||
|
<button className="btn btn-ghost" onClick={onClose}>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button className="btn btn-primary" onClick={handleSubmit}>
|
||||||
|
Save Changes
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="modal-backdrop" onClick={onClose}></div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
101
frontend/src/components/RenewDialog.tsx
Normal file
101
frontend/src/components/RenewDialog.tsx
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
import * as Dialog from '@radix-ui/react-dialog'
|
||||||
|
import { useMutation, useQueryClient } from '@tanstack/react-query'
|
||||||
|
import { devicesApi } from '../api'
|
||||||
|
import toast from 'react-hot-toast'
|
||||||
|
import { useState } from 'react'
|
||||||
|
import CredentialsViewer from './CredentialsViewer'
|
||||||
|
import type { AxiosError } from 'axios'
|
||||||
|
import type { Device, DeviceRegistrationResponse } from '../types/api'
|
||||||
|
|
||||||
|
interface RenewDialogProps {
|
||||||
|
device: Device
|
||||||
|
open: boolean
|
||||||
|
onOpenChange: (open: boolean) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function RenewDialog({ device, open, onOpenChange }: RenewDialogProps) {
|
||||||
|
const queryClient = useQueryClient()
|
||||||
|
const [credentials, setCredentials] = useState<DeviceRegistrationResponse | null>(null)
|
||||||
|
|
||||||
|
const renewMutation = useMutation({
|
||||||
|
mutationFn: () => devicesApi.renew(device.id),
|
||||||
|
onSuccess: (response) => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['devices'] })
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['device', device.id] })
|
||||||
|
setCredentials(response.data)
|
||||||
|
toast.success(`Certificate for "${device.name}" renewed successfully`)
|
||||||
|
},
|
||||||
|
onError: (error) => {
|
||||||
|
const axiosError = error as AxiosError<{ detail?: string }>
|
||||||
|
const message = axiosError.response?.data?.detail || axiosError.message
|
||||||
|
toast.error(`Failed to renew certificate: ${message}`)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const handleOpenChange = (nextOpen: boolean) => {
|
||||||
|
if (!nextOpen) {
|
||||||
|
setCredentials(null)
|
||||||
|
onOpenChange(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog.Root open={open} onOpenChange={handleOpenChange}>
|
||||||
|
<Dialog.Portal>
|
||||||
|
<Dialog.Overlay className="fixed inset-0 bg-black/50 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0" />
|
||||||
|
<Dialog.Content className="fixed left-[50%] top-[50%] z-50 max-h-[85vh] w-[90vw] max-w-[600px] translate-x-[-50%] translate-y-[-50%] rounded-lg bg-base-100 p-6 shadow-xl overflow-y-auto">
|
||||||
|
<Dialog.Title className="text-2xl font-bold mb-4">
|
||||||
|
{credentials ? 'Certificate Renewed' : 'Renew Certificate'}
|
||||||
|
</Dialog.Title>
|
||||||
|
|
||||||
|
{!credentials ? (
|
||||||
|
<>
|
||||||
|
<Dialog.Description className="text-base-content/70 mb-6">
|
||||||
|
This will generate a new certificate for <strong>{device.name}</strong>.
|
||||||
|
You will need to update the device with the new credentials.
|
||||||
|
</Dialog.Description>
|
||||||
|
<div className="flex justify-end gap-3">
|
||||||
|
<Dialog.Close asChild>
|
||||||
|
<button className="btn btn-ghost" disabled={renewMutation.isPending}>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
</Dialog.Close>
|
||||||
|
<button
|
||||||
|
className="btn btn-warning"
|
||||||
|
onClick={() => renewMutation.mutate()}
|
||||||
|
disabled={renewMutation.isPending}
|
||||||
|
>
|
||||||
|
{renewMutation.isPending ? (
|
||||||
|
<>
|
||||||
|
<span className="loading loading-spinner loading-sm"></span>
|
||||||
|
Renewing...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
'Renew Certificate'
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="alert alert-warning mb-4">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" className="stroke-current shrink-0 h-6 w-6" fill="none" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
|
||||||
|
</svg>
|
||||||
|
<span>Save these credentials now! They will not be shown again.</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<CredentialsViewer credentials={credentials} deviceId={device.id} />
|
||||||
|
|
||||||
|
<div className="flex justify-end mt-6">
|
||||||
|
<Dialog.Close asChild>
|
||||||
|
<button className="btn btn-primary">Done</button>
|
||||||
|
</Dialog.Close>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Dialog.Content>
|
||||||
|
</Dialog.Portal>
|
||||||
|
</Dialog.Root>
|
||||||
|
)
|
||||||
|
}
|
||||||
74
frontend/src/components/RevokeDialog.tsx
Normal file
74
frontend/src/components/RevokeDialog.tsx
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
import * as AlertDialog from '@radix-ui/react-alert-dialog'
|
||||||
|
import { useMutation, useQueryClient } from '@tanstack/react-query'
|
||||||
|
import { devicesApi } from '../api'
|
||||||
|
import toast from 'react-hot-toast'
|
||||||
|
import type { AxiosError } from 'axios'
|
||||||
|
import type { Device } from '../types/api'
|
||||||
|
|
||||||
|
interface RevokeDialogProps {
|
||||||
|
device: Device
|
||||||
|
open: boolean
|
||||||
|
onOpenChange: (open: boolean) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function RevokeDialog({ device, open, onOpenChange }: RevokeDialogProps) {
|
||||||
|
const queryClient = useQueryClient()
|
||||||
|
|
||||||
|
const revokeMutation = useMutation({
|
||||||
|
mutationFn: () => devicesApi.revoke(device.id),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['devices'] })
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['device', device.id] })
|
||||||
|
toast.success(`Certificate for "${device.name}" revoked successfully`)
|
||||||
|
onOpenChange(false)
|
||||||
|
},
|
||||||
|
onError: (error) => {
|
||||||
|
const axiosError = error as AxiosError<{ detail?: string }>
|
||||||
|
const message = axiosError.response?.data?.detail || axiosError.message
|
||||||
|
toast.error(`Failed to revoke certificate: ${message}`)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AlertDialog.Root open={open} onOpenChange={onOpenChange}>
|
||||||
|
<AlertDialog.Portal>
|
||||||
|
<AlertDialog.Overlay className="fixed inset-0 bg-black/50 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0" />
|
||||||
|
<AlertDialog.Content className="fixed left-[50%] top-[50%] z-50 max-h-[85vh] w-[90vw] max-w-[500px] translate-x-[-50%] translate-y-[-50%] rounded-lg bg-base-100 p-6 shadow-xl">
|
||||||
|
<AlertDialog.Title className="text-2xl font-bold mb-2">
|
||||||
|
Revoke Certificate
|
||||||
|
</AlertDialog.Title>
|
||||||
|
<AlertDialog.Description className="text-base-content/70 mb-6">
|
||||||
|
Are you sure you want to revoke the certificate for <strong>{device.name}</strong>?
|
||||||
|
The device will no longer be able to connect until you renew its certificate.
|
||||||
|
</AlertDialog.Description>
|
||||||
|
<div className="flex justify-end gap-3">
|
||||||
|
<AlertDialog.Cancel asChild>
|
||||||
|
<button className="btn btn-ghost" disabled={revokeMutation.isPending}>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
</AlertDialog.Cancel>
|
||||||
|
<AlertDialog.Action asChild>
|
||||||
|
<button
|
||||||
|
className="btn btn-warning"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.preventDefault()
|
||||||
|
revokeMutation.mutate()
|
||||||
|
}}
|
||||||
|
disabled={revokeMutation.isPending}
|
||||||
|
>
|
||||||
|
{revokeMutation.isPending ? (
|
||||||
|
<>
|
||||||
|
<span className="loading loading-spinner loading-sm"></span>
|
||||||
|
Revoking...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
'Revoke Certificate'
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</AlertDialog.Action>
|
||||||
|
</div>
|
||||||
|
</AlertDialog.Content>
|
||||||
|
</AlertDialog.Portal>
|
||||||
|
</AlertDialog.Root>
|
||||||
|
)
|
||||||
|
}
|
||||||
84
frontend/src/components/dashboard/TelemetryTrendCard.tsx
Normal file
84
frontend/src/components/dashboard/TelemetryTrendCard.tsx
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
import { ResponsiveContainer, LineChart, Line, Tooltip, XAxis, YAxis, CartesianGrid } from 'recharts'
|
||||||
|
|
||||||
|
interface TelemetryTrendCardProps {
|
||||||
|
title: string
|
||||||
|
data: Array<{ time: string; value: number }>
|
||||||
|
unit?: string
|
||||||
|
accentColor?: string
|
||||||
|
subtitle?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatTimeLabel(timestamp: string) {
|
||||||
|
const date = new Date(timestamp)
|
||||||
|
return date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatValue(value: number, unit?: string) {
|
||||||
|
const rounded = Number.isInteger(value) ? value : value.toFixed(1)
|
||||||
|
return unit ? `${rounded} ${unit}` : String(rounded)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function TelemetryTrendCard({ title, data, unit, accentColor = '#2563eb', subtitle }: TelemetryTrendCardProps) {
|
||||||
|
const latest = data.at(-1)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="card bg-base-100 shadow-xl">
|
||||||
|
<div className="card-body gap-4">
|
||||||
|
<div className="flex items-start justify-between">
|
||||||
|
<div>
|
||||||
|
<h3 className="text-lg font-semibold">{title}</h3>
|
||||||
|
{subtitle && <p className="text-sm text-base-content/60">{subtitle}</p>}
|
||||||
|
</div>
|
||||||
|
{latest ? (
|
||||||
|
<div className="text-right">
|
||||||
|
<div className="text-3xl font-bold text-primary">
|
||||||
|
{formatValue(latest.value, unit)}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-base-content/60">as of {formatTimeLabel(latest.time)}</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="text-sm text-base-content/60">No data</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="h-48">
|
||||||
|
{data.length > 1 ? (
|
||||||
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
|
<LineChart data={data} margin={{ top: 10, right: 20, left: 0, bottom: 0 }}>
|
||||||
|
<CartesianGrid strokeDasharray="3 3" stroke="hsl(var(--bc) / 0.1)" />
|
||||||
|
<XAxis
|
||||||
|
dataKey="time"
|
||||||
|
tickFormatter={formatTimeLabel}
|
||||||
|
tick={{ fontSize: 12, fill: '#ffffff' }}
|
||||||
|
stroke="rgba(255, 255, 255, 0.3)"
|
||||||
|
/>
|
||||||
|
<YAxis
|
||||||
|
tickFormatter={(val) => formatValue(val, unit)}
|
||||||
|
width={48}
|
||||||
|
tick={{ fontSize: 12, fill: '#ffffff' }}
|
||||||
|
stroke="rgba(255, 255, 255, 0.3)"
|
||||||
|
/>
|
||||||
|
<Tooltip
|
||||||
|
formatter={(value: number) => formatValue(value, unit)}
|
||||||
|
labelFormatter={(label) => formatTimeLabel(String(label))}
|
||||||
|
/>
|
||||||
|
<Line
|
||||||
|
type="monotone"
|
||||||
|
dataKey="value"
|
||||||
|
stroke={accentColor}
|
||||||
|
strokeWidth={2}
|
||||||
|
dot={false}
|
||||||
|
activeDot={{ r: 4 }}
|
||||||
|
/>
|
||||||
|
</LineChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
) : (
|
||||||
|
<div className="flex h-full items-center justify-center text-sm text-base-content/60">
|
||||||
|
Not enough telemetry to chart yet
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
144
frontend/src/components/widgets/AiInsightWidget.tsx
Normal file
144
frontend/src/components/widgets/AiInsightWidget.tsx
Normal file
@@ -0,0 +1,144 @@
|
|||||||
|
import { useState } from 'react'
|
||||||
|
import { useQuery } from '@tanstack/react-query'
|
||||||
|
import { telemetryApi } from '../../api'
|
||||||
|
import type { WidgetConfig } from '../../hooks'
|
||||||
|
|
||||||
|
interface AiInsightWidgetProps {
|
||||||
|
config: WidgetConfig
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function AiInsightWidget({ config }: AiInsightWidgetProps) {
|
||||||
|
const { deviceIds, metricIds, timeframe, title } = config
|
||||||
|
const [promptType, setPromptType] = useState<'trend_summary' | 'anomaly_detection'>('trend_summary')
|
||||||
|
const [showAnalysis, setShowAnalysis] = useState(false)
|
||||||
|
|
||||||
|
const deviceId = deviceIds[0]
|
||||||
|
const metric = metricIds[0]
|
||||||
|
|
||||||
|
const {
|
||||||
|
data: analysis,
|
||||||
|
isLoading,
|
||||||
|
refetch,
|
||||||
|
error,
|
||||||
|
} = useQuery({
|
||||||
|
queryKey: ['ai-insight', deviceId, metric, promptType, timeframe],
|
||||||
|
queryFn: async () => {
|
||||||
|
const response = await telemetryApi.analyze({
|
||||||
|
device_id: deviceId,
|
||||||
|
metric,
|
||||||
|
hours: timeframe.hours || 240,
|
||||||
|
limit: 200,
|
||||||
|
prompt_type: promptType,
|
||||||
|
})
|
||||||
|
return response.data
|
||||||
|
},
|
||||||
|
enabled: false, // Manual trigger
|
||||||
|
})
|
||||||
|
|
||||||
|
const handleAnalyze = () => {
|
||||||
|
setShowAnalysis(true)
|
||||||
|
refetch()
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="card bg-base-100 shadow">
|
||||||
|
<div className="card-body">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<h3 className="card-title text-base">{title || 'AI Environmental Insights'}</h3>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<select
|
||||||
|
className="select select-bordered select-sm"
|
||||||
|
value={promptType}
|
||||||
|
onChange={(e) => setPromptType(e.target.value as 'trend_summary' | 'anomaly_detection')}
|
||||||
|
>
|
||||||
|
<option value="trend_summary">Trend Summary</option>
|
||||||
|
<option value="anomaly_detection">Anomaly Detection</option>
|
||||||
|
</select>
|
||||||
|
<button
|
||||||
|
className="btn btn-primary btn-sm"
|
||||||
|
onClick={handleAnalyze}
|
||||||
|
disabled={isLoading}
|
||||||
|
>
|
||||||
|
{isLoading ? (
|
||||||
|
<>
|
||||||
|
<span className="loading loading-spinner loading-xs"></span>
|
||||||
|
Analyzing...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9.663 17h4.673M12 3v1m6.364 1.636l-.707.707M21 12h-1M4 12H3m3.343-5.657l-.707-.707m2.828 9.9a5 5 0 117.072 0l-.548.547A3.374 3.374 0 0014 18.469V19a2 2 0 11-4 0v-.531c0-.895-.356-1.754-.988-2.386l-.548-.547z" />
|
||||||
|
</svg>
|
||||||
|
Analyze
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isLoading && (
|
||||||
|
<div className="flex items-center justify-center py-12">
|
||||||
|
<div className="text-center space-y-4">
|
||||||
|
<span className="loading loading-spinner loading-lg text-primary"></span>
|
||||||
|
<p className="text-sm text-base-content/60">Analyzing environmental data...</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="alert alert-error mt-4">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" className="stroke-current shrink-0 h-6 w-6" fill="none" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||||
|
</svg>
|
||||||
|
<div>
|
||||||
|
<div className="font-bold">Analysis Failed</div>
|
||||||
|
<div className="text-sm">{(error as Error)?.message || 'Could not connect to GPT service'}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{analysis && showAnalysis && !isLoading && (
|
||||||
|
<div className="space-y-4 mt-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="badge badge-primary badge-lg">
|
||||||
|
{promptType === 'trend_summary' ? 'Trend Analysis' : 'Anomaly Detection'}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-base-content/50">
|
||||||
|
{analysis.data_points_analyzed} data points analyzed
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="divider my-2"></div>
|
||||||
|
<div className="prose max-w-none">
|
||||||
|
<div className="whitespace-pre-wrap text-sm leading-relaxed max-h-96 overflow-y-auto">
|
||||||
|
{analysis.analysis}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-end gap-2 mt-4">
|
||||||
|
<button
|
||||||
|
className="btn btn-ghost btn-sm"
|
||||||
|
onClick={() => setShowAnalysis(false)}
|
||||||
|
>
|
||||||
|
Close
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="btn btn-outline btn-sm"
|
||||||
|
onClick={() => refetch()}
|
||||||
|
>
|
||||||
|
Refresh Analysis
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!showAnalysis && (
|
||||||
|
<div className="text-center py-8 text-base-content/60">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" className="h-12 w-12 mx-auto mb-4 opacity-50" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9.663 17h4.673M12 3v1m6.364 1.636l-.707.707M21 12h-1M4 12H3m3.343-5.657l-.707-.707m2.828 9.9a5 5 0 117.072 0l-.548.547A3.374 3.374 0 0014 18.469V19a2 2 0 11-4 0v-.531c0-.895-.356-1.754-.988-2.386l-.548-.547z" />
|
||||||
|
</svg>
|
||||||
|
<p className="text-sm">Click Analyze to get AI-powered environmental insights</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
141
frontend/src/components/widgets/AirQualityWidget.tsx
Normal file
141
frontend/src/components/widgets/AirQualityWidget.tsx
Normal file
@@ -0,0 +1,141 @@
|
|||||||
|
import { useQuery } from '@tanstack/react-query'
|
||||||
|
import type { WidgetConfig } from '../../hooks'
|
||||||
|
import { weatherApi } from '../../api'
|
||||||
|
import './widget-styles.css'
|
||||||
|
|
||||||
|
interface AirQualityWidgetProps {
|
||||||
|
config: WidgetConfig
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function AirQualityWidget({ config }: AirQualityWidgetProps) {
|
||||||
|
// Get city from config or use default (Pulse.eco city)
|
||||||
|
const city = (config.visualization as Record<string, unknown>)?.city as string || 'skopje'
|
||||||
|
|
||||||
|
const { data: airQuality, isLoading, error } = useQuery({
|
||||||
|
queryKey: ['air-quality', city],
|
||||||
|
queryFn: async () => {
|
||||||
|
const response = await weatherApi.getAirQuality(city)
|
||||||
|
return response.data
|
||||||
|
},
|
||||||
|
refetchInterval: 300000, // Refresh every 5 minutes
|
||||||
|
staleTime: 240000, // Consider fresh for 4 minutes
|
||||||
|
})
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<div className="widget-card card bg-base-100 h-full">
|
||||||
|
<div className="card-body flex items-center justify-center">
|
||||||
|
<span className="loading loading-spinner loading-lg"></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<div className="widget-card card bg-base-100 h-full">
|
||||||
|
<div className="card-body">
|
||||||
|
<h2 className="card-title text-sm truncate">{config.title}</h2>
|
||||||
|
<div className="flex flex-col items-center justify-center flex-1">
|
||||||
|
<p className="text-error text-sm text-center">
|
||||||
|
Failed to load air quality data for {city}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-base-content/60 mt-2">
|
||||||
|
Try: skopje, bitola, tetovo
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!airQuality) return null
|
||||||
|
|
||||||
|
// Get AQI color based on status
|
||||||
|
const getStatusColor = (status: string) => {
|
||||||
|
switch (status.toLowerCase()) {
|
||||||
|
case 'good':
|
||||||
|
return 'success'
|
||||||
|
case 'moderate':
|
||||||
|
return 'warning'
|
||||||
|
case 'unhealthy for sensitive groups':
|
||||||
|
case 'unhealthy':
|
||||||
|
return 'error'
|
||||||
|
case 'very unhealthy':
|
||||||
|
case 'hazardous':
|
||||||
|
return 'error'
|
||||||
|
default:
|
||||||
|
return 'base-content/40'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const statusColor = getStatusColor(airQuality.status)
|
||||||
|
const pm10 = airQuality.measurements.pm10
|
||||||
|
const pm25 = airQuality.measurements.pm25
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="widget-card card bg-base-100 h-full">
|
||||||
|
<div className="card-body">
|
||||||
|
<h2 className="card-title text-sm truncate">{config.title}</h2>
|
||||||
|
<div className="flex flex-col items-center justify-center flex-1">
|
||||||
|
{/* Air quality icon */}
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
className={`h-12 w-12 text-${statusColor} mb-1`}
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke="currentColor"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={2}
|
||||||
|
d="M3 15a4 4 0 004 4h9a5 5 0 10-.1-9.999 5.002 5.002 0 10-9.78 2.096A4.001 4.001 0 003 15z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
|
||||||
|
{/* PM Values */}
|
||||||
|
<div className="grid grid-cols-2 gap-2 w-full mb-2">
|
||||||
|
{pm10 && (
|
||||||
|
<div className="text-center">
|
||||||
|
<div className="text-xl font-bold">{pm10.average.toFixed(1)}</div>
|
||||||
|
<div className="text-xs text-base-content/60">PM10 μg/m³</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{pm25 && (
|
||||||
|
<div className="text-center">
|
||||||
|
<div className="text-xl font-bold">{pm25.average.toFixed(1)}</div>
|
||||||
|
<div className="text-xs text-base-content/60">PM2.5 μg/m³</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* AQI Status badge */}
|
||||||
|
<div className={`badge badge-${statusColor} truncate max-w-full`}>
|
||||||
|
{airQuality.status}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Additional pollutants */}
|
||||||
|
<div className="grid grid-cols-2 gap-2 mt-2 w-full text-xs">
|
||||||
|
{Object.entries(airQuality.measurements).map(([pollutant, data]) => {
|
||||||
|
if (pollutant === 'pm10' || pollutant === 'pm25') return null
|
||||||
|
return (
|
||||||
|
<div key={pollutant} className="flex justify-between">
|
||||||
|
<span className="opacity-60">{pollutant.toUpperCase()}:</span>
|
||||||
|
<span className="font-semibold">{data.average.toFixed(1)}</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* City and sensor count */}
|
||||||
|
<div className="text-xs text-base-content/40 mt-2 px-2 w-full overflow-hidden">
|
||||||
|
<div className="truncate text-center">
|
||||||
|
{airQuality.city.charAt(0).toUpperCase() + airQuality.city.slice(1)} • {airQuality.sensor_count} sensors
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
153
frontend/src/components/widgets/ComfortIndexWidget.tsx
Normal file
153
frontend/src/components/widgets/ComfortIndexWidget.tsx
Normal file
@@ -0,0 +1,153 @@
|
|||||||
|
import { useQuery } from '@tanstack/react-query'
|
||||||
|
import { devicesApi } from '../../api'
|
||||||
|
import type { WidgetConfig } from '../../hooks'
|
||||||
|
import './widget-styles.css'
|
||||||
|
|
||||||
|
interface ComfortIndexWidgetProps {
|
||||||
|
config: WidgetConfig
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ComfortIndexWidget({ config }: ComfortIndexWidgetProps) {
|
||||||
|
const deviceId = config.deviceIds[0]
|
||||||
|
|
||||||
|
const { data, isLoading, error } = useQuery({
|
||||||
|
queryKey: ['comfort-index', deviceId],
|
||||||
|
queryFn: async () => {
|
||||||
|
const response = await devicesApi.getComfortIndex(deviceId)
|
||||||
|
return response.data
|
||||||
|
},
|
||||||
|
refetchInterval: 60000, // Refresh every minute
|
||||||
|
enabled: !!deviceId,
|
||||||
|
})
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<div className="widget-card card bg-base-100 h-full">
|
||||||
|
<div className="card-body flex items-center justify-center">
|
||||||
|
<span className="loading loading-spinner loading-lg"></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error || !data) {
|
||||||
|
return (
|
||||||
|
<div className="widget-card card bg-base-100 h-full">
|
||||||
|
<div className="card-body flex items-center justify-center">
|
||||||
|
<div className="alert alert-error">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" className="stroke-current shrink-0 h-6 w-6" fill="none" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||||
|
</svg>
|
||||||
|
<span>Failed to load comfort index</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const getRatingColor = (rating: string) => {
|
||||||
|
switch (rating) {
|
||||||
|
case 'Excellent': return 'text-success'
|
||||||
|
case 'Good': return 'text-info'
|
||||||
|
case 'Fair': return 'text-warning'
|
||||||
|
case 'Poor': return 'text-error'
|
||||||
|
case 'Very Poor': return 'text-error'
|
||||||
|
default: return 'text-base-content'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const getScoreColor = (score: number) => {
|
||||||
|
if (score >= 90) return 'text-success'
|
||||||
|
if (score >= 75) return 'text-info'
|
||||||
|
if (score >= 60) return 'text-warning'
|
||||||
|
if (score >= 40) return 'text-error'
|
||||||
|
return 'text-error'
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="widget-card card bg-base-100 h-full">
|
||||||
|
<div className="card-body p-3 gap-2">
|
||||||
|
{/* Title */}
|
||||||
|
<h2 className="card-title text-sm mb-1">{config.title}</h2>
|
||||||
|
|
||||||
|
{/* Overall Score */}
|
||||||
|
<div className="text-center">
|
||||||
|
<div className={`text-4xl font-bold ${getScoreColor(data.overall_score)}`}>
|
||||||
|
{data.overall_score}
|
||||||
|
</div>
|
||||||
|
<div className={`text-lg font-semibold ${getRatingColor(data.rating)} mt-0.5`}>
|
||||||
|
{data.rating}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-base-content/60">Comfort Index</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Component Scores */}
|
||||||
|
<div className="grid grid-cols-2 gap-2 text-xs">
|
||||||
|
<div className="flex items-center justify-between p-2 bg-base-200 rounded">
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<span className="text-sm">🌡️</span>
|
||||||
|
<span className="truncate">Temperature</span>
|
||||||
|
</div>
|
||||||
|
<span className={`font-bold ${getScoreColor(data.components.temperature)}`}>
|
||||||
|
{data.components.temperature}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between p-2 bg-base-200 rounded">
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<span className="text-sm">💧</span>
|
||||||
|
<span className="truncate">Humidity</span>
|
||||||
|
</div>
|
||||||
|
<span className={`font-bold ${getScoreColor(data.components.humidity)}`}>
|
||||||
|
{data.components.humidity}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between p-2 bg-base-200 rounded">
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<span className="text-sm">🌬️</span>
|
||||||
|
<span className="truncate">Air Quality</span>
|
||||||
|
</div>
|
||||||
|
<span className={`font-bold ${getScoreColor(data.components.air_quality)}`}>
|
||||||
|
{data.components.air_quality}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between p-2 bg-base-200 rounded">
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<span className="text-sm">🔊</span>
|
||||||
|
<span className="truncate">Acoustic</span>
|
||||||
|
</div>
|
||||||
|
<span className={`font-bold ${getScoreColor(data.components.acoustic)}`}>
|
||||||
|
{data.components.acoustic}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between p-2 bg-base-200 rounded col-span-2">
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<span className="text-sm">💡</span>
|
||||||
|
<span className="truncate">Lighting</span>
|
||||||
|
</div>
|
||||||
|
<span className={`font-bold ${getScoreColor(data.components.light)}`}>
|
||||||
|
{data.components.light}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Suggestions */}
|
||||||
|
{data.suggestions.length > 0 && (
|
||||||
|
<div className="mt-auto">
|
||||||
|
<div className="text-xs font-semibold mb-1">💡 Suggestions</div>
|
||||||
|
<div className="space-y-1 max-h-20 overflow-y-auto">
|
||||||
|
{data.suggestions.map((suggestion, i) => (
|
||||||
|
<div key={i} className="text-xs bg-warning/10 p-1 rounded border-l-2 border-warning">
|
||||||
|
{suggestion}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
103
frontend/src/components/widgets/GaugeWidget.tsx
Normal file
103
frontend/src/components/widgets/GaugeWidget.tsx
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
import { useTelemetrySeries } from '../../hooks'
|
||||||
|
import type { WidgetConfig } from '../../hooks'
|
||||||
|
|
||||||
|
interface GaugeWidgetProps {
|
||||||
|
config: WidgetConfig
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function GaugeWidget({ config }: GaugeWidgetProps) {
|
||||||
|
const { deviceIds, metricIds, timeframe, title } = config
|
||||||
|
|
||||||
|
const deviceId = deviceIds[0]
|
||||||
|
const metric = metricIds[0]
|
||||||
|
|
||||||
|
const { data, isLoading, error } = useTelemetrySeries({
|
||||||
|
deviceId,
|
||||||
|
metric,
|
||||||
|
hours: timeframe.hours,
|
||||||
|
startTime: timeframe.startTime,
|
||||||
|
endTime: timeframe.endTime,
|
||||||
|
limit: 1,
|
||||||
|
})
|
||||||
|
|
||||||
|
const latest = data[0]
|
||||||
|
const value = latest?.value || 0
|
||||||
|
|
||||||
|
// Simple gauge ranges (could be configured per metric)
|
||||||
|
const ranges = {
|
||||||
|
low: 33,
|
||||||
|
medium: 66,
|
||||||
|
high: 100,
|
||||||
|
}
|
||||||
|
|
||||||
|
const percentage = Math.min(100, Math.max(0, value))
|
||||||
|
let color = 'text-success'
|
||||||
|
if (percentage > ranges.medium) color = 'text-error'
|
||||||
|
else if (percentage > ranges.low) color = 'text-warning'
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<div className="card bg-base-200 animate-pulse">
|
||||||
|
<div className="card-body h-48"></div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<div className="card bg-error/10">
|
||||||
|
<div className="card-body">
|
||||||
|
<p className="text-error text-sm">Error: {error.message}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="card bg-base-100 shadow">
|
||||||
|
<div className="card-body items-center text-center">
|
||||||
|
<h3 className="card-title text-base">{title || metric}</h3>
|
||||||
|
<div className="relative w-32 h-32 mt-4">
|
||||||
|
<svg className="w-full h-full transform -rotate-90">
|
||||||
|
<circle
|
||||||
|
cx="64"
|
||||||
|
cy="64"
|
||||||
|
r="56"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="8"
|
||||||
|
fill="none"
|
||||||
|
className="text-base-300"
|
||||||
|
/>
|
||||||
|
<circle
|
||||||
|
cx="64"
|
||||||
|
cy="64"
|
||||||
|
r="56"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="8"
|
||||||
|
fill="none"
|
||||||
|
strokeDasharray={`${2 * Math.PI * 56}`}
|
||||||
|
strokeDashoffset={`${2 * Math.PI * 56 * (1 - percentage / 100)}`}
|
||||||
|
className={color}
|
||||||
|
strokeLinecap="round"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
<div className="absolute inset-0 flex items-center justify-center">
|
||||||
|
<div>
|
||||||
|
<div className={`text-3xl font-bold ${color}`}>
|
||||||
|
{value.toFixed(1)}
|
||||||
|
</div>
|
||||||
|
{latest?.unit && (
|
||||||
|
<div className="text-sm text-base-content/60">{latest.unit}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{latest && (
|
||||||
|
<div className="text-xs text-base-content/50 mt-4">
|
||||||
|
Updated {new Date(latest.time).toLocaleTimeString()}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
237
frontend/src/components/widgets/LineChartWidget.tsx
Normal file
237
frontend/src/components/widgets/LineChartWidget.tsx
Normal file
@@ -0,0 +1,237 @@
|
|||||||
|
import { useMemo } from 'react'
|
||||||
|
import {
|
||||||
|
LineChart,
|
||||||
|
Line,
|
||||||
|
XAxis,
|
||||||
|
YAxis,
|
||||||
|
CartesianGrid,
|
||||||
|
Tooltip,
|
||||||
|
Legend,
|
||||||
|
ResponsiveContainer,
|
||||||
|
} from 'recharts'
|
||||||
|
import { useTelemetrySeries } from '../../hooks'
|
||||||
|
import type { WidgetConfig } from '../../hooks'
|
||||||
|
import { formatMetricName } from '../../utils/formatters'
|
||||||
|
|
||||||
|
interface LineChartWidgetProps {
|
||||||
|
config: WidgetConfig
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper component to fetch data for a single metric
|
||||||
|
function useMultiMetricData(deviceId: string, metricIds: string[], timeframe: WidgetConfig['timeframe']) {
|
||||||
|
// Fetch data for each metric (React allows hooks in arrays when count is stable)
|
||||||
|
const metric1 = useTelemetrySeries({
|
||||||
|
deviceId,
|
||||||
|
metric: metricIds[0] || '',
|
||||||
|
hours: timeframe.hours,
|
||||||
|
startTime: timeframe.startTime,
|
||||||
|
endTime: timeframe.endTime,
|
||||||
|
})
|
||||||
|
|
||||||
|
const metric2 = useTelemetrySeries({
|
||||||
|
deviceId,
|
||||||
|
metric: metricIds[1] || '',
|
||||||
|
hours: timeframe.hours,
|
||||||
|
startTime: timeframe.startTime,
|
||||||
|
endTime: timeframe.endTime,
|
||||||
|
})
|
||||||
|
|
||||||
|
const metric3 = useTelemetrySeries({
|
||||||
|
deviceId,
|
||||||
|
metric: metricIds[2] || '',
|
||||||
|
hours: timeframe.hours,
|
||||||
|
startTime: timeframe.startTime,
|
||||||
|
endTime: timeframe.endTime,
|
||||||
|
})
|
||||||
|
|
||||||
|
const metric4 = useTelemetrySeries({
|
||||||
|
deviceId,
|
||||||
|
metric: metricIds[3] || '',
|
||||||
|
hours: timeframe.hours,
|
||||||
|
startTime: timeframe.startTime,
|
||||||
|
endTime: timeframe.endTime,
|
||||||
|
})
|
||||||
|
|
||||||
|
const metric5 = useTelemetrySeries({
|
||||||
|
deviceId,
|
||||||
|
metric: metricIds[4] || '',
|
||||||
|
hours: timeframe.hours,
|
||||||
|
startTime: timeframe.startTime,
|
||||||
|
endTime: timeframe.endTime,
|
||||||
|
})
|
||||||
|
|
||||||
|
const queries = [metric1, metric2, metric3, metric4, metric5].slice(0, metricIds.length)
|
||||||
|
|
||||||
|
return { queries, metricIds }
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function LineChartWidget({ config }: LineChartWidgetProps) {
|
||||||
|
const { deviceIds, metricIds, timeframe, visualization } = config
|
||||||
|
const deviceId = deviceIds[0]
|
||||||
|
|
||||||
|
const { queries } = useMultiMetricData(deviceId, metricIds, timeframe)
|
||||||
|
|
||||||
|
const isLoading = queries.some((q) => q.isLoading)
|
||||||
|
const error = queries.find((q) => q.error)?.error
|
||||||
|
|
||||||
|
// Combine data from all metrics into a single chart dataset
|
||||||
|
const chartData = useMemo(() => {
|
||||||
|
if (queries.length === 0 || !queries[0]?.data || queries[0].data.length === 0) return []
|
||||||
|
|
||||||
|
// Create a map of time -> { time, fullDateTime, metric1, metric2, ... }
|
||||||
|
const timeMap = new Map<string, Record<string, number | string>>()
|
||||||
|
|
||||||
|
queries.forEach((query, index) => {
|
||||||
|
const metric = metricIds[index]
|
||||||
|
if (!query.data) return
|
||||||
|
|
||||||
|
query.data.forEach((point) => {
|
||||||
|
const date = new Date(point.time)
|
||||||
|
|
||||||
|
// Short time for X-axis display (just time)
|
||||||
|
const timeStr = date.toLocaleString('en-US', {
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
})
|
||||||
|
|
||||||
|
// Full date/time for tooltip
|
||||||
|
const fullDateTime = date.toLocaleString('en-US', {
|
||||||
|
month: 'short',
|
||||||
|
day: 'numeric',
|
||||||
|
year: 'numeric',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
})
|
||||||
|
|
||||||
|
const key = date.getTime().toString()
|
||||||
|
|
||||||
|
if (!timeMap.has(key)) {
|
||||||
|
timeMap.set(key, {
|
||||||
|
time: timeStr,
|
||||||
|
fullDateTime: fullDateTime,
|
||||||
|
timestamp: date.getTime()
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const entry = timeMap.get(key)!
|
||||||
|
entry[metric] = point.value
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
// Sort by timestamp
|
||||||
|
return Array.from(timeMap.values()).sort((a, b) => {
|
||||||
|
return (a.timestamp as number) - (b.timestamp as number)
|
||||||
|
})
|
||||||
|
}, [queries, metricIds])
|
||||||
|
|
||||||
|
const colors = visualization?.colors || [
|
||||||
|
'#3b82f6', // blue
|
||||||
|
'#10b981', // green
|
||||||
|
'#f59e0b', // amber
|
||||||
|
'#ef4444', // red
|
||||||
|
'#8b5cf6', // purple
|
||||||
|
'#ec4899', // pink
|
||||||
|
]
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center h-full">
|
||||||
|
<span className="loading loading-spinner loading-lg"></span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<div className="alert alert-error">
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
className="stroke-current shrink-0 h-6 w-6"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth="2"
|
||||||
|
d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
<span>Error loading data: {error.message}</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (chartData.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className="alert alert-info">
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
className="stroke-current shrink-0 w-6 h-6"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth="2"
|
||||||
|
d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||||
|
></path>
|
||||||
|
</svg>
|
||||||
|
<span>No data available for this metric</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="card bg-base-100 h-full overflow-hidden">
|
||||||
|
<div className="card-body p-4">
|
||||||
|
<h3 className="card-title text-sm mb-2">
|
||||||
|
{config.title || metricIds.map(formatMetricName).join(' & ')}
|
||||||
|
</h3>
|
||||||
|
<ResponsiveContainer width="100%" height={visualization?.height || 280}>
|
||||||
|
<LineChart data={chartData} margin={{ top: 5, right: 20, left: 0, bottom: 50 }}>
|
||||||
|
{visualization?.showGrid !== false && (
|
||||||
|
<CartesianGrid strokeDasharray="3 3" opacity={0.3} />
|
||||||
|
)}
|
||||||
|
<XAxis
|
||||||
|
dataKey="time"
|
||||||
|
tick={{ fontSize: 11 }}
|
||||||
|
angle={-45}
|
||||||
|
textAnchor="end"
|
||||||
|
height={50}
|
||||||
|
/>
|
||||||
|
<YAxis tick={{ fontSize: 12 }} />
|
||||||
|
<Tooltip
|
||||||
|
contentStyle={{
|
||||||
|
backgroundColor: 'rgba(0, 0, 0, 0.8)',
|
||||||
|
border: 'none',
|
||||||
|
borderRadius: '8px',
|
||||||
|
color: 'white',
|
||||||
|
}}
|
||||||
|
labelFormatter={(label, payload) => {
|
||||||
|
// Use fullDateTime from the data point for tooltip
|
||||||
|
return payload && payload[0] ? payload[0].payload.fullDateTime : label
|
||||||
|
}}
|
||||||
|
formatter={(value: number) => [value.toFixed(2)]}
|
||||||
|
/>
|
||||||
|
{visualization?.showLegend !== false && <Legend />}
|
||||||
|
{metricIds.map((metric, index) => (
|
||||||
|
<Line
|
||||||
|
key={metric}
|
||||||
|
type="monotone"
|
||||||
|
dataKey={metric}
|
||||||
|
stroke={colors[index % colors.length]}
|
||||||
|
strokeWidth={2}
|
||||||
|
dot={{ r: 2, strokeWidth: 0 }}
|
||||||
|
activeDot={{ r: 4 }}
|
||||||
|
connectNulls={true}
|
||||||
|
name={formatMetricName(metric)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</LineChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
78
frontend/src/components/widgets/StatWidget.tsx
Normal file
78
frontend/src/components/widgets/StatWidget.tsx
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
import { useTelemetrySeries } from '../../hooks'
|
||||||
|
import type { WidgetConfig } from '../../hooks'
|
||||||
|
import { formatMetricName } from '../../utils/formatters'
|
||||||
|
|
||||||
|
interface StatWidgetProps {
|
||||||
|
config: WidgetConfig
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function StatWidgetProps({ config }: StatWidgetProps) {
|
||||||
|
const { deviceIds, metricIds, timeframe, title } = config
|
||||||
|
|
||||||
|
const deviceId = deviceIds[0]
|
||||||
|
const metric = metricIds[0]
|
||||||
|
|
||||||
|
const { data, isLoading, error } = useTelemetrySeries({
|
||||||
|
deviceId,
|
||||||
|
metric,
|
||||||
|
hours: timeframe.hours,
|
||||||
|
startTime: timeframe.startTime,
|
||||||
|
endTime: timeframe.endTime,
|
||||||
|
limit: 100,
|
||||||
|
})
|
||||||
|
|
||||||
|
const latest = data[0]
|
||||||
|
const values = data.map((d) => d.value)
|
||||||
|
const average = values.length > 0 ? values.reduce((a, b) => a + b, 0) / values.length : 0
|
||||||
|
const min = values.length > 0 ? Math.min(...values) : 0
|
||||||
|
const max = values.length > 0 ? Math.max(...values) : 0
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<div className="card bg-base-200 animate-pulse">
|
||||||
|
<div className="card-body h-32"></div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<div className="card bg-error/10">
|
||||||
|
<div className="card-body">
|
||||||
|
<p className="text-error text-sm">Error: {error.message}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="card bg-base-100 shadow">
|
||||||
|
<div className="card-body">
|
||||||
|
<div className="text-sm uppercase tracking-wide text-base-content/60">
|
||||||
|
{title || formatMetricName(metric)}
|
||||||
|
</div>
|
||||||
|
<div className="text-4xl font-bold text-primary">
|
||||||
|
{latest ? latest.value.toFixed(1) : '—'}
|
||||||
|
{latest?.unit && <span className="text-xl ml-2">{latest.unit}</span>}
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-3 gap-2 mt-4 text-sm">
|
||||||
|
<div>
|
||||||
|
<div className="text-base-content/60">Min</div>
|
||||||
|
<div className="font-semibold">{min.toFixed(1)}</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="text-base-content/60">Avg</div>
|
||||||
|
<div className="font-semibold">{average.toFixed(1)}</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="text-base-content/60">Max</div>
|
||||||
|
<div className="font-semibold">{max.toFixed(1)}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-base-content/50 mt-2">
|
||||||
|
{data.length} readings in last {timeframe.hours || 24}h
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
112
frontend/src/components/widgets/WeatherWidget.tsx
Normal file
112
frontend/src/components/widgets/WeatherWidget.tsx
Normal file
@@ -0,0 +1,112 @@
|
|||||||
|
import { useQuery } from '@tanstack/react-query'
|
||||||
|
import type { WidgetConfig } from '../../hooks'
|
||||||
|
import { weatherApi } from '../../api'
|
||||||
|
|
||||||
|
interface WeatherWidgetProps {
|
||||||
|
config: WidgetConfig
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function WeatherWidget({ config }: WeatherWidgetProps) {
|
||||||
|
// Get city from config or use default
|
||||||
|
const city = (config.visualization as Record<string, unknown>)?.city as string || 'Skopje'
|
||||||
|
|
||||||
|
const { data: weather, isLoading, error } = useQuery({
|
||||||
|
queryKey: ['weather', city],
|
||||||
|
queryFn: async () => {
|
||||||
|
const response = await weatherApi.getCurrent({ city })
|
||||||
|
return response.data
|
||||||
|
},
|
||||||
|
refetchInterval: 300000, // Refresh every 5 minutes
|
||||||
|
staleTime: 240000, // Consider fresh for 4 minutes
|
||||||
|
})
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<div className="widget-card card bg-base-100 h-full">
|
||||||
|
<div className="card-body flex items-center justify-center">
|
||||||
|
<span className="loading loading-spinner loading-lg"></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<div className="widget-card card bg-base-100 h-full">
|
||||||
|
<div className="card-body">
|
||||||
|
<h2 className="card-title text-sm truncate">{config.title}</h2>
|
||||||
|
<div className="flex flex-col items-center justify-center flex-1">
|
||||||
|
<p className="text-error">Failed to load weather data</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!weather) return null
|
||||||
|
|
||||||
|
// Weather code to icon mapping
|
||||||
|
const getWeatherIcon = (code: number) => {
|
||||||
|
if (code === 0 || code === 1) return '☀️' // Clear/Mainly clear
|
||||||
|
if (code === 2) return '⛅' // Partly cloudy
|
||||||
|
if (code === 3) return '☁️' // Overcast
|
||||||
|
if (code >= 45 && code <= 48) return '🌫️' // Fog
|
||||||
|
if (code >= 51 && code <= 55) return '🌦️' // Drizzle
|
||||||
|
if (code >= 61 && code <= 65) return '🌧️' // Rain
|
||||||
|
if (code >= 71 && code <= 77) return '🌨️' // Snow
|
||||||
|
if (code >= 80 && code <= 82) return '🌧️' // Rain showers
|
||||||
|
if (code >= 85 && code <= 86) return '🌨️' // Snow showers
|
||||||
|
if (code >= 95) return '⛈️' // Thunderstorm
|
||||||
|
return '🌡️'
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="widget-card card bg-base-100 h-full">
|
||||||
|
<div className="card-body">
|
||||||
|
<h2 className="card-title text-sm truncate">{config.title}</h2>
|
||||||
|
<div className="flex flex-col items-center justify-center flex-1">
|
||||||
|
{/* Weather Icon */}
|
||||||
|
<div className="text-5xl mb-1">{getWeatherIcon(weather.weather_code)}</div>
|
||||||
|
|
||||||
|
{/* Temperature */}
|
||||||
|
<div className="text-3xl font-bold">{weather.temperature.toFixed(1)}°C</div>
|
||||||
|
<div className="text-xs text-base-content/60">
|
||||||
|
Feels like {weather.apparent_temperature.toFixed(1)}°C
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Weather Description */}
|
||||||
|
<div className="badge badge-primary mt-1 truncate max-w-full">
|
||||||
|
{weather.weather_description}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Additional Info */}
|
||||||
|
<div className="grid grid-cols-2 gap-2 mt-2 w-full text-xs">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="opacity-60">💧</span>
|
||||||
|
<span>{weather.humidity}%</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="opacity-60">💨</span>
|
||||||
|
<span>{weather.wind_speed.toFixed(1)} km/h</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="opacity-60">☁️</span>
|
||||||
|
<span>{weather.cloud_cover}%</span>
|
||||||
|
</div>
|
||||||
|
{weather.precipitation > 0 && (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="opacity-60">🌧️</span>
|
||||||
|
<span>{weather.precipitation} mm</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Location */}
|
||||||
|
<div className="text-xs text-base-content/40 mt-3 px-2 w-full overflow-hidden">
|
||||||
|
<div className="truncate text-center">{weather.location}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
98
frontend/src/components/widgets/WidgetContainer.tsx
Normal file
98
frontend/src/components/widgets/WidgetContainer.tsx
Normal file
@@ -0,0 +1,98 @@
|
|||||||
|
import type { WidgetConfig } from '../../hooks'
|
||||||
|
import { widgetRegistry } from './registry'
|
||||||
|
|
||||||
|
interface WidgetContainerProps {
|
||||||
|
config: WidgetConfig
|
||||||
|
onRemove?: (id: string) => void
|
||||||
|
onEdit?: (id: string) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function WidgetContainer({ config, onRemove, onEdit }: WidgetContainerProps) {
|
||||||
|
const WidgetComponent = widgetRegistry[config.type]
|
||||||
|
|
||||||
|
if (!WidgetComponent) {
|
||||||
|
return (
|
||||||
|
<div className="card bg-error/10">
|
||||||
|
<div className="card-body">
|
||||||
|
<p className="text-error">Unknown widget type: {config.type}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="relative group h-full">
|
||||||
|
{/* Drag handle and actions */}
|
||||||
|
<div className="absolute top-0 left-0 right-0 z-10 flex items-center justify-between p-2 opacity-0 group-hover:opacity-100 transition-opacity bg-gradient-to-b from-base-300/90 to-transparent">
|
||||||
|
<div className="drag-handle cursor-move flex items-center gap-1 px-2 py-1 rounded bg-base-100/80 text-xs">
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
className="h-4 w-4"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke="currentColor"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={2}
|
||||||
|
d="M4 8h16M4 16h16"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
Drag
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-1">
|
||||||
|
{onEdit && (
|
||||||
|
<button
|
||||||
|
className="btn btn-xs btn-circle btn-ghost bg-base-100/80"
|
||||||
|
onClick={() => onEdit(config.id)}
|
||||||
|
title="Edit widget"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
className="h-4 w-4"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke="currentColor"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={2}
|
||||||
|
d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{onRemove && (
|
||||||
|
<button
|
||||||
|
className="btn btn-xs btn-circle btn-ghost bg-base-100/80"
|
||||||
|
onClick={() => onRemove(config.id)}
|
||||||
|
title="Remove widget"
|
||||||
|
>
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
className="h-4 w-4"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
stroke="currentColor"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth={2}
|
||||||
|
d="M6 18L18 6M6 6l12 12"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Actual widget */}
|
||||||
|
<div className="h-full">
|
||||||
|
<WidgetComponent config={config} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
6
frontend/src/components/widgets/index.ts
Normal file
6
frontend/src/components/widgets/index.ts
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
export { default as WidgetContainer } from './WidgetContainer'
|
||||||
|
export { default as LineChartWidget } from './LineChartWidget'
|
||||||
|
export { default as StatWidget } from './StatWidget'
|
||||||
|
export { default as GaugeWidget } from './GaugeWidget'
|
||||||
|
export { default as AiInsightWidget } from './AiInsightWidget'
|
||||||
|
export { widgetRegistry } from './registry'
|
||||||
25
frontend/src/components/widgets/registry.ts
Normal file
25
frontend/src/components/widgets/registry.ts
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
import type { ComponentType } from 'react'
|
||||||
|
import type { WidgetConfig, WidgetType } from '../../hooks'
|
||||||
|
|
||||||
|
import LineChartWidget from './LineChartWidget'
|
||||||
|
import StatWidget from './StatWidget'
|
||||||
|
import GaugeWidget from './GaugeWidget'
|
||||||
|
import AiInsightWidget from './AiInsightWidget'
|
||||||
|
import AirQualityWidget from './AirQualityWidget'
|
||||||
|
import WeatherWidget from './WeatherWidget'
|
||||||
|
import ComfortIndexWidget from './ComfortIndexWidget'
|
||||||
|
|
||||||
|
interface WidgetProps {
|
||||||
|
config: WidgetConfig
|
||||||
|
}
|
||||||
|
|
||||||
|
export const widgetRegistry: Record<WidgetType, ComponentType<WidgetProps>> = {
|
||||||
|
'line-chart': LineChartWidget,
|
||||||
|
'stat': StatWidget,
|
||||||
|
'gauge': GaugeWidget,
|
||||||
|
'ai-insight': AiInsightWidget,
|
||||||
|
'bar-chart': LineChartWidget,
|
||||||
|
'air-quality': AirQualityWidget,
|
||||||
|
'weather': WeatherWidget,
|
||||||
|
'comfort-index': ComfortIndexWidget,
|
||||||
|
}
|
||||||
25
frontend/src/components/widgets/widget-styles.css
Normal file
25
frontend/src/components/widgets/widget-styles.css
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
/* Widget-specific styling overrides */
|
||||||
|
|
||||||
|
/* Reduce card body padding for widgets - default is 2rem (32px), we want less */
|
||||||
|
.widget-card .card-body {
|
||||||
|
padding: 1rem; /* 16px instead of 32px */
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Lighter shadow for widgets */
|
||||||
|
.widget-card {
|
||||||
|
box-shadow: 0 1px 3px 0 rgb(0 0 0 / 0.1), 0 1px 2px -1px rgb(0 0 0 / 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Ensure cards fill their container properly */
|
||||||
|
.widget-card {
|
||||||
|
height: 100%;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.widget-card .card-body {
|
||||||
|
flex: 1;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
min-height: 0; /* Allow flex children to shrink */
|
||||||
|
}
|
||||||
4
frontend/src/hooks/index.ts
Normal file
4
frontend/src/hooks/index.ts
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
export { useTelemetrySeries } from './useTelemetrySeries'
|
||||||
|
export { useDeviceMetrics, useAllMetrics } from './useDeviceMetrics'
|
||||||
|
export { useDashboardConfig } from './useDashboardConfig'
|
||||||
|
export type { WidgetConfig, DashboardConfig, WidgetType } from './useDashboardConfig'
|
||||||
128
frontend/src/hooks/useDashboardConfig.ts
Normal file
128
frontend/src/hooks/useDashboardConfig.ts
Normal file
@@ -0,0 +1,128 @@
|
|||||||
|
import { useState, useEffect } from 'react'
|
||||||
|
|
||||||
|
export type WidgetType = 'line-chart' | 'gauge' | 'stat' | 'ai-insight' | 'bar-chart' | 'air-quality' | 'weather' | 'comfort-index'
|
||||||
|
|
||||||
|
export interface WidgetConfig {
|
||||||
|
id: string
|
||||||
|
type: WidgetType
|
||||||
|
title: string
|
||||||
|
deviceIds: string[]
|
||||||
|
metricIds: string[]
|
||||||
|
timeframe: {
|
||||||
|
hours?: number
|
||||||
|
startTime?: string
|
||||||
|
endTime?: string
|
||||||
|
}
|
||||||
|
visualization?: {
|
||||||
|
colors?: string[]
|
||||||
|
showLegend?: boolean
|
||||||
|
showGrid?: boolean
|
||||||
|
height?: number
|
||||||
|
city?: string
|
||||||
|
}
|
||||||
|
position?: {
|
||||||
|
x: number
|
||||||
|
y: number
|
||||||
|
w: number
|
||||||
|
h: number
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DashboardConfig {
|
||||||
|
widgets: WidgetConfig[]
|
||||||
|
layout: 'grid' | 'freeform'
|
||||||
|
refreshInterval?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
const DEFAULT_CONFIG: DashboardConfig = {
|
||||||
|
widgets: [],
|
||||||
|
layout: 'grid',
|
||||||
|
refreshInterval: 30000,
|
||||||
|
}
|
||||||
|
|
||||||
|
const STORAGE_KEY = 'iot-dashboard-config'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook to manage dashboard configuration with localStorage persistence
|
||||||
|
*/
|
||||||
|
export function useDashboardConfig() {
|
||||||
|
const [config, setConfig] = useState<DashboardConfig>(() => {
|
||||||
|
try {
|
||||||
|
const stored = localStorage.getItem(STORAGE_KEY)
|
||||||
|
if (stored) {
|
||||||
|
return JSON.parse(stored) as DashboardConfig
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to load dashboard config:', error)
|
||||||
|
}
|
||||||
|
return DEFAULT_CONFIG
|
||||||
|
})
|
||||||
|
|
||||||
|
// Persist to localStorage whenever config changes
|
||||||
|
useEffect(() => {
|
||||||
|
try {
|
||||||
|
localStorage.setItem(STORAGE_KEY, JSON.stringify(config))
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to save dashboard config:', error)
|
||||||
|
}
|
||||||
|
}, [config])
|
||||||
|
|
||||||
|
const addWidget = (widget: WidgetConfig) => {
|
||||||
|
setConfig((prev) => ({
|
||||||
|
...prev,
|
||||||
|
widgets: [...prev.widgets, widget],
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
const updateWidget = (id: string, updates: Partial<WidgetConfig>) => {
|
||||||
|
setConfig((prev) => ({
|
||||||
|
...prev,
|
||||||
|
widgets: prev.widgets.map((w) => (w.id === id ? { ...w, ...updates } : w)),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
const removeWidget = (id: string) => {
|
||||||
|
setConfig((prev) => ({
|
||||||
|
...prev,
|
||||||
|
widgets: prev.widgets.filter((w) => w.id !== id),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
const reorderWidgets = (widgets: WidgetConfig[]) => {
|
||||||
|
setConfig((prev) => ({
|
||||||
|
...prev,
|
||||||
|
widgets,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
const resetConfig = () => {
|
||||||
|
setConfig(DEFAULT_CONFIG)
|
||||||
|
}
|
||||||
|
|
||||||
|
const exportConfig = (): string => {
|
||||||
|
return JSON.stringify(config, null, 2)
|
||||||
|
}
|
||||||
|
|
||||||
|
const importConfig = (configOrJson: DashboardConfig | string) => {
|
||||||
|
try {
|
||||||
|
const imported = typeof configOrJson === 'string'
|
||||||
|
? JSON.parse(configOrJson) as DashboardConfig
|
||||||
|
: configOrJson
|
||||||
|
setConfig(imported)
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to import config:', error)
|
||||||
|
throw new Error('Invalid configuration')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
config,
|
||||||
|
addWidget,
|
||||||
|
updateWidget,
|
||||||
|
removeWidget,
|
||||||
|
reorderWidgets,
|
||||||
|
resetConfig,
|
||||||
|
exportConfig,
|
||||||
|
importConfig,
|
||||||
|
}
|
||||||
|
}
|
||||||
54
frontend/src/hooks/useDeviceMetrics.ts
Normal file
54
frontend/src/hooks/useDeviceMetrics.ts
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
import { useQuery } from '@tanstack/react-query'
|
||||||
|
import { devicesApi, telemetryApi } from '../api'
|
||||||
|
|
||||||
|
interface DeviceMetricsResult {
|
||||||
|
deviceId: string
|
||||||
|
deviceName: string
|
||||||
|
metrics: string[]
|
||||||
|
isLoading: boolean
|
||||||
|
error: Error | null
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook to fetch available metrics for a specific device
|
||||||
|
*/
|
||||||
|
export function useDeviceMetrics(deviceId: string | undefined): DeviceMetricsResult {
|
||||||
|
const { data, isLoading, error } = useQuery({
|
||||||
|
queryKey: ['device', deviceId, 'metrics'],
|
||||||
|
queryFn: async () => {
|
||||||
|
if (!deviceId) return null
|
||||||
|
const response = await devicesApi.getMetrics(deviceId)
|
||||||
|
return response.data
|
||||||
|
},
|
||||||
|
enabled: !!deviceId,
|
||||||
|
staleTime: 60000, // Cache for 1 minute
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
deviceId: data?.device_id || '',
|
||||||
|
deviceName: data?.device_name || '',
|
||||||
|
metrics: data?.metrics || [],
|
||||||
|
isLoading,
|
||||||
|
error: error as Error | null,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook to fetch all available metrics across all devices
|
||||||
|
*/
|
||||||
|
export function useAllMetrics() {
|
||||||
|
const { data, isLoading, error } = useQuery({
|
||||||
|
queryKey: ['telemetry', 'metrics'],
|
||||||
|
queryFn: async () => {
|
||||||
|
const response = await telemetryApi.getMetrics()
|
||||||
|
return response.data.metrics
|
||||||
|
},
|
||||||
|
staleTime: 60000,
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
metrics: data || [],
|
||||||
|
isLoading,
|
||||||
|
error: error as Error | null,
|
||||||
|
}
|
||||||
|
}
|
||||||
75
frontend/src/hooks/useTelemetrySeries.ts
Normal file
75
frontend/src/hooks/useTelemetrySeries.ts
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
import { useQuery } from '@tanstack/react-query'
|
||||||
|
import { telemetryApi } from '../api'
|
||||||
|
import type { Telemetry } from '../types/api'
|
||||||
|
|
||||||
|
interface TelemetrySeriesParams {
|
||||||
|
deviceId?: string
|
||||||
|
metric?: string
|
||||||
|
hours?: number
|
||||||
|
startTime?: string
|
||||||
|
endTime?: string
|
||||||
|
limit?: number
|
||||||
|
enabled?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TelemetrySeries {
|
||||||
|
data: Telemetry[]
|
||||||
|
isLoading: boolean
|
||||||
|
isFetching: boolean
|
||||||
|
error: Error | null
|
||||||
|
refetch: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook to fetch telemetry data series with flexible filtering
|
||||||
|
*/
|
||||||
|
export function useTelemetrySeries({
|
||||||
|
deviceId,
|
||||||
|
metric,
|
||||||
|
hours = 24,
|
||||||
|
startTime,
|
||||||
|
endTime,
|
||||||
|
limit = 1000,
|
||||||
|
enabled = true,
|
||||||
|
}: TelemetrySeriesParams): TelemetrySeries {
|
||||||
|
const { data, isLoading, isFetching, error, refetch } = useQuery({
|
||||||
|
queryKey: ['telemetry', 'series', { deviceId, metric, hours, startTime, endTime, limit }],
|
||||||
|
queryFn: async () => {
|
||||||
|
const params: {
|
||||||
|
device_id?: string
|
||||||
|
metric?: string
|
||||||
|
hours?: number
|
||||||
|
start_time?: string
|
||||||
|
end_time?: string
|
||||||
|
page_size: number
|
||||||
|
} = {
|
||||||
|
page_size: limit,
|
||||||
|
}
|
||||||
|
|
||||||
|
if (deviceId) params.device_id = deviceId
|
||||||
|
if (metric) params.metric = metric
|
||||||
|
if (startTime) params.start_time = startTime
|
||||||
|
if (endTime) params.end_time = endTime
|
||||||
|
if (!startTime && !endTime && hours) params.hours = hours
|
||||||
|
|
||||||
|
const response = await telemetryApi.query(params)
|
||||||
|
|
||||||
|
// Handle paginated response
|
||||||
|
if ('results' in response.data) {
|
||||||
|
return response.data.results
|
||||||
|
}
|
||||||
|
|
||||||
|
return response.data as Telemetry[]
|
||||||
|
},
|
||||||
|
enabled,
|
||||||
|
refetchInterval: 30000, // Refresh every 30 seconds
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
data: data || [],
|
||||||
|
isLoading,
|
||||||
|
isFetching,
|
||||||
|
error: error as Error | null,
|
||||||
|
refetch,
|
||||||
|
}
|
||||||
|
}
|
||||||
126
frontend/src/index.css
Normal file
126
frontend/src/index.css
Normal file
@@ -0,0 +1,126 @@
|
|||||||
|
@import "tailwindcss";
|
||||||
|
@plugin "daisyui";
|
||||||
|
|
||||||
|
/* DaisyUI theme configuration */
|
||||||
|
@theme {
|
||||||
|
--dui-themes: light, dark, cupcake, corporate;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Custom scrollbar styles */
|
||||||
|
::-webkit-scrollbar {
|
||||||
|
width: 8px;
|
||||||
|
height: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-track {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-thumb {
|
||||||
|
background: rgba(0, 0, 0, 0.2);
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-thumb:hover {
|
||||||
|
background: rgba(0, 0, 0, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Dark mode scrollbar */
|
||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
|
::-webkit-scrollbar-thumb {
|
||||||
|
background: rgba(255, 255, 255, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-thumb:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.3);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Smooth transitions */
|
||||||
|
.btn,
|
||||||
|
.card {
|
||||||
|
transition: all 0.2s ease-in-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card:hover {
|
||||||
|
transform: translateY(-2px);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Animations */
|
||||||
|
@keyframes fadeInUp {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(20px);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.stats {
|
||||||
|
animation: fadeInUp 0.5s ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Radix UI Dialog/AlertDialog overlays and content */
|
||||||
|
@keyframes overlayShow {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes contentShow {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translate(-50%, -48%) scale(0.96);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translate(-50%, -50%) scale(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Dialog/AlertDialog Overlay */
|
||||||
|
[data-radix-dialog-overlay],
|
||||||
|
[data-radix-alert-dialog-overlay] {
|
||||||
|
background-color: rgba(0, 0, 0, 0.5);
|
||||||
|
position: fixed;
|
||||||
|
inset: 0;
|
||||||
|
animation: overlayShow 150ms cubic-bezier(0.16, 1, 0.3, 1);
|
||||||
|
z-index: 50;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Dialog/AlertDialog Content */
|
||||||
|
[data-radix-dialog-content],
|
||||||
|
[data-radix-alert-dialog-content] {
|
||||||
|
background-color: white;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -2px rgba(0, 0, 0, 0.05);
|
||||||
|
position: fixed;
|
||||||
|
top: 50%;
|
||||||
|
left: 50%;
|
||||||
|
transform: translate(-50%, -50%);
|
||||||
|
width: 90vw;
|
||||||
|
max-width: 450px;
|
||||||
|
max-height: 85vh;
|
||||||
|
padding: 1.5rem;
|
||||||
|
animation: contentShow 150ms cubic-bezier(0.16, 1, 0.3, 1);
|
||||||
|
z-index: 51;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-radix-dialog-content]:focus,
|
||||||
|
[data-radix-alert-dialog-content]:focus {
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Dark mode support for dialogs */
|
||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
|
[data-radix-dialog-content],
|
||||||
|
[data-radix-alert-dialog-content] {
|
||||||
|
background-color: #1f2937;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
|
}
|
||||||
36
frontend/src/lib/api-client.ts
Normal file
36
frontend/src/lib/api-client.ts
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
import axios from 'axios';
|
||||||
|
|
||||||
|
// Use Vite proxy in development, or env variable in production
|
||||||
|
const API_BASE_URL = import.meta.env.VITE_API_URL || '/api';
|
||||||
|
|
||||||
|
export const apiClient = axios.create({
|
||||||
|
baseURL: API_BASE_URL,
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
withCredentials: true, // For session auth
|
||||||
|
});
|
||||||
|
|
||||||
|
// Add request interceptor for JWT token (if using JWT)
|
||||||
|
apiClient.interceptors.request.use((config) => {
|
||||||
|
const token = localStorage.getItem('access_token');
|
||||||
|
if (token) {
|
||||||
|
config.headers.Authorization = `Bearer ${token}`;
|
||||||
|
}
|
||||||
|
return config;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Add response interceptor for error handling
|
||||||
|
apiClient.interceptors.response.use(
|
||||||
|
(response) => response,
|
||||||
|
(error) => {
|
||||||
|
if (error.response?.status === 401) {
|
||||||
|
// Handle unauthorized - redirect to login
|
||||||
|
localStorage.removeItem('access_token');
|
||||||
|
// window.location.href = '/login';
|
||||||
|
}
|
||||||
|
return Promise.reject(error);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
export default apiClient;
|
||||||
29
frontend/src/lib/utils.ts
Normal file
29
frontend/src/lib/utils.ts
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
import { type ClassValue, clsx } from "clsx"
|
||||||
|
import { twMerge } from "tailwind-merge"
|
||||||
|
|
||||||
|
export function cn(...inputs: ClassValue[]) {
|
||||||
|
return twMerge(clsx(inputs))
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatDate(date: Date | string): string {
|
||||||
|
const d = typeof date === 'string' ? new Date(date) : date
|
||||||
|
return d.toLocaleDateString() + ' ' + d.toLocaleTimeString()
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatRelativeTime(date: Date | string): string {
|
||||||
|
const d = typeof date === 'string' ? new Date(date) : date
|
||||||
|
const now = new Date()
|
||||||
|
const diff = now.getTime() - d.getTime()
|
||||||
|
|
||||||
|
const seconds = Math.floor(diff / 1000)
|
||||||
|
const minutes = Math.floor(seconds / 60)
|
||||||
|
const hours = Math.floor(minutes / 60)
|
||||||
|
const days = Math.floor(hours / 24)
|
||||||
|
|
||||||
|
if (seconds < 60) return `${seconds}s ago`
|
||||||
|
if (minutes < 60) return `${minutes}m ago`
|
||||||
|
if (hours < 24) return `${hours}h ago`
|
||||||
|
if (days < 30) return `${days}d ago`
|
||||||
|
|
||||||
|
return formatDate(d)
|
||||||
|
}
|
||||||
10
frontend/src/main.tsx
Normal file
10
frontend/src/main.tsx
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
import { StrictMode } from 'react'
|
||||||
|
import { createRoot } from 'react-dom/client'
|
||||||
|
import './index.css'
|
||||||
|
import App from './App.tsx'
|
||||||
|
|
||||||
|
createRoot(document.getElementById('root')!).render(
|
||||||
|
<StrictMode>
|
||||||
|
<App />
|
||||||
|
</StrictMode>,
|
||||||
|
)
|
||||||
160
frontend/src/pages/AddDevice.tsx
Normal file
160
frontend/src/pages/AddDevice.tsx
Normal file
@@ -0,0 +1,160 @@
|
|||||||
|
import { Link } from 'react-router-dom'
|
||||||
|
import { useState } from 'react'
|
||||||
|
import { useForm } from 'react-hook-form'
|
||||||
|
import { useMutation, useQueryClient } from '@tanstack/react-query'
|
||||||
|
import toast from 'react-hot-toast'
|
||||||
|
import type { AxiosError } from 'axios'
|
||||||
|
import { devicesApi } from '../api'
|
||||||
|
import DeviceCredentialsDialog from '../components/DeviceCredentialsDialog'
|
||||||
|
import type { DeviceRegistrationRequest, DeviceRegistrationResponse } from '../types/api'
|
||||||
|
|
||||||
|
type DeviceRegistrationForm = DeviceRegistrationRequest
|
||||||
|
|
||||||
|
export default function AddDevice() {
|
||||||
|
const queryClient = useQueryClient()
|
||||||
|
const {
|
||||||
|
register,
|
||||||
|
handleSubmit,
|
||||||
|
formState: { errors },
|
||||||
|
reset,
|
||||||
|
} = useForm<DeviceRegistrationForm>({
|
||||||
|
defaultValues: {
|
||||||
|
protocol: 'mqtt',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const [credentials, setCredentials] = useState<DeviceRegistrationResponse | null>(null)
|
||||||
|
const [credentialsOpen, setCredentialsOpen] = useState(false)
|
||||||
|
|
||||||
|
const registerMutation = useMutation({
|
||||||
|
mutationFn: (payload: DeviceRegistrationRequest) => devicesApi.create(payload),
|
||||||
|
onSuccess: (response) => {
|
||||||
|
setCredentials(response.data)
|
||||||
|
setCredentialsOpen(true)
|
||||||
|
toast.success('Device registered successfully')
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['devices'] })
|
||||||
|
reset({ name: '', location: '', protocol: 'mqtt' })
|
||||||
|
},
|
||||||
|
onError: (error) => {
|
||||||
|
const axiosError = error as AxiosError<{ detail?: string }>
|
||||||
|
const message = axiosError.response?.data?.detail || axiosError.message
|
||||||
|
toast.error(`Failed to register device: ${message}`)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const onSubmit = (data: DeviceRegistrationForm) => {
|
||||||
|
if (data.protocol !== 'mqtt') {
|
||||||
|
toast.error('Only MQTT devices are supported right now')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
registerMutation.mutate({
|
||||||
|
name: data.name.trim(),
|
||||||
|
location: data.location?.trim() || undefined,
|
||||||
|
protocol: 'mqtt',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="p-6">
|
||||||
|
<div className="mb-6">
|
||||||
|
<Link to="/devices" className="btn btn-ghost btn-sm mb-4">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5 mr-2" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 19l-7-7m0 0l7-7m-7 7h18" />
|
||||||
|
</svg>
|
||||||
|
Back to Devices
|
||||||
|
</Link>
|
||||||
|
<h1 className="text-3xl font-bold">Add New Device</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="card bg-base-100 shadow-xl max-w-2xl">
|
||||||
|
<div className="card-body">
|
||||||
|
<h2 className="card-title">Device Registration</h2>
|
||||||
|
<p className="text-sm opacity-70 mb-4">
|
||||||
|
Register a new IoT device. For MQTT devices, a certificate will be automatically generated.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<form className="space-y-4" onSubmit={handleSubmit(onSubmit)}>
|
||||||
|
<div className="form-control">
|
||||||
|
<label className="label">
|
||||||
|
<span className="label-text font-semibold">Device Name *</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="e.g., Office Temperature Sensor"
|
||||||
|
className={`input input-bordered w-full ${errors.name ? 'input-error' : ''}`}
|
||||||
|
{...register('name', { required: 'Device name is required' })}
|
||||||
|
/>
|
||||||
|
{errors.name && (
|
||||||
|
<label className="label">
|
||||||
|
<span className="label-text-alt text-error">{errors.name.message}</span>
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="form-control">
|
||||||
|
<label className="label">
|
||||||
|
<span className="label-text font-semibold">Location</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="e.g., Office Room 101"
|
||||||
|
className="input input-bordered w-full"
|
||||||
|
{...register('location')}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="form-control">
|
||||||
|
<label className="label">
|
||||||
|
<span className="label-text font-semibold">Protocol *</span>
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
className="select select-bordered w-full"
|
||||||
|
{...register('protocol')}
|
||||||
|
>
|
||||||
|
<option value="mqtt">MQTT (with mTLS)</option>
|
||||||
|
<option value="http" disabled>
|
||||||
|
HTTP (coming soon)
|
||||||
|
</option>
|
||||||
|
<option value="webhook" disabled>
|
||||||
|
Webhook (coming soon)
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
<label className="label">
|
||||||
|
<span className="label-text-alt">MQTT devices will receive a certificate for secure communication</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="card-actions justify-end mt-6">
|
||||||
|
<Link to="/devices" className="btn btn-ghost">
|
||||||
|
Cancel
|
||||||
|
</Link>
|
||||||
|
<button type="submit" className="btn btn-primary" disabled={registerMutation.isPending}>
|
||||||
|
{registerMutation.isPending ? (
|
||||||
|
<>
|
||||||
|
<span className="loading loading-spinner loading-sm" />
|
||||||
|
Registering...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
'Register Device'
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DeviceCredentialsDialog
|
||||||
|
open={credentialsOpen}
|
||||||
|
credentials={credentials}
|
||||||
|
deviceName={credentials?.device_id}
|
||||||
|
onOpenChange={(open) => {
|
||||||
|
setCredentialsOpen(open)
|
||||||
|
if (!open) {
|
||||||
|
setCredentials(null)
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
199
frontend/src/pages/Dashboard.tsx
Normal file
199
frontend/src/pages/Dashboard.tsx
Normal file
@@ -0,0 +1,199 @@
|
|||||||
|
import { useState, useRef, useEffect } from 'react'
|
||||||
|
import GridLayout from 'react-grid-layout'
|
||||||
|
import 'react-grid-layout/css/styles.css'
|
||||||
|
import { useDashboardConfig } from '../hooks'
|
||||||
|
import { WidgetContainer } from '../components/widgets'
|
||||||
|
import AddWidgetModal from '../components/AddWidgetModal'
|
||||||
|
import EditWidgetModal from '../components/EditWidgetModal'
|
||||||
|
|
||||||
|
export default function Dashboard() {
|
||||||
|
const { config, addWidget, removeWidget, updateWidget, exportConfig, importConfig } = useDashboardConfig()
|
||||||
|
const [isModalOpen, setIsModalOpen] = useState(false)
|
||||||
|
const [editingWidget, setEditingWidget] = useState<string | null>(null)
|
||||||
|
const [gridWidth, setGridWidth] = useState(1200)
|
||||||
|
const gridContainerRef = useRef<HTMLDivElement>(null)
|
||||||
|
|
||||||
|
// Update grid width on resize
|
||||||
|
useEffect(() => {
|
||||||
|
const updateWidth = () => {
|
||||||
|
if (gridContainerRef.current) {
|
||||||
|
setGridWidth(gridContainerRef.current.offsetWidth)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
updateWidth()
|
||||||
|
window.addEventListener('resize', updateWidth)
|
||||||
|
return () => window.removeEventListener('resize', updateWidth)
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const handleLayoutChange = (newLayout: GridLayout.Layout[]) => {
|
||||||
|
// Update widget positions when layout changes
|
||||||
|
newLayout.forEach((item) => {
|
||||||
|
const widget = config.widgets.find((w) => w.id === item.i)
|
||||||
|
if (widget) {
|
||||||
|
updateWidget(item.i, {
|
||||||
|
position: {
|
||||||
|
x: item.x,
|
||||||
|
y: item.y,
|
||||||
|
w: item.w,
|
||||||
|
h: item.h,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const layout = config.widgets.map((widget) => ({
|
||||||
|
i: widget.id,
|
||||||
|
x: widget.position?.x || 0,
|
||||||
|
y: widget.position?.y || 0,
|
||||||
|
w: widget.position?.w || 1,
|
||||||
|
h: widget.position?.h || 2,
|
||||||
|
minW: 1,
|
||||||
|
minH: 1,
|
||||||
|
maxW: 4,
|
||||||
|
}))
|
||||||
|
|
||||||
|
const handleExport = () => {
|
||||||
|
const json = exportConfig()
|
||||||
|
const blob = new Blob([json], { type: 'application/json' })
|
||||||
|
const url = URL.createObjectURL(blob)
|
||||||
|
const a = document.createElement('a')
|
||||||
|
a.href = url
|
||||||
|
a.download = `dashboard-config-${new Date().toISOString().split('T')[0]}.json`
|
||||||
|
a.click()
|
||||||
|
URL.revokeObjectURL(url)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleImport = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
const file = e.target.files?.[0]
|
||||||
|
if (!file) return
|
||||||
|
const reader = new FileReader()
|
||||||
|
reader.onload = (event) => {
|
||||||
|
try {
|
||||||
|
const text = event.target?.result as string
|
||||||
|
const parsed = JSON.parse(text)
|
||||||
|
importConfig(parsed)
|
||||||
|
alert('Dashboard configuration imported successfully!')
|
||||||
|
} catch (error) {
|
||||||
|
alert('Failed to import configuration')
|
||||||
|
console.error(error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
reader.readAsText(file)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="p-6 space-y-6">
|
||||||
|
<div className="flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-3xl font-bold">Dashboard</h1>
|
||||||
|
<p className="text-base-content/70">
|
||||||
|
Customize your view with modular widgets
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button
|
||||||
|
className="btn btn-outline btn-sm"
|
||||||
|
onClick={handleExport}
|
||||||
|
>
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" />
|
||||||
|
</svg>
|
||||||
|
Export
|
||||||
|
</button>
|
||||||
|
<label className="btn btn-outline btn-sm">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-8l-4-4m0 0L8 8m4-4v12" />
|
||||||
|
</svg>
|
||||||
|
Import
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
accept="application/json"
|
||||||
|
className="hidden"
|
||||||
|
onChange={handleImport}
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
<button
|
||||||
|
className="btn btn-primary btn-sm"
|
||||||
|
onClick={() => setIsModalOpen(true)}
|
||||||
|
>
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
|
||||||
|
</svg>
|
||||||
|
Add Widget
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{config.widgets.length === 0 ? (
|
||||||
|
<div className="card bg-base-200 shadow-lg">
|
||||||
|
<div className="card-body items-center text-center py-16">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" className="h-24 w-24 text-base-content/20" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z" />
|
||||||
|
</svg>
|
||||||
|
<h2 className="text-2xl font-bold mt-4">No Widgets Yet</h2>
|
||||||
|
<p className="text-base-content/60 max-w-md">
|
||||||
|
Get started by adding your first widget. Choose from line charts, stat cards, gauges, or AI insights.
|
||||||
|
</p>
|
||||||
|
<button
|
||||||
|
className="btn btn-primary mt-6"
|
||||||
|
onClick={() => setIsModalOpen(true)}
|
||||||
|
>
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
|
||||||
|
</svg>
|
||||||
|
Add Your First Widget
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="w-full" ref={gridContainerRef}>
|
||||||
|
<GridLayout
|
||||||
|
className="layout"
|
||||||
|
layout={layout}
|
||||||
|
cols={4}
|
||||||
|
rowHeight={150}
|
||||||
|
width={gridWidth}
|
||||||
|
onLayoutChange={handleLayoutChange}
|
||||||
|
draggableHandle=".drag-handle"
|
||||||
|
compactType="vertical"
|
||||||
|
preventCollision={false}
|
||||||
|
isResizable={true}
|
||||||
|
isDraggable={true}
|
||||||
|
margin={[12, 12]}
|
||||||
|
>
|
||||||
|
{config.widgets.map((widget) => (
|
||||||
|
<div key={widget.id} className="h-full">
|
||||||
|
<WidgetContainer
|
||||||
|
config={widget}
|
||||||
|
onRemove={() => removeWidget(widget.id)}
|
||||||
|
onEdit={() => setEditingWidget(widget.id)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</GridLayout>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<AddWidgetModal
|
||||||
|
isOpen={isModalOpen}
|
||||||
|
onClose={() => setIsModalOpen(false)}
|
||||||
|
onAdd={(widget) => {
|
||||||
|
addWidget(widget)
|
||||||
|
setIsModalOpen(false)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<EditWidgetModal
|
||||||
|
isOpen={editingWidget !== null}
|
||||||
|
widget={config.widgets.find((w) => w.id === editingWidget) || null}
|
||||||
|
onClose={() => setEditingWidget(null)}
|
||||||
|
onSave={(widgetId, updates) => {
|
||||||
|
updateWidget(widgetId, updates)
|
||||||
|
setEditingWidget(null)
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
187
frontend/src/pages/DeviceDetail.tsx
Normal file
187
frontend/src/pages/DeviceDetail.tsx
Normal file
@@ -0,0 +1,187 @@
|
|||||||
|
import { useParams, Link, useNavigate } from 'react-router-dom'
|
||||||
|
import { useQuery } from '@tanstack/react-query'
|
||||||
|
import { useState } from 'react'
|
||||||
|
import { devicesApi } from '../api'
|
||||||
|
import DeleteDeviceDialog from '../components/DeleteDeviceDialog'
|
||||||
|
import RenewDialog from '../components/RenewDialog'
|
||||||
|
import RevokeDialog from '../components/RevokeDialog'
|
||||||
|
|
||||||
|
export default function DeviceDetail() {
|
||||||
|
const { id } = useParams<{ id: string }>()
|
||||||
|
const navigate = useNavigate()
|
||||||
|
const [deleteOpen, setDeleteOpen] = useState(false)
|
||||||
|
const [renewOpen, setRenewOpen] = useState(false)
|
||||||
|
const [revokeOpen, setRevokeOpen] = useState(false)
|
||||||
|
|
||||||
|
const { data: device, isLoading } = useQuery({
|
||||||
|
queryKey: ['device', id],
|
||||||
|
queryFn: async () => {
|
||||||
|
const response = await devicesApi.getOne(id!)
|
||||||
|
return response.data
|
||||||
|
},
|
||||||
|
enabled: !!id,
|
||||||
|
})
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center min-h-screen">
|
||||||
|
<span className="loading loading-spinner loading-lg"></span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!device) {
|
||||||
|
return (
|
||||||
|
<div className="p-6">
|
||||||
|
<div className="alert alert-error">
|
||||||
|
<span>Device not found</span>
|
||||||
|
</div>
|
||||||
|
<Link to="/devices" className="btn btn-ghost mt-4">
|
||||||
|
Back to Device List
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="p-6">
|
||||||
|
<div className="mb-6">
|
||||||
|
<Link to="/devices" className="btn btn-ghost btn-sm mb-4">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5 mr-2" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 19l-7-7m0 0l7-7m-7 7h18" />
|
||||||
|
</svg>
|
||||||
|
Back to Devices
|
||||||
|
</Link>
|
||||||
|
<h1 className="text-3xl font-bold">Device Details</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="card bg-base-100 shadow-xl">
|
||||||
|
<div className="card-body">
|
||||||
|
<h2 className="card-title text-2xl mb-4">{device.name}</h2>
|
||||||
|
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="table">
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<th className="w-1/3">Device ID:</th>
|
||||||
|
<td><code className="bg-base-200 px-3 py-1 rounded">{device.id}</code></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<th>Location:</th>
|
||||||
|
<td>{device.location || '—'}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<th>Protocol:</th>
|
||||||
|
<td>
|
||||||
|
<div className="badge badge-info">{device.protocol.toUpperCase()}</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<th>Status:</th>
|
||||||
|
<td>
|
||||||
|
<div className={`badge ${device.is_active ? 'badge-success' : 'badge-ghost'}`}>
|
||||||
|
{device.is_active ? 'Active' : 'Inactive'}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<th>Created:</th>
|
||||||
|
<td>{new Date(device.created_at).toLocaleString()}</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Certificate Information for MQTT devices */}
|
||||||
|
{device.protocol === 'mqtt' && device.active_certificate && (
|
||||||
|
<div className="mt-6">
|
||||||
|
<h3 className="text-xl font-bold mb-4">Certificate Information</h3>
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="table">
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<th className="w-1/3">Certificate ID:</th>
|
||||||
|
<td><code className="bg-base-200 px-3 py-1 rounded">{device.active_certificate.id}</code></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<th>Issued At:</th>
|
||||||
|
<td>{new Date(device.active_certificate.issued_at).toLocaleString()}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<th>Expires At:</th>
|
||||||
|
<td>{new Date(device.active_certificate.expires_at).toLocaleString()}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<th>Days Until Expiry:</th>
|
||||||
|
<td>
|
||||||
|
<span className={`font-semibold ${
|
||||||
|
device.active_certificate.days_until_expiry < 30 ? 'text-warning' :
|
||||||
|
device.active_certificate.days_until_expiry < 7 ? 'text-error' :
|
||||||
|
'text-success'
|
||||||
|
}`}>
|
||||||
|
{device.active_certificate.days_until_expiry} days
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<th>Status:</th>
|
||||||
|
<td>
|
||||||
|
{device.active_certificate.revoked_at ? (
|
||||||
|
<div className="badge badge-error">Revoked</div>
|
||||||
|
) : device.active_certificate.is_expired ? (
|
||||||
|
<div className="badge badge-error">Expired</div>
|
||||||
|
) : device.active_certificate.is_expiring_soon ? (
|
||||||
|
<div className="badge badge-warning">Expiring Soon</div>
|
||||||
|
) : (
|
||||||
|
<div className="badge badge-success">Active</div>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Action Buttons */}
|
||||||
|
<div className="card-actions justify-end mt-6">
|
||||||
|
{device.protocol === 'mqtt' && (
|
||||||
|
<>
|
||||||
|
<button className="btn btn-outline btn-warning" onClick={() => setRenewOpen(true)}>
|
||||||
|
Renew Certificate
|
||||||
|
</button>
|
||||||
|
<button className="btn btn-outline btn-error" onClick={() => setRevokeOpen(true)}>
|
||||||
|
Revoke Certificate
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<button className="btn btn-error" onClick={() => setDeleteOpen(true)}>
|
||||||
|
Delete Device
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DeleteDeviceDialog
|
||||||
|
device={device}
|
||||||
|
open={deleteOpen}
|
||||||
|
onOpenChange={(open) => setDeleteOpen(open)}
|
||||||
|
onDeleted={() => navigate('/devices')}
|
||||||
|
/>
|
||||||
|
{device.protocol === 'mqtt' && (
|
||||||
|
<>
|
||||||
|
<RenewDialog
|
||||||
|
device={device}
|
||||||
|
open={renewOpen}
|
||||||
|
onOpenChange={(open) => setRenewOpen(open)}
|
||||||
|
/>
|
||||||
|
<RevokeDialog
|
||||||
|
device={device}
|
||||||
|
open={revokeOpen}
|
||||||
|
onOpenChange={(open) => setRevokeOpen(open)}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
280
frontend/src/pages/DeviceList.tsx
Normal file
280
frontend/src/pages/DeviceList.tsx
Normal file
@@ -0,0 +1,280 @@
|
|||||||
|
import { Link } from 'react-router-dom'
|
||||||
|
import { useQuery } from '@tanstack/react-query'
|
||||||
|
import { useState } from 'react'
|
||||||
|
import { dashboardApi, devicesApi } from '../api'
|
||||||
|
import type { DashboardOverview, Device } from '../types/api'
|
||||||
|
import DeleteDeviceDialog from '../components/DeleteDeviceDialog'
|
||||||
|
import RevokeDialog from '../components/RevokeDialog'
|
||||||
|
import RenewDialog from '../components/RenewDialog'
|
||||||
|
|
||||||
|
export default function DeviceList() {
|
||||||
|
const [deleteDevice, setDeleteDevice] = useState<Device | null>(null)
|
||||||
|
const [revokeDevice, setRevokeDevice] = useState<Device | null>(null)
|
||||||
|
const [renewDevice, setRenewDevice] = useState<Device | null>(null)
|
||||||
|
const { data: devicesData, isLoading: devicesLoading } = useQuery({
|
||||||
|
queryKey: ['devices'],
|
||||||
|
queryFn: async () => {
|
||||||
|
const response = await devicesApi.getAll()
|
||||||
|
return response.data
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const { data: overview } = useQuery({
|
||||||
|
queryKey: ['dashboard', 'overview'],
|
||||||
|
queryFn: async (): Promise<DashboardOverview> => {
|
||||||
|
const response = await dashboardApi.getOverview()
|
||||||
|
return response.data
|
||||||
|
},
|
||||||
|
staleTime: 5000,
|
||||||
|
})
|
||||||
|
|
||||||
|
const devices = devicesData?.results || []
|
||||||
|
|
||||||
|
if (devicesLoading) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center min-h-screen">
|
||||||
|
<span className="loading loading-spinner loading-lg"></span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="p-6">
|
||||||
|
<div className="flex justify-between items-center mb-6">
|
||||||
|
<h1 className="text-3xl font-bold">Manage Devices</h1>
|
||||||
|
<Link to="/devices/add" className="btn btn-primary">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5 mr-2" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
|
||||||
|
</svg>
|
||||||
|
Add Device
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<section className="mb-8 space-y-4">
|
||||||
|
<h2 className="text-xl font-semibold">System Health</h2>
|
||||||
|
{overview ? (
|
||||||
|
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-4">
|
||||||
|
<div className="stats shadow">
|
||||||
|
<div className="stat">
|
||||||
|
<div className="stat-figure text-primary">
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
className="inline-block w-8 h-8 stroke-current"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth="2"
|
||||||
|
d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div className="stat-title">Total Devices</div>
|
||||||
|
<div className="stat-value text-primary">{overview.total_devices}</div>
|
||||||
|
<div className="stat-desc">Registered in system</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="stats shadow">
|
||||||
|
<div className="stat">
|
||||||
|
<div className="stat-figure text-success">
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
className="inline-block w-8 h-8 stroke-current"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth="2"
|
||||||
|
d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div className="stat-title">Active Devices</div>
|
||||||
|
<div className="stat-value text-success">{overview.active_devices}</div>
|
||||||
|
<div className="stat-desc">Currently online</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="stats shadow">
|
||||||
|
<div className="stat">
|
||||||
|
<div className="stat-figure text-secondary">
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
className="inline-block w-8 h-8 stroke-current"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth="2"
|
||||||
|
d="M8.111 16.404a5.5 5.5 0 017.778 0M12 20h.01m-7.08-7.071c3.904-3.905 10.236-3.905 14.141 0M1.394 9.393c5.857-5.857 15.355-15.857 21.213 0"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div className="stat-title">MQTT Devices</div>
|
||||||
|
<div className="stat-value text-secondary">{overview.mqtt_devices}</div>
|
||||||
|
<div className="stat-desc">Using mTLS</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="stats shadow">
|
||||||
|
<div className="stat">
|
||||||
|
<div className="stat-figure text-warning">
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
className="inline-block w-8 h-8 stroke-current"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth="2"
|
||||||
|
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div className="stat-title">Expiring Soon</div>
|
||||||
|
<div className="stat-value text-warning">
|
||||||
|
{overview.certificates_expiring_soon}
|
||||||
|
</div>
|
||||||
|
<div className="stat-desc">Certificates need renewal</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-4">
|
||||||
|
{[1, 2, 3, 4].map((key) => (
|
||||||
|
<div key={key} className="stats shadow animate-pulse">
|
||||||
|
<div className="stat">
|
||||||
|
<div className="stat-figure w-8 h-8 rounded-full bg-base-200"></div>
|
||||||
|
<div className="stat-title bg-base-200 h-4 w-24 rounded"></div>
|
||||||
|
<div className="stat-value bg-base-200 h-6 w-20 rounded mt-2"></div>
|
||||||
|
<div className="stat-desc bg-base-200 h-4 w-28 rounded mt-2"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="table table-zebra w-full">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Name</th>
|
||||||
|
<th>Location</th>
|
||||||
|
<th>Protocol</th>
|
||||||
|
<th>Certificate Status</th>
|
||||||
|
<th>Certificate Expiry</th>
|
||||||
|
<th>Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{devices && devices.length > 0 ? (
|
||||||
|
devices.map((device: Device) => {
|
||||||
|
const expiresAt = device.active_certificate?.expires_at ?? device.certificate_expires_at
|
||||||
|
|
||||||
|
return (
|
||||||
|
<tr key={device.id} className="hover">
|
||||||
|
<td className="font-semibold">{device.name}</td>
|
||||||
|
<td>{device.location || '—'}</td>
|
||||||
|
<td>
|
||||||
|
<div className="badge badge-info">{device.protocol.toUpperCase()}</div>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
{device.protocol === 'mqtt' ? (
|
||||||
|
<div className={`badge ${
|
||||||
|
device.certificate_status === 'Valid' ? 'badge-success' :
|
||||||
|
device.certificate_status === 'Expiring Soon' ? 'badge-warning' :
|
||||||
|
'badge-error'
|
||||||
|
}`}>
|
||||||
|
{device.certificate_status || 'Unknown'}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<span className="badge badge-ghost">N/A</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td>{expiresAt ? new Date(expiresAt).toLocaleString() : '—'}</td>
|
||||||
|
<td>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Link to={`/devices/${device.id}`} className="btn btn-outline btn-info btn-xs">
|
||||||
|
View
|
||||||
|
</Link>
|
||||||
|
<button
|
||||||
|
className="btn btn-error btn-xs"
|
||||||
|
onClick={() => setDeleteDevice(device)}
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</button>
|
||||||
|
{device.protocol === 'mqtt' && (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
className="btn btn-outline btn-warning btn-xs"
|
||||||
|
onClick={() => setRenewDevice(device)}
|
||||||
|
>
|
||||||
|
Renew
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="btn btn-outline btn-error btn-xs"
|
||||||
|
onClick={() => setRevokeDevice(device)}
|
||||||
|
>
|
||||||
|
Revoke
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
) : (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={6} className="text-center py-8">
|
||||||
|
<div className="flex flex-col items-center gap-4">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" className="h-16 w-16 opacity-30" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
|
||||||
|
</svg>
|
||||||
|
<p className="text-lg opacity-60">No devices found.</p>
|
||||||
|
<Link to="/devices/add" className="btn btn-primary btn-sm">
|
||||||
|
Add Your First Device
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Dialogs */}
|
||||||
|
{deleteDevice && (
|
||||||
|
<DeleteDeviceDialog
|
||||||
|
device={deleteDevice}
|
||||||
|
open={!!deleteDevice}
|
||||||
|
onOpenChange={(open) => !open && setDeleteDevice(null)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{revokeDevice && (
|
||||||
|
<RevokeDialog
|
||||||
|
device={revokeDevice}
|
||||||
|
open={!!revokeDevice}
|
||||||
|
onOpenChange={(open) => !open && setRevokeDevice(null)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{renewDevice && (
|
||||||
|
<RenewDialog
|
||||||
|
device={renewDevice}
|
||||||
|
open={!!renewDevice}
|
||||||
|
onOpenChange={(open) => !open && setRenewDevice(null)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
66
frontend/src/types/api.ts
Normal file
66
frontend/src/types/api.ts
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
export interface Device {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
location?: string;
|
||||||
|
protocol: 'mqtt' | 'http' | 'webhook';
|
||||||
|
connection_config?: Record<string, any>;
|
||||||
|
is_active: boolean;
|
||||||
|
created_at: string;
|
||||||
|
certificate_status?: string;
|
||||||
|
certificate_expires_at?: string;
|
||||||
|
active_certificate?: DeviceCertificate;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DeviceCertificate {
|
||||||
|
id: string;
|
||||||
|
device_id: string;
|
||||||
|
issued_at: string;
|
||||||
|
expires_at: string;
|
||||||
|
revoked_at?: string;
|
||||||
|
is_revoked: boolean;
|
||||||
|
is_expired: boolean;
|
||||||
|
is_expiring_soon: boolean;
|
||||||
|
is_valid: boolean;
|
||||||
|
days_until_expiry: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Telemetry {
|
||||||
|
time: string;
|
||||||
|
device_id: string;
|
||||||
|
device_name: string;
|
||||||
|
metric: string;
|
||||||
|
value: number;
|
||||||
|
unit?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DeviceRegistrationRequest {
|
||||||
|
name: string;
|
||||||
|
location?: string;
|
||||||
|
protocol?: 'mqtt' | 'http' | 'webhook';
|
||||||
|
connection_config?: Record<string, any>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DeviceRegistrationResponse {
|
||||||
|
device_id: string;
|
||||||
|
protocol: string;
|
||||||
|
certificate_id?: string;
|
||||||
|
ca_certificate_pem?: string;
|
||||||
|
certificate_pem?: string;
|
||||||
|
private_key_pem?: string;
|
||||||
|
expires_at?: string;
|
||||||
|
onboarding_token?: string; // One-time token for secure onboarding (valid 15 min)
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DashboardOverview {
|
||||||
|
total_devices: number;
|
||||||
|
active_devices: number;
|
||||||
|
mqtt_devices: number;
|
||||||
|
http_devices: number;
|
||||||
|
certificates_expiring_soon: number;
|
||||||
|
recent_telemetry: Telemetry[];
|
||||||
|
devices_with_metrics: {
|
||||||
|
device_id: string;
|
||||||
|
device_name: string;
|
||||||
|
metrics: string[];
|
||||||
|
}[];
|
||||||
|
}
|
||||||
64
frontend/src/utils/formatters.ts
Normal file
64
frontend/src/utils/formatters.ts
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
/**
|
||||||
|
* Format metric names for display
|
||||||
|
*/
|
||||||
|
export function formatMetricName(metric: string): string {
|
||||||
|
// Known metric mappings
|
||||||
|
const metricNames: Record<string, string> = {
|
||||||
|
'temperature': 'Temperature',
|
||||||
|
'humidity': 'Humidity',
|
||||||
|
'co2': 'CO₂',
|
||||||
|
'pressure': 'Pressure',
|
||||||
|
'light': 'Light Level',
|
||||||
|
'noise': 'Noise Level',
|
||||||
|
'pm25': 'PM2.5',
|
||||||
|
'voc': 'VOC',
|
||||||
|
}
|
||||||
|
|
||||||
|
// Return mapped name or capitalize the metric
|
||||||
|
return metricNames[metric.toLowerCase()] ||
|
||||||
|
metric.replace(/_/g, ' ')
|
||||||
|
.split(' ')
|
||||||
|
.map(word => word.charAt(0).toUpperCase() + word.slice(1))
|
||||||
|
.join(' ')
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format device name for display
|
||||||
|
*/
|
||||||
|
export function formatDeviceName(deviceName: string): string {
|
||||||
|
return deviceName
|
||||||
|
.replace(/_/g, ' ')
|
||||||
|
.replace(/-/g, ' ')
|
||||||
|
.split(' ')
|
||||||
|
.map(word => word.charAt(0).toUpperCase() + word.slice(1))
|
||||||
|
.join(' ')
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a default widget title based on type, device, and metrics
|
||||||
|
*/
|
||||||
|
export function createDefaultWidgetTitle(
|
||||||
|
type: string,
|
||||||
|
deviceName: string | undefined,
|
||||||
|
metrics: string[]
|
||||||
|
): string {
|
||||||
|
const formattedMetrics = metrics.map(formatMetricName).join(' & ')
|
||||||
|
|
||||||
|
switch (type) {
|
||||||
|
case 'line-chart':
|
||||||
|
if (metrics.length > 1) {
|
||||||
|
return `${formattedMetrics}`
|
||||||
|
}
|
||||||
|
return `${formattedMetrics}`
|
||||||
|
case 'stat':
|
||||||
|
return `${formattedMetrics}`
|
||||||
|
case 'gauge':
|
||||||
|
return `${formattedMetrics}`
|
||||||
|
case 'ai-insight':
|
||||||
|
return deviceName
|
||||||
|
? `AI Insights - ${formatDeviceName(deviceName)}`
|
||||||
|
: 'AI Insights'
|
||||||
|
default:
|
||||||
|
return formattedMetrics
|
||||||
|
}
|
||||||
|
}
|
||||||
28
frontend/tsconfig.app.json
Normal file
28
frontend/tsconfig.app.json
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
|
||||||
|
"target": "ES2022",
|
||||||
|
"useDefineForClassFields": true,
|
||||||
|
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||||
|
"module": "ESNext",
|
||||||
|
"types": ["vite/client"],
|
||||||
|
"skipLibCheck": true,
|
||||||
|
|
||||||
|
/* Bundler mode */
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"verbatimModuleSyntax": true,
|
||||||
|
"moduleDetection": "force",
|
||||||
|
"noEmit": true,
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
|
||||||
|
/* Linting */
|
||||||
|
"strict": true,
|
||||||
|
"noUnusedLocals": true,
|
||||||
|
"noUnusedParameters": true,
|
||||||
|
"erasableSyntaxOnly": true,
|
||||||
|
"noFallthroughCasesInSwitch": true,
|
||||||
|
"noUncheckedSideEffectImports": true
|
||||||
|
},
|
||||||
|
"include": ["src"]
|
||||||
|
}
|
||||||
7
frontend/tsconfig.json
Normal file
7
frontend/tsconfig.json
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"files": [],
|
||||||
|
"references": [
|
||||||
|
{ "path": "./tsconfig.app.json" },
|
||||||
|
{ "path": "./tsconfig.node.json" }
|
||||||
|
]
|
||||||
|
}
|
||||||
26
frontend/tsconfig.node.json
Normal file
26
frontend/tsconfig.node.json
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
|
||||||
|
"target": "ES2023",
|
||||||
|
"lib": ["ES2023"],
|
||||||
|
"module": "ESNext",
|
||||||
|
"types": ["node"],
|
||||||
|
"skipLibCheck": true,
|
||||||
|
|
||||||
|
/* Bundler mode */
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"verbatimModuleSyntax": true,
|
||||||
|
"moduleDetection": "force",
|
||||||
|
"noEmit": true,
|
||||||
|
|
||||||
|
/* Linting */
|
||||||
|
"strict": true,
|
||||||
|
"noUnusedLocals": true,
|
||||||
|
"noUnusedParameters": true,
|
||||||
|
"erasableSyntaxOnly": true,
|
||||||
|
"noFallthroughCasesInSwitch": true,
|
||||||
|
"noUncheckedSideEffectImports": true
|
||||||
|
},
|
||||||
|
"include": ["vite.config.ts"]
|
||||||
|
}
|
||||||
23
frontend/vite.config.ts
Normal file
23
frontend/vite.config.ts
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
import { defineConfig } from 'vite'
|
||||||
|
import react from '@vitejs/plugin-react'
|
||||||
|
import tailwindcss from '@tailwindcss/vite'
|
||||||
|
import path from 'path'
|
||||||
|
|
||||||
|
// https://vite.dev/config/
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [react(), tailwindcss()],
|
||||||
|
resolve: {
|
||||||
|
alias: {
|
||||||
|
'@': path.resolve(__dirname, './src'),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
server: {
|
||||||
|
port: 5173,
|
||||||
|
proxy: {
|
||||||
|
'/api': {
|
||||||
|
target: 'http://localhost:3000',
|
||||||
|
changeOrigin: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
42
gpt.py
42
gpt.py
@@ -1,42 +0,0 @@
|
|||||||
import json
|
|
||||||
import os
|
|
||||||
import redis
|
|
||||||
from dotenv import load_dotenv
|
|
||||||
from openai import OpenAI
|
|
||||||
|
|
||||||
load_dotenv()
|
|
||||||
client = OpenAI()
|
|
||||||
|
|
||||||
REDIS_HOST=os.getenv('REDIS_HOST')
|
|
||||||
redis_client = redis.StrictRedis(host=REDIS_HOST, port=6379, db=0)
|
|
||||||
|
|
||||||
data = redis_client.get("last5").decode("utf-8")
|
|
||||||
|
|
||||||
|
|
||||||
def analysis(environment_data):
|
|
||||||
completion = client.chat.completions.create(
|
|
||||||
model="gpt-4o-mini",
|
|
||||||
messages=[
|
|
||||||
{"role": "system",
|
|
||||||
"content": "You are an assistant that analyzes environmental data for an office working space and provides "
|
|
||||||
"concise numerical insights."},
|
|
||||||
{
|
|
||||||
"role": "user",
|
|
||||||
"content": f"Analyze the following environmental data. The goal is maintaining optimal working "
|
|
||||||
f"conditions in the office and peak working brain. Focus on any outliers or necessary adjustments. The data is as following: {environment_data}."
|
|
||||||
f"The output should be only the recommendations in numerical form with postitive and negative "
|
|
||||||
f"numbers and also provide small summary in a sentence or two of the current conditions and "
|
|
||||||
f"easily computable in json format. Be consistent with the + and - signs and the summary"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
response_format={"type": "json_object"}
|
|
||||||
|
|
||||||
)
|
|
||||||
output = completion.choices[0].message.content
|
|
||||||
|
|
||||||
return output
|
|
||||||
|
|
||||||
output = analysis(data)
|
|
||||||
redis_client.set("gpt",json.dumps(output))
|
|
||||||
|
|
||||||
print(output)
|
|
||||||
3
infrastructure/.gitignore
vendored
Normal file
3
infrastructure/.gitignore
vendored
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
mosquitto/certs/
|
||||||
|
mosquitto/data/
|
||||||
|
mosquitto/logs/
|
||||||
35
infrastructure/compose.yml
Normal file
35
infrastructure/compose.yml
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
services:
|
||||||
|
|
||||||
|
redis:
|
||||||
|
image: redis:8
|
||||||
|
ports:
|
||||||
|
- "6379:6379"
|
||||||
|
volumes:
|
||||||
|
- redis-data:/data
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
mqtt:
|
||||||
|
image: eclipse-mosquitto:2.0
|
||||||
|
ports:
|
||||||
|
- "1883:1883"
|
||||||
|
- "9001:9001"
|
||||||
|
- "8883:8883"
|
||||||
|
volumes:
|
||||||
|
- ./mosquitto/:/mosquitto/:Z
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
timescaledb:
|
||||||
|
image: timescale/timescaledb:latest-pg17
|
||||||
|
environment:
|
||||||
|
POSTGRES_USER: ${POSTGRES_USER:-postgres}
|
||||||
|
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-example}
|
||||||
|
POSTGRES_DB: ${POSTGRES_DB:-iot_data}
|
||||||
|
ports:
|
||||||
|
- "5432:5432"
|
||||||
|
volumes:
|
||||||
|
- timescaledb-data:/var/lib/postgresql/data
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
redis-data:
|
||||||
|
timescaledb-data:
|
||||||
32
infrastructure/mosquitto/config/mosquitto.conf
Normal file
32
infrastructure/mosquitto/config/mosquitto.conf
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
# Persistence
|
||||||
|
persistence true
|
||||||
|
persistence_location /mosquitto/data/
|
||||||
|
|
||||||
|
# Logging
|
||||||
|
#log_dest file /mosquitto/log/mosquitto.log
|
||||||
|
|
||||||
|
# Standard MQTT listener (for testing without certs)
|
||||||
|
listener 1883
|
||||||
|
allow_anonymous true
|
||||||
|
|
||||||
|
# mTLS listener (requires client certificates)
|
||||||
|
listener 8883
|
||||||
|
allow_anonymous true
|
||||||
|
protocol mqtt
|
||||||
|
|
||||||
|
# Server certificates (mosquitto's identity)
|
||||||
|
certfile /mosquitto/certs/server.crt
|
||||||
|
keyfile /mosquitto/certs/server.key
|
||||||
|
|
||||||
|
# CA certificate to verify client certificates
|
||||||
|
cafile /mosquitto/certs/ca.crt
|
||||||
|
|
||||||
|
# CRL file
|
||||||
|
crlfile /mosquitto/certs/ca.crl
|
||||||
|
|
||||||
|
# Certificate-based authentication
|
||||||
|
require_certificate true
|
||||||
|
use_identity_as_username true
|
||||||
|
|
||||||
|
# TLS version restrictions
|
||||||
|
tls_version tlsv1.2
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
PASSWORD='postgres'
|
PASSWORD='postgres'
|
||||||
SECRET_KEY='django_key'
|
SECRET_KEY='django_key'
|
||||||
CONNECTION_STRING='postgres://postgres:postgres@localhost:5555/example'
|
CONNECTION_STRING='postgres://postgres:postgres@localhost:5555/example'
|
||||||
REDIS_HOST="localhost"
|
|
||||||
MQTT_BROKER="localhost"
|
WEATHER_API_KEY=your_weather_api_key_here
|
||||||
OPENAI_API_KEY="example"
|
DEVICE_MANAGER_URL=http://localhost:8000
|
||||||
|
|
||||||
|
|||||||
1
iotDashboard/api/__init__.py
Normal file
1
iotDashboard/api/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
"""REST API for IoT Dashboard."""
|
||||||
77
iotDashboard/api/serializers.py
Normal file
77
iotDashboard/api/serializers.py
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
"""DRF serializers for IoT Dashboard models."""
|
||||||
|
|
||||||
|
from rest_framework import serializers
|
||||||
|
from iotDashboard.models import Device, DeviceCertificate, Telemetry
|
||||||
|
|
||||||
|
|
||||||
|
class DeviceCertificateSerializer(serializers.ModelSerializer):
|
||||||
|
"""Serializer for device certificates."""
|
||||||
|
|
||||||
|
is_revoked = serializers.ReadOnlyField()
|
||||||
|
is_expired = serializers.ReadOnlyField()
|
||||||
|
is_expiring_soon = serializers.ReadOnlyField()
|
||||||
|
is_valid = serializers.ReadOnlyField()
|
||||||
|
days_until_expiry = serializers.ReadOnlyField()
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = DeviceCertificate
|
||||||
|
fields = [
|
||||||
|
'id', 'device_id', 'issued_at', 'expires_at',
|
||||||
|
'revoked_at', 'is_revoked', 'is_expired',
|
||||||
|
'is_expiring_soon', 'is_valid', 'days_until_expiry'
|
||||||
|
]
|
||||||
|
# Don't expose private keys in API
|
||||||
|
# certificate_pem and private_key_pem are excluded by default
|
||||||
|
|
||||||
|
|
||||||
|
class DeviceSerializer(serializers.ModelSerializer):
|
||||||
|
"""Serializer for devices with certificate status."""
|
||||||
|
|
||||||
|
certificate_status = serializers.ReadOnlyField()
|
||||||
|
active_certificate = DeviceCertificateSerializer(read_only=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = Device
|
||||||
|
fields = [
|
||||||
|
'id', 'name', 'location', 'protocol',
|
||||||
|
'connection_config', 'is_active', 'created_at',
|
||||||
|
'certificate_status', 'active_certificate'
|
||||||
|
]
|
||||||
|
read_only_fields = ['id', 'created_at']
|
||||||
|
|
||||||
|
|
||||||
|
class DeviceCreateSerializer(serializers.Serializer):
|
||||||
|
"""Serializer for device registration requests."""
|
||||||
|
|
||||||
|
name = serializers.CharField(max_length=255)
|
||||||
|
location = serializers.CharField(max_length=255, required=False, allow_blank=True)
|
||||||
|
protocol = serializers.ChoiceField(choices=['mqtt', 'http', 'webhook'], default='mqtt')
|
||||||
|
connection_config = serializers.JSONField(required=False, allow_null=True)
|
||||||
|
|
||||||
|
|
||||||
|
class TelemetrySerializer(serializers.ModelSerializer):
|
||||||
|
"""Serializer for telemetry data."""
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
model = Telemetry
|
||||||
|
fields = ['time', 'device_id', 'metric', 'value', 'unit']
|
||||||
|
|
||||||
|
|
||||||
|
class DeviceMetricsSerializer(serializers.Serializer):
|
||||||
|
"""Serializer for device metrics list."""
|
||||||
|
|
||||||
|
device_id = serializers.CharField()
|
||||||
|
device_name = serializers.CharField()
|
||||||
|
metrics = serializers.ListField(child=serializers.CharField())
|
||||||
|
|
||||||
|
|
||||||
|
class DashboardOverviewSerializer(serializers.Serializer):
|
||||||
|
"""Serializer for dashboard overview data."""
|
||||||
|
|
||||||
|
total_devices = serializers.IntegerField()
|
||||||
|
active_devices = serializers.IntegerField()
|
||||||
|
mqtt_devices = serializers.IntegerField()
|
||||||
|
http_devices = serializers.IntegerField()
|
||||||
|
certificates_expiring_soon = serializers.IntegerField()
|
||||||
|
recent_telemetry = TelemetrySerializer(many=True)
|
||||||
|
devices_with_metrics = DeviceMetricsSerializer(many=True)
|
||||||
15
iotDashboard/api/urls.py
Normal file
15
iotDashboard/api/urls.py
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
"""URL routing for IoT Dashboard REST API."""
|
||||||
|
|
||||||
|
from django.urls import path, include
|
||||||
|
from rest_framework.routers import DefaultRouter
|
||||||
|
from .views import DeviceViewSet, TelemetryViewSet, DashboardViewSet, WeatherViewSet
|
||||||
|
|
||||||
|
router = DefaultRouter()
|
||||||
|
router.register(r'devices', DeviceViewSet, basename='device')
|
||||||
|
router.register(r'telemetry', TelemetryViewSet, basename='telemetry')
|
||||||
|
router.register(r'dashboard', DashboardViewSet, basename='dashboard')
|
||||||
|
router.register(r'weather', WeatherViewSet, basename='weather')
|
||||||
|
|
||||||
|
urlpatterns = [
|
||||||
|
path('', include(router.urls)),
|
||||||
|
]
|
||||||
578
iotDashboard/api/views.py
Normal file
578
iotDashboard/api/views.py
Normal file
@@ -0,0 +1,578 @@
|
|||||||
|
"""DRF ViewSets for IoT Dashboard API."""
|
||||||
|
|
||||||
|
import requests
|
||||||
|
from datetime import timedelta
|
||||||
|
from django.utils import timezone
|
||||||
|
from rest_framework import viewsets, status
|
||||||
|
from rest_framework.decorators import action
|
||||||
|
from rest_framework.response import Response
|
||||||
|
from asgiref.sync import async_to_sync
|
||||||
|
|
||||||
|
from iotDashboard.models import Device, DeviceCertificate, Telemetry
|
||||||
|
from iotDashboard.device_manager_client import (
|
||||||
|
DeviceManagerClient,
|
||||||
|
DeviceManagerAPIError
|
||||||
|
)
|
||||||
|
from iotDashboard import gpt_service_client
|
||||||
|
from iotDashboard import weather_client
|
||||||
|
from iotDashboard.comfort_index import (
|
||||||
|
ComfortMetrics,
|
||||||
|
ComfortIndexCalculator,
|
||||||
|
calculate_comfort_index_from_telemetry,
|
||||||
|
)
|
||||||
|
from .serializers import (
|
||||||
|
DeviceSerializer,
|
||||||
|
DeviceCreateSerializer,
|
||||||
|
TelemetrySerializer,
|
||||||
|
DashboardOverviewSerializer,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
device_manager = DeviceManagerClient()
|
||||||
|
|
||||||
|
|
||||||
|
class DeviceViewSet(viewsets.ModelViewSet):
|
||||||
|
"""ViewSet for device management."""
|
||||||
|
|
||||||
|
queryset = Device.objects.all()
|
||||||
|
serializer_class = DeviceSerializer
|
||||||
|
# permission_classes = [IsAuthenticated] # Uncomment for production
|
||||||
|
|
||||||
|
def get_serializer_class(self):
|
||||||
|
if self.action == 'create':
|
||||||
|
return DeviceCreateSerializer
|
||||||
|
return DeviceSerializer
|
||||||
|
|
||||||
|
def create(self, request):
|
||||||
|
"""Register a new device via device_manager API."""
|
||||||
|
serializer = DeviceCreateSerializer(data=request.data)
|
||||||
|
serializer.is_valid(raise_exception=True)
|
||||||
|
|
||||||
|
try:
|
||||||
|
response = device_manager.register_device(
|
||||||
|
name=serializer.validated_data['name'],
|
||||||
|
location=serializer.validated_data.get('location'),
|
||||||
|
protocol=serializer.validated_data.get('protocol', 'mqtt'),
|
||||||
|
connection_config=serializer.validated_data.get('connection_config'),
|
||||||
|
)
|
||||||
|
|
||||||
|
# Return full registration response with credentials
|
||||||
|
return Response({
|
||||||
|
'device_id': response.device_id,
|
||||||
|
'protocol': response.protocol,
|
||||||
|
'certificate_id': response.certificate_id,
|
||||||
|
'ca_certificate_pem': response.ca_certificate_pem,
|
||||||
|
'certificate_pem': response.certificate_pem,
|
||||||
|
'private_key_pem': response.private_key_pem,
|
||||||
|
'expires_at': response.expires_at.isoformat() if response.expires_at else None,
|
||||||
|
'onboarding_token': response.onboarding_token, # One-time token for QR code
|
||||||
|
}, status=status.HTTP_201_CREATED)
|
||||||
|
|
||||||
|
except DeviceManagerAPIError as e:
|
||||||
|
return Response(
|
||||||
|
{'error': e.message, 'details': e.details},
|
||||||
|
status=e.status_code or status.HTTP_500_INTERNAL_SERVER_ERROR
|
||||||
|
)
|
||||||
|
|
||||||
|
def destroy(self, request, pk=None):
|
||||||
|
"""Delete a device."""
|
||||||
|
try:
|
||||||
|
device = self.get_object()
|
||||||
|
device_name = device.name
|
||||||
|
device.delete()
|
||||||
|
return Response(
|
||||||
|
{'message': f"Device '{device_name}' deleted successfully"},
|
||||||
|
status=status.HTTP_204_NO_CONTENT
|
||||||
|
)
|
||||||
|
except Device.DoesNotExist:
|
||||||
|
return Response(
|
||||||
|
{'error': 'Device not found'},
|
||||||
|
status=status.HTTP_404_NOT_FOUND
|
||||||
|
)
|
||||||
|
|
||||||
|
@action(detail=True, methods=['post'])
|
||||||
|
def revoke(self, request, pk=None):
|
||||||
|
"""Revoke a device's certificate."""
|
||||||
|
device = self.get_object()
|
||||||
|
|
||||||
|
if device.protocol != 'mqtt':
|
||||||
|
return Response(
|
||||||
|
{'error': 'Only MQTT devices have certificates to revoke'},
|
||||||
|
status=status.HTTP_400_BAD_REQUEST
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
result = device_manager.revoke_certificate(device.id)
|
||||||
|
return Response(result)
|
||||||
|
except DeviceManagerAPIError as e:
|
||||||
|
return Response(
|
||||||
|
{'error': e.message, 'details': e.details},
|
||||||
|
status=e.status_code or status.HTTP_500_INTERNAL_SERVER_ERROR
|
||||||
|
)
|
||||||
|
|
||||||
|
@action(detail=True, methods=['post'])
|
||||||
|
def renew(self, request, pk=None):
|
||||||
|
"""Renew a device's certificate."""
|
||||||
|
device = self.get_object()
|
||||||
|
|
||||||
|
if device.protocol != 'mqtt':
|
||||||
|
return Response(
|
||||||
|
{'error': 'Only MQTT devices have certificates to renew'},
|
||||||
|
status=status.HTTP_400_BAD_REQUEST
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
response = device_manager.renew_certificate(device.id)
|
||||||
|
return Response({
|
||||||
|
'device_id': response.device_id,
|
||||||
|
'protocol': response.protocol,
|
||||||
|
'certificate_id': response.certificate_id,
|
||||||
|
'ca_certificate_pem': response.ca_certificate_pem,
|
||||||
|
'certificate_pem': response.certificate_pem,
|
||||||
|
'private_key_pem': response.private_key_pem,
|
||||||
|
'expires_at': response.expires_at.isoformat() if response.expires_at else None,
|
||||||
|
})
|
||||||
|
except DeviceManagerAPIError as e:
|
||||||
|
return Response(
|
||||||
|
{'error': e.message, 'details': e.details},
|
||||||
|
status=e.status_code or status.HTTP_500_INTERNAL_SERVER_ERROR
|
||||||
|
)
|
||||||
|
|
||||||
|
@action(detail=True, methods=['get'])
|
||||||
|
def credentials(self, request, pk=None):
|
||||||
|
"""
|
||||||
|
Fetch device credentials using one-time onboarding token.
|
||||||
|
Used by mobile apps after scanning QR code.
|
||||||
|
|
||||||
|
Query params:
|
||||||
|
- token: One-time onboarding token from QR code
|
||||||
|
"""
|
||||||
|
device_id = pk
|
||||||
|
token = request.query_params.get('token')
|
||||||
|
|
||||||
|
if not token:
|
||||||
|
return Response(
|
||||||
|
{'error': 'token parameter is required'},
|
||||||
|
status=status.HTTP_400_BAD_REQUEST
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
response = device_manager.get_device_credentials(device_id, token)
|
||||||
|
|
||||||
|
# Return credentials
|
||||||
|
return Response({
|
||||||
|
'device_id': response.device_id,
|
||||||
|
'protocol': response.protocol,
|
||||||
|
'certificate_id': response.certificate_id,
|
||||||
|
'ca_certificate_pem': response.ca_certificate_pem,
|
||||||
|
'certificate_pem': response.certificate_pem,
|
||||||
|
'private_key_pem': response.private_key_pem,
|
||||||
|
'expires_at': response.expires_at.isoformat() if response.expires_at else None,
|
||||||
|
})
|
||||||
|
|
||||||
|
except DeviceManagerAPIError as e:
|
||||||
|
return Response(
|
||||||
|
{'error': e.message, 'details': e.details},
|
||||||
|
status=e.status_code or status.HTTP_500_INTERNAL_SERVER_ERROR
|
||||||
|
)
|
||||||
|
|
||||||
|
@action(detail=True, methods=['get'])
|
||||||
|
def telemetry(self, request, pk=None):
|
||||||
|
"""Get telemetry data for a specific device."""
|
||||||
|
device = self.get_object()
|
||||||
|
|
||||||
|
# Parse query parameters
|
||||||
|
metric = request.query_params.get('metric')
|
||||||
|
hours = int(request.query_params.get('hours', 24))
|
||||||
|
limit = int(request.query_params.get('limit', 1000))
|
||||||
|
|
||||||
|
# Build query
|
||||||
|
queryset = Telemetry.objects.filter(
|
||||||
|
device_id=device.id,
|
||||||
|
time__gte=timezone.now() - timedelta(hours=hours)
|
||||||
|
)
|
||||||
|
|
||||||
|
if metric:
|
||||||
|
queryset = queryset.filter(metric=metric)
|
||||||
|
|
||||||
|
queryset = queryset.order_by('-time')[:limit]
|
||||||
|
|
||||||
|
serializer = TelemetrySerializer(queryset, many=True)
|
||||||
|
return Response(serializer.data)
|
||||||
|
|
||||||
|
@action(detail=True, methods=['get'])
|
||||||
|
def metrics(self, request, pk=None):
|
||||||
|
"""Get available metrics for a device."""
|
||||||
|
device = self.get_object()
|
||||||
|
|
||||||
|
metrics = (
|
||||||
|
Telemetry.objects
|
||||||
|
.filter(device_id=device.id)
|
||||||
|
.values_list('metric', flat=True)
|
||||||
|
.distinct()
|
||||||
|
)
|
||||||
|
|
||||||
|
return Response({
|
||||||
|
'device_id': device.id,
|
||||||
|
'device_name': device.name,
|
||||||
|
'metrics': list(metrics)
|
||||||
|
})
|
||||||
|
|
||||||
|
@action(detail=True, methods=['get'])
|
||||||
|
def comfort_index(self, request, pk=None):
|
||||||
|
"""
|
||||||
|
Calculate comfort index from latest telemetry data.
|
||||||
|
|
||||||
|
Returns overall comfort score (0-100) and component breakdowns.
|
||||||
|
"""
|
||||||
|
device = self.get_object()
|
||||||
|
|
||||||
|
# Get latest reading for each metric
|
||||||
|
latest_readings = {}
|
||||||
|
metrics_to_check = ['temperature', 'humidity', 'co2', 'CO2', 'noise', 'sound',
|
||||||
|
'pm2.5', 'PM2.5', 'pm10', 'PM10', 'light', 'lux']
|
||||||
|
|
||||||
|
for metric in metrics_to_check:
|
||||||
|
reading = (
|
||||||
|
Telemetry.objects
|
||||||
|
.filter(device_id=device.id, metric=metric)
|
||||||
|
.order_by('-time')
|
||||||
|
.first()
|
||||||
|
)
|
||||||
|
if reading:
|
||||||
|
latest_readings[metric] = reading.value
|
||||||
|
|
||||||
|
if not latest_readings:
|
||||||
|
return Response(
|
||||||
|
{'error': 'No telemetry data available for comfort calculation'},
|
||||||
|
status=status.HTTP_404_NOT_FOUND
|
||||||
|
)
|
||||||
|
|
||||||
|
# Calculate comfort index
|
||||||
|
comfort_metrics = ComfortMetrics(
|
||||||
|
temperature=latest_readings.get('temperature'),
|
||||||
|
humidity=latest_readings.get('humidity'),
|
||||||
|
co2=latest_readings.get('co2') or latest_readings.get('CO2'),
|
||||||
|
noise=latest_readings.get('noise') or latest_readings.get('sound'),
|
||||||
|
pm25=latest_readings.get('pm2.5') or latest_readings.get('PM2.5'),
|
||||||
|
pm10=latest_readings.get('pm10') or latest_readings.get('PM10'),
|
||||||
|
light=latest_readings.get('light') or latest_readings.get('lux'),
|
||||||
|
)
|
||||||
|
|
||||||
|
comfort_score = ComfortIndexCalculator.calculate(comfort_metrics)
|
||||||
|
|
||||||
|
return Response({
|
||||||
|
'device_id': device.id,
|
||||||
|
'device_name': device.name,
|
||||||
|
'overall_score': comfort_score.overall_score,
|
||||||
|
'rating': comfort_score.rating,
|
||||||
|
'components': {
|
||||||
|
'temperature': comfort_score.temperature_score,
|
||||||
|
'humidity': comfort_score.humidity_score,
|
||||||
|
'air_quality': comfort_score.air_quality_score,
|
||||||
|
'acoustic': comfort_score.acoustic_score,
|
||||||
|
'light': comfort_score.light_score,
|
||||||
|
},
|
||||||
|
'suggestions': comfort_score.suggestions,
|
||||||
|
'raw_readings': latest_readings,
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
class TelemetryViewSet(viewsets.ReadOnlyModelViewSet):
|
||||||
|
"""ViewSet for telemetry data queries."""
|
||||||
|
|
||||||
|
queryset = Telemetry.objects.all()
|
||||||
|
serializer_class = TelemetrySerializer
|
||||||
|
# permission_classes = [IsAuthenticated]
|
||||||
|
|
||||||
|
def get_queryset(self):
|
||||||
|
"""Filter telemetry by query parameters."""
|
||||||
|
queryset = Telemetry.objects.all()
|
||||||
|
|
||||||
|
# Filter by device
|
||||||
|
device_id = self.request.query_params.get('device_id')
|
||||||
|
if device_id:
|
||||||
|
queryset = queryset.filter(device_id=device_id)
|
||||||
|
|
||||||
|
# Filter by metric
|
||||||
|
metric = self.request.query_params.get('metric')
|
||||||
|
if metric:
|
||||||
|
queryset = queryset.filter(metric=metric)
|
||||||
|
|
||||||
|
# Filter by time range
|
||||||
|
hours = self.request.query_params.get('hours')
|
||||||
|
if hours:
|
||||||
|
queryset = queryset.filter(
|
||||||
|
time__gte=timezone.now() - timedelta(hours=int(hours))
|
||||||
|
)
|
||||||
|
|
||||||
|
start_time = self.request.query_params.get('start_time')
|
||||||
|
if start_time:
|
||||||
|
queryset = queryset.filter(time__gte=start_time)
|
||||||
|
|
||||||
|
end_time = self.request.query_params.get('end_time')
|
||||||
|
if end_time:
|
||||||
|
queryset = queryset.filter(time__lte=end_time)
|
||||||
|
|
||||||
|
return queryset.order_by('-time')
|
||||||
|
|
||||||
|
@action(detail=False, methods=['get'])
|
||||||
|
def latest(self, request):
|
||||||
|
"""Get latest telemetry readings for all devices."""
|
||||||
|
from django.db.models import Max
|
||||||
|
|
||||||
|
# Get latest timestamp for each device-metric combination
|
||||||
|
latest_readings = (
|
||||||
|
Telemetry.objects
|
||||||
|
.values('device_id', 'metric')
|
||||||
|
.annotate(latest_time=Max('time'))
|
||||||
|
)
|
||||||
|
|
||||||
|
# Fetch the actual records
|
||||||
|
telemetry = []
|
||||||
|
for reading in latest_readings:
|
||||||
|
record = Telemetry.objects.get(
|
||||||
|
device_id=reading['device_id'],
|
||||||
|
metric=reading['metric'],
|
||||||
|
time=reading['latest_time']
|
||||||
|
)
|
||||||
|
telemetry.append(record)
|
||||||
|
|
||||||
|
serializer = self.get_serializer(telemetry, many=True)
|
||||||
|
return Response(serializer.data)
|
||||||
|
|
||||||
|
@action(detail=False, methods=['get'])
|
||||||
|
def metrics(self, request):
|
||||||
|
"""Get list of all available metrics."""
|
||||||
|
metrics = (
|
||||||
|
Telemetry.objects
|
||||||
|
.values_list('metric', flat=True)
|
||||||
|
.distinct()
|
||||||
|
)
|
||||||
|
return Response({'metrics': list(metrics)})
|
||||||
|
|
||||||
|
@action(detail=False, methods=['post'])
|
||||||
|
def analyze(self, request):
|
||||||
|
"""Analyze telemetry data using GPT service."""
|
||||||
|
# Parse request parameters
|
||||||
|
device_id = request.data.get('device_id')
|
||||||
|
metric = request.data.get('metric')
|
||||||
|
hours = int(request.data.get('hours', 24))
|
||||||
|
limit = int(request.data.get('limit', 100))
|
||||||
|
prompt_type = request.data.get('prompt_type', 'trend_summary')
|
||||||
|
custom_prompt = request.data.get('custom_prompt')
|
||||||
|
|
||||||
|
# Validate device_id
|
||||||
|
if not device_id:
|
||||||
|
return Response(
|
||||||
|
{'error': 'device_id is required'},
|
||||||
|
status=status.HTTP_400_BAD_REQUEST
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
device = Device.objects.get(id=device_id)
|
||||||
|
except Device.DoesNotExist:
|
||||||
|
return Response(
|
||||||
|
{'error': f'Device {device_id} not found'},
|
||||||
|
status=status.HTTP_404_NOT_FOUND
|
||||||
|
)
|
||||||
|
|
||||||
|
# Query telemetry data
|
||||||
|
queryset = Telemetry.objects.filter(
|
||||||
|
device_id=device_id,
|
||||||
|
time__gte=timezone.now() - timedelta(hours=hours)
|
||||||
|
)
|
||||||
|
|
||||||
|
if metric:
|
||||||
|
queryset = queryset.filter(metric=metric)
|
||||||
|
|
||||||
|
telemetry = queryset.order_by('-time')[:limit]
|
||||||
|
|
||||||
|
if not telemetry:
|
||||||
|
return Response(
|
||||||
|
{'error': 'No telemetry data found for specified parameters'},
|
||||||
|
status=status.HTTP_404_NOT_FOUND
|
||||||
|
)
|
||||||
|
|
||||||
|
# Format data for GPT service
|
||||||
|
telemetry_data = [
|
||||||
|
{
|
||||||
|
'device_id': str(t.device_id),
|
||||||
|
'metric': t.metric,
|
||||||
|
'value': float(t.value),
|
||||||
|
'timestamp': t.time.isoformat()
|
||||||
|
}
|
||||||
|
for t in telemetry
|
||||||
|
]
|
||||||
|
|
||||||
|
# Device context
|
||||||
|
device_info = {
|
||||||
|
'name': device.name,
|
||||||
|
'location': device.location,
|
||||||
|
'protocol': device.protocol,
|
||||||
|
}
|
||||||
|
|
||||||
|
# Call GPT service
|
||||||
|
try:
|
||||||
|
result = async_to_sync(gpt_service_client.analyze_telemetry)(
|
||||||
|
telemetry_data=telemetry_data,
|
||||||
|
device_info=device_info,
|
||||||
|
prompt_type=prompt_type,
|
||||||
|
custom_prompt=custom_prompt
|
||||||
|
)
|
||||||
|
return Response({
|
||||||
|
'analysis': result.analysis,
|
||||||
|
'prompt_type': result.prompt_type,
|
||||||
|
'data_points_analyzed': result.data_points_analyzed
|
||||||
|
})
|
||||||
|
|
||||||
|
except gpt_service_client.GPTServiceError as e:
|
||||||
|
return Response(
|
||||||
|
{
|
||||||
|
'error': e.message,
|
||||||
|
'details': e.details,
|
||||||
|
'gpt_service_available': False
|
||||||
|
},
|
||||||
|
status=e.status_code or status.HTTP_503_SERVICE_UNAVAILABLE
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class DashboardViewSet(viewsets.ViewSet):
|
||||||
|
"""ViewSet for dashboard overview data."""
|
||||||
|
|
||||||
|
# permission_classes = [IsAuthenticated]
|
||||||
|
|
||||||
|
@action(detail=False, methods=['get'])
|
||||||
|
def overview(self, request):
|
||||||
|
"""Get dashboard overview statistics."""
|
||||||
|
# Device statistics
|
||||||
|
total_devices = Device.objects.count()
|
||||||
|
active_devices = Device.objects.filter(is_active=True).count()
|
||||||
|
mqtt_devices = Device.objects.filter(protocol='mqtt').count()
|
||||||
|
http_devices = Device.objects.filter(protocol__in=['http', 'webhook']).count()
|
||||||
|
|
||||||
|
# Certificate statistics
|
||||||
|
expiring_soon = DeviceCertificate.objects.filter(
|
||||||
|
revoked_at__isnull=True,
|
||||||
|
expires_at__lte=timezone.now() + timedelta(days=30),
|
||||||
|
expires_at__gt=timezone.now()
|
||||||
|
).count()
|
||||||
|
|
||||||
|
# Recent telemetry (last 10 readings)
|
||||||
|
recent_telemetry = Telemetry.objects.order_by('-time')[:10]
|
||||||
|
|
||||||
|
# Devices with their metrics
|
||||||
|
devices = Device.objects.all()
|
||||||
|
devices_with_metrics = []
|
||||||
|
for device in devices:
|
||||||
|
metrics = (
|
||||||
|
Telemetry.objects
|
||||||
|
.filter(device_id=device.id)
|
||||||
|
.values_list('metric', flat=True)
|
||||||
|
.distinct()
|
||||||
|
)
|
||||||
|
devices_with_metrics.append({
|
||||||
|
'device_id': device.id,
|
||||||
|
'device_name': device.name,
|
||||||
|
'metrics': list(metrics)
|
||||||
|
})
|
||||||
|
|
||||||
|
data = {
|
||||||
|
'total_devices': total_devices,
|
||||||
|
'active_devices': active_devices,
|
||||||
|
'mqtt_devices': mqtt_devices,
|
||||||
|
'http_devices': http_devices,
|
||||||
|
'certificates_expiring_soon': expiring_soon,
|
||||||
|
'recent_telemetry': TelemetrySerializer(recent_telemetry, many=True).data,
|
||||||
|
'devices_with_metrics': devices_with_metrics,
|
||||||
|
}
|
||||||
|
|
||||||
|
serializer = DashboardOverviewSerializer(data)
|
||||||
|
return Response(serializer.data)
|
||||||
|
|
||||||
|
|
||||||
|
class WeatherViewSet(viewsets.ViewSet):
|
||||||
|
"""ViewSet for weather and air quality data."""
|
||||||
|
|
||||||
|
@action(detail=False, methods=['get'])
|
||||||
|
def current(self, request):
|
||||||
|
"""
|
||||||
|
Get current weather data by city name or coordinates.
|
||||||
|
|
||||||
|
Query params:
|
||||||
|
- city: City name (e.g., "Skopje")
|
||||||
|
OR
|
||||||
|
- lat: Latitude
|
||||||
|
- lon: Longitude
|
||||||
|
"""
|
||||||
|
city = request.query_params.get('city')
|
||||||
|
lat = request.query_params.get('lat')
|
||||||
|
lon = request.query_params.get('lon')
|
||||||
|
|
||||||
|
try:
|
||||||
|
if city:
|
||||||
|
# Fetch by city name
|
||||||
|
weather_data = weather_client.get_weather_by_city(city)
|
||||||
|
elif lat and lon:
|
||||||
|
# Fetch by coordinates
|
||||||
|
latitude = float(lat)
|
||||||
|
longitude = float(lon)
|
||||||
|
raw_weather = weather_client.fetch_current_weather(latitude, longitude)
|
||||||
|
weather_data = weather_client.parse_weather_data(raw_weather)
|
||||||
|
else:
|
||||||
|
return Response(
|
||||||
|
{'error': 'Either city or (lat, lon) parameters are required'},
|
||||||
|
status=status.HTTP_400_BAD_REQUEST
|
||||||
|
)
|
||||||
|
|
||||||
|
return Response(weather_data)
|
||||||
|
|
||||||
|
except ValueError as e:
|
||||||
|
return Response(
|
||||||
|
{'error': str(e)},
|
||||||
|
status=status.HTTP_404_NOT_FOUND
|
||||||
|
)
|
||||||
|
except requests.RequestException as e:
|
||||||
|
return Response(
|
||||||
|
{'error': 'Failed to fetch weather data', 'details': str(e)},
|
||||||
|
status=status.HTTP_503_SERVICE_UNAVAILABLE
|
||||||
|
)
|
||||||
|
|
||||||
|
@action(detail=False, methods=['get'])
|
||||||
|
def air_quality(self, request):
|
||||||
|
"""
|
||||||
|
Get current air quality data for a city (Pulse.eco API).
|
||||||
|
|
||||||
|
Query params:
|
||||||
|
- city: City name (e.g., "skopje", "bitola", "tetovo")
|
||||||
|
"""
|
||||||
|
city = request.query_params.get('city')
|
||||||
|
|
||||||
|
if not city:
|
||||||
|
return Response(
|
||||||
|
{'error': 'city parameter is required'},
|
||||||
|
status=status.HTTP_400_BAD_REQUEST
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
raw_data = weather_client.get_air_quality(city)
|
||||||
|
parsed_data = weather_client.parse_air_quality_data(raw_data, city)
|
||||||
|
return Response(parsed_data)
|
||||||
|
|
||||||
|
except requests.HTTPError as e:
|
||||||
|
if e.response.status_code == 404:
|
||||||
|
return Response(
|
||||||
|
{'error': f'City "{city}" not found or not supported by Pulse.eco'},
|
||||||
|
status=status.HTTP_404_NOT_FOUND
|
||||||
|
)
|
||||||
|
return Response(
|
||||||
|
{'error': 'Failed to fetch air quality data', 'details': str(e)},
|
||||||
|
status=status.HTTP_503_SERVICE_UNAVAILABLE
|
||||||
|
)
|
||||||
|
except requests.RequestException as e:
|
||||||
|
return Response(
|
||||||
|
{'error': 'Failed to fetch air quality data', 'details': str(e)},
|
||||||
|
status=status.HTTP_503_SERVICE_UNAVAILABLE
|
||||||
|
)
|
||||||
|
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
|
|
||||||
from django.apps import AppConfig
|
from django.apps import AppConfig
|
||||||
|
|
||||||
|
|
||||||
class IotDashboardConfig(AppConfig):
|
class IotDashboardConfig(AppConfig):
|
||||||
default_auto_field = 'django.db.models.BigAutoField'
|
default_auto_field = "django.db.models.BigAutoField"
|
||||||
name = 'iotDashboard'
|
name = "iotDashboard"
|
||||||
|
|||||||
@@ -11,6 +11,6 @@ import os
|
|||||||
|
|
||||||
from django.core.asgi import get_asgi_application
|
from django.core.asgi import get_asgi_application
|
||||||
|
|
||||||
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'iotDashboard.settings')
|
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "iotDashboard.settings")
|
||||||
|
|
||||||
application = get_asgi_application()
|
application = get_asgi_application()
|
||||||
|
|||||||
300
iotDashboard/comfort_index.py
Normal file
300
iotDashboard/comfort_index.py
Normal file
@@ -0,0 +1,300 @@
|
|||||||
|
"""
|
||||||
|
Comfort Index Calculation Module
|
||||||
|
|
||||||
|
Combines temperature, humidity, CO₂, noise, and air quality into a weighted comfort score.
|
||||||
|
Score range: 0-100 (100 = optimal comfort)
|
||||||
|
"""
|
||||||
|
|
||||||
|
from typing import Dict, List, Optional, Tuple
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class ComfortMetrics:
|
||||||
|
"""Raw environmental metrics for comfort calculation."""
|
||||||
|
temperature: Optional[float] = None
|
||||||
|
humidity: Optional[float] = None
|
||||||
|
co2: Optional[float] = None
|
||||||
|
noise: Optional[float] = None
|
||||||
|
pm25: Optional[float] = None
|
||||||
|
pm10: Optional[float] = None
|
||||||
|
light: Optional[float] = None
|
||||||
|
timestamp: Optional[datetime] = None
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class ComfortScore:
|
||||||
|
"""Comfort index results with component scores."""
|
||||||
|
overall_score: float # 0-100
|
||||||
|
temperature_score: float
|
||||||
|
humidity_score: float
|
||||||
|
air_quality_score: float
|
||||||
|
acoustic_score: float
|
||||||
|
light_score: float
|
||||||
|
rating: str # "Excellent", "Good", "Fair", "Poor", "Very Poor"
|
||||||
|
suggestions: List[str]
|
||||||
|
|
||||||
|
class ComfortIndexCalculator:
|
||||||
|
"""Calculate multi-factor comfort index from environmental sensors."""
|
||||||
|
|
||||||
|
# Optimal ranges (comfort score = 100 at these values)
|
||||||
|
OPTIMAL_TEMP_RANGE = (20.0, 24.0) # °C
|
||||||
|
OPTIMAL_HUMIDITY_RANGE = (40.0, 60.0) # %
|
||||||
|
OPTIMAL_CO2_MAX = 800 # ppm (< 1000 is good)
|
||||||
|
OPTIMAL_NOISE_MAX = 40 # dB (office environment)
|
||||||
|
OPTIMAL_PM25_MAX = 12 # μg/m³ (WHO guideline)
|
||||||
|
OPTIMAL_PM10_MAX = 20 # μg/m³
|
||||||
|
OPTIMAL_LIGHT_RANGE = (300, 500) # Lux (office work)
|
||||||
|
|
||||||
|
# Weights for overall score (must sum to 1.0)
|
||||||
|
WEIGHTS = {
|
||||||
|
'temperature': 0.25,
|
||||||
|
'humidity': 0.15,
|
||||||
|
'air_quality': 0.30,
|
||||||
|
'acoustic': 0.15,
|
||||||
|
'light': 0.15,
|
||||||
|
}
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def calculate_temperature_score(temp: float) -> Tuple[float, List[str]]:
|
||||||
|
"""Score temperature comfort (0-100)."""
|
||||||
|
suggestions = []
|
||||||
|
optimal_min, optimal_max = ComfortIndexCalculator.OPTIMAL_TEMP_RANGE
|
||||||
|
|
||||||
|
if optimal_min <= temp <= optimal_max:
|
||||||
|
score = 100.0
|
||||||
|
elif temp < optimal_min:
|
||||||
|
deviation = optimal_min - temp
|
||||||
|
score = max(0, 100 - (deviation * 10)) # -10 points per degree below
|
||||||
|
if deviation > 2:
|
||||||
|
suggestions.append(f"Temperature too cold ({temp:.1f}°C). Increase heating to {optimal_min}-{optimal_max}°C")
|
||||||
|
else:
|
||||||
|
deviation = temp - optimal_max
|
||||||
|
score = max(0, 100 - (deviation * 10)) # -10 points per degree above
|
||||||
|
if deviation > 2:
|
||||||
|
suggestions.append(f"Temperature too warm ({temp:.1f}°C). Increase cooling to {optimal_min}-{optimal_max}°C")
|
||||||
|
|
||||||
|
return score, suggestions
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def calculate_humidity_score(humidity: float) -> Tuple[float, List[str]]:
|
||||||
|
"""Score humidity comfort (0-100)."""
|
||||||
|
suggestions = []
|
||||||
|
optimal_min, optimal_max = ComfortIndexCalculator.OPTIMAL_HUMIDITY_RANGE
|
||||||
|
|
||||||
|
if optimal_min <= humidity <= optimal_max:
|
||||||
|
score = 100.0
|
||||||
|
elif humidity < optimal_min:
|
||||||
|
# Too dry
|
||||||
|
deviation = optimal_min - humidity
|
||||||
|
score = max(0, 100 - (deviation * 2)) # -2 points per % below
|
||||||
|
if deviation > 10:
|
||||||
|
suggestions.append(f"Air too dry ({humidity:.1f}%). Use humidifier to reach {optimal_min}-{optimal_max}%")
|
||||||
|
else:
|
||||||
|
# Too humid
|
||||||
|
deviation = humidity - optimal_max
|
||||||
|
score = max(0, 100 - (deviation * 2)) # -2 points per % above
|
||||||
|
if deviation > 10:
|
||||||
|
suggestions.append(f"Air too humid ({humidity:.1f}%). Use dehumidifier to reach {optimal_min}-{optimal_max}%")
|
||||||
|
|
||||||
|
return score, suggestions
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def calculate_air_quality_score(co2: Optional[float], pm25: Optional[float], pm10: Optional[float]) -> Tuple[float, List[str]]:
|
||||||
|
"""Score air quality based on CO₂ and particulate matter (0-100)."""
|
||||||
|
suggestions = []
|
||||||
|
scores = []
|
||||||
|
|
||||||
|
# CO₂ score
|
||||||
|
if co2 is not None:
|
||||||
|
if co2 <= ComfortIndexCalculator.OPTIMAL_CO2_MAX:
|
||||||
|
co2_score = 100.0
|
||||||
|
elif co2 <= 1000:
|
||||||
|
# Acceptable range (800-1000 ppm)
|
||||||
|
co2_score = 100 - ((co2 - 800) / 2) # -0.5 points per ppm
|
||||||
|
else:
|
||||||
|
# Poor air quality
|
||||||
|
co2_score = max(0, 50 - ((co2 - 1000) / 20)) # Drops to 0 at 2000 ppm
|
||||||
|
if co2 > 1500:
|
||||||
|
suggestions.append(f"CO₂ level very high ({co2:.0f} ppm). Increase ventilation immediately")
|
||||||
|
else:
|
||||||
|
suggestions.append(f"CO₂ level elevated ({co2:.0f} ppm). Improve ventilation")
|
||||||
|
scores.append(co2_score)
|
||||||
|
|
||||||
|
# PM2.5 score
|
||||||
|
if pm25 is not None:
|
||||||
|
if pm25 <= ComfortIndexCalculator.OPTIMAL_PM25_MAX:
|
||||||
|
pm25_score = 100.0
|
||||||
|
elif pm25 <= 35.4:
|
||||||
|
pm25_score = 100 - ((pm25 - 12) / 0.234)
|
||||||
|
else:
|
||||||
|
pm25_score = 0
|
||||||
|
suggestions.append(f"PM2.5 unhealthy ({pm25:.1f} μg/m³). Use air purifier and avoid outdoor air")
|
||||||
|
scores.append(pm25_score)
|
||||||
|
|
||||||
|
# PM10 score
|
||||||
|
if pm10 is not None:
|
||||||
|
if pm10 <= ComfortIndexCalculator.OPTIMAL_PM10_MAX:
|
||||||
|
pm10_score = 100.0
|
||||||
|
elif pm10 <= 50: # Acceptable
|
||||||
|
pm10_score = 100 - ((pm10 - 20) / 0.3)
|
||||||
|
else:
|
||||||
|
pm10_score = max(0, 50 - (pm10 - 50))
|
||||||
|
if pm10 > 100:
|
||||||
|
suggestions.append(f"PM10 very high ({pm10:.1f} μg/m³). Close windows and use filtration")
|
||||||
|
scores.append(pm10_score)
|
||||||
|
|
||||||
|
score = sum(scores) / len(scores) if scores else 50.0
|
||||||
|
return score, suggestions
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def calculate_acoustic_score(noise: float) -> Tuple[float, List[str]]:
|
||||||
|
"""Score acoustic comfort based on noise level (0-100)."""
|
||||||
|
suggestions = []
|
||||||
|
|
||||||
|
if noise <= ComfortIndexCalculator.OPTIMAL_NOISE_MAX:
|
||||||
|
score = 100.0
|
||||||
|
elif noise <= 55:
|
||||||
|
score = 100 - ((noise - 40) * 3)
|
||||||
|
elif noise <= 70: # Noisy
|
||||||
|
score = max(0, 55 - ((noise - 55) * 2))
|
||||||
|
suggestions.append(f"Noise level high ({noise:.1f} dB). Consider noise-canceling or quieter environment")
|
||||||
|
else: # Very noisy
|
||||||
|
score = 0
|
||||||
|
suggestions.append(f"Noise level very high ({noise:.1f} dB). Immediate action needed for hearing protection")
|
||||||
|
|
||||||
|
return score, suggestions
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def calculate_light_score(light: float) -> Tuple[float, List[str]]:
|
||||||
|
"""Score lighting comfort (0-100)."""
|
||||||
|
suggestions = []
|
||||||
|
optimal_min, optimal_max = ComfortIndexCalculator.OPTIMAL_LIGHT_RANGE
|
||||||
|
|
||||||
|
if optimal_min <= light <= optimal_max:
|
||||||
|
score = 100.0
|
||||||
|
elif light < optimal_min:
|
||||||
|
# Too dark
|
||||||
|
if light < 100:
|
||||||
|
score = 0
|
||||||
|
suggestions.append(f"Lighting very dim ({light:.0f} lux). Increase to {optimal_min}-{optimal_max} lux")
|
||||||
|
else:
|
||||||
|
score = (light / optimal_min) * 100
|
||||||
|
suggestions.append(f"Lighting insufficient ({light:.0f} lux). Increase to {optimal_min}-{optimal_max} lux")
|
||||||
|
else:
|
||||||
|
# Too bright
|
||||||
|
if light > 1000:
|
||||||
|
score = max(0, 100 - ((light - 1000) / 10))
|
||||||
|
suggestions.append(f"Lighting very bright ({light:.0f} lux). May cause glare. Reduce to {optimal_min}-{optimal_max} lux")
|
||||||
|
else:
|
||||||
|
score = 100 - ((light - optimal_max) / 5)
|
||||||
|
|
||||||
|
return score, suggestions
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def get_rating(score: float) -> str:
|
||||||
|
"""Convert numeric score to rating."""
|
||||||
|
if score >= 90:
|
||||||
|
return "Excellent"
|
||||||
|
elif score >= 75:
|
||||||
|
return "Good"
|
||||||
|
elif score >= 60:
|
||||||
|
return "Fair"
|
||||||
|
elif score >= 40:
|
||||||
|
return "Poor"
|
||||||
|
else:
|
||||||
|
return "Very Poor"
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def calculate(cls, metrics: ComfortMetrics) -> ComfortScore:
|
||||||
|
"""
|
||||||
|
Calculate overall comfort index from environmental metrics.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
metrics: ComfortMetrics with sensor readings
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
ComfortScore with overall score and component breakdowns
|
||||||
|
"""
|
||||||
|
all_suggestions = []
|
||||||
|
component_scores = {}
|
||||||
|
|
||||||
|
# Temperature
|
||||||
|
if metrics.temperature is not None:
|
||||||
|
temp_score, temp_suggestions = cls.calculate_temperature_score(metrics.temperature)
|
||||||
|
component_scores['temperature'] = temp_score
|
||||||
|
all_suggestions.extend(temp_suggestions)
|
||||||
|
else:
|
||||||
|
component_scores['temperature'] = 50.0 #
|
||||||
|
|
||||||
|
# Humidity
|
||||||
|
if metrics.humidity is not None:
|
||||||
|
humidity_score, humidity_suggestions = cls.calculate_humidity_score(metrics.humidity)
|
||||||
|
component_scores['humidity'] = humidity_score
|
||||||
|
all_suggestions.extend(humidity_suggestions)
|
||||||
|
else:
|
||||||
|
component_scores['humidity'] = 50.0
|
||||||
|
|
||||||
|
# Air Quality (CO₂ + PM)
|
||||||
|
air_score, air_suggestions = cls.calculate_air_quality_score(
|
||||||
|
metrics.co2, metrics.pm25, metrics.pm10
|
||||||
|
)
|
||||||
|
component_scores['air_quality'] = air_score
|
||||||
|
all_suggestions.extend(air_suggestions)
|
||||||
|
|
||||||
|
# Acoustic
|
||||||
|
if metrics.noise is not None:
|
||||||
|
acoustic_score, acoustic_suggestions = cls.calculate_acoustic_score(metrics.noise)
|
||||||
|
component_scores['acoustic'] = acoustic_score
|
||||||
|
all_suggestions.extend(acoustic_suggestions)
|
||||||
|
else:
|
||||||
|
component_scores['acoustic'] = 50.0
|
||||||
|
|
||||||
|
# Light
|
||||||
|
if metrics.light is not None:
|
||||||
|
light_score, light_suggestions = cls.calculate_light_score(metrics.light)
|
||||||
|
component_scores['light'] = light_score
|
||||||
|
all_suggestions.extend(light_suggestions)
|
||||||
|
else:
|
||||||
|
component_scores['light'] = 50.0
|
||||||
|
|
||||||
|
# Calculate weighted overall score
|
||||||
|
overall = sum(
|
||||||
|
component_scores[key] * cls.WEIGHTS[key]
|
||||||
|
for key in cls.WEIGHTS.keys()
|
||||||
|
)
|
||||||
|
|
||||||
|
return ComfortScore(
|
||||||
|
overall_score=round(overall, 1),
|
||||||
|
temperature_score=round(component_scores['temperature'], 1),
|
||||||
|
humidity_score=round(component_scores['humidity'], 1),
|
||||||
|
air_quality_score=round(component_scores['air_quality'], 1),
|
||||||
|
acoustic_score=round(component_scores['acoustic'], 1),
|
||||||
|
light_score=round(component_scores['light'], 1),
|
||||||
|
rating=cls.get_rating(overall),
|
||||||
|
suggestions=all_suggestions,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def calculate_comfort_index_from_telemetry(telemetry_data: Dict[str, float]) -> ComfortScore:
|
||||||
|
"""
|
||||||
|
Convenience function to calculate comfort index from telemetry dictionary.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
telemetry_data: Dict with metric names as keys, e.g. {"temperature": 22.5, "humidity": 45}
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
ComfortScore
|
||||||
|
"""
|
||||||
|
metrics = ComfortMetrics(
|
||||||
|
temperature=telemetry_data.get('temperature'),
|
||||||
|
humidity=telemetry_data.get('humidity'),
|
||||||
|
co2=telemetry_data.get('co2') or telemetry_data.get('CO2'),
|
||||||
|
noise=telemetry_data.get('noise') or telemetry_data.get('sound'),
|
||||||
|
pm25=telemetry_data.get('pm2.5') or telemetry_data.get('PM2.5'),
|
||||||
|
pm10=telemetry_data.get('pm10') or telemetry_data.get('PM10'),
|
||||||
|
light=telemetry_data.get('light') or telemetry_data.get('lux'),
|
||||||
|
)
|
||||||
|
|
||||||
|
return ComfortIndexCalculator.calculate(metrics)
|
||||||
215
iotDashboard/device_manager_client.py
Normal file
215
iotDashboard/device_manager_client.py
Normal file
@@ -0,0 +1,215 @@
|
|||||||
|
"""API client for the device_manager microservice."""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import requests
|
||||||
|
from typing import Optional, Dict, Any, List
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class DeviceRegistrationResponse:
|
||||||
|
device_id: str
|
||||||
|
protocol: str
|
||||||
|
certificate_id: Optional[str] = None
|
||||||
|
ca_certificate_pem: Optional[str] = None
|
||||||
|
certificate_pem: Optional[str] = None
|
||||||
|
private_key_pem: Optional[str] = None
|
||||||
|
expires_at: Optional[datetime] = None
|
||||||
|
onboarding_token: Optional[str] = None # One-time token for secure onboarding
|
||||||
|
credential_id: Optional[str] = None
|
||||||
|
api_key: Optional[str] = None
|
||||||
|
webhook_secret: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class DeviceInfo:
|
||||||
|
id: str
|
||||||
|
name: str
|
||||||
|
location: Optional[str]
|
||||||
|
protocol: str
|
||||||
|
connection_config: Optional[Dict[str, Any]]
|
||||||
|
created_at: datetime
|
||||||
|
|
||||||
|
|
||||||
|
class DeviceManagerAPIError(Exception):
|
||||||
|
def __init__(self, status_code: int, message: str, details: Optional[Dict] = None):
|
||||||
|
self.status_code = status_code
|
||||||
|
self.message = message
|
||||||
|
self.details = details or {}
|
||||||
|
super().__init__(f"API Error {status_code}: {message}")
|
||||||
|
|
||||||
|
|
||||||
|
class DeviceManagerClient:
|
||||||
|
def __init__(self, base_url: Optional[str] = None):
|
||||||
|
self.base_url = base_url or os.getenv("DEVICE_MANAGER_URL", "http://localhost:8000")
|
||||||
|
self.session = requests.Session()
|
||||||
|
self.session.headers.update({"Content-Type": "application/json"})
|
||||||
|
|
||||||
|
def _request(self, method: str, endpoint: str, **kwargs) -> requests.Response:
|
||||||
|
url = f"{self.base_url}{endpoint}"
|
||||||
|
try:
|
||||||
|
response = self.session.request(method, url, **kwargs)
|
||||||
|
response.raise_for_status()
|
||||||
|
return response
|
||||||
|
except requests.exceptions.HTTPError as e:
|
||||||
|
try:
|
||||||
|
error_data = e.response.json()
|
||||||
|
message = error_data.get("detail", str(e))
|
||||||
|
except (ValueError, AttributeError):
|
||||||
|
message = str(e)
|
||||||
|
|
||||||
|
raise DeviceManagerAPIError(
|
||||||
|
status_code=e.response.status_code,
|
||||||
|
message=message,
|
||||||
|
details=error_data if "error_data" in locals() else {},
|
||||||
|
)
|
||||||
|
except requests.exceptions.RequestException as e:
|
||||||
|
raise DeviceManagerAPIError(
|
||||||
|
status_code=0, message=f"Connection error: {str(e)}"
|
||||||
|
)
|
||||||
|
|
||||||
|
def register_device(
|
||||||
|
self,
|
||||||
|
name: str,
|
||||||
|
location: Optional[str] = None,
|
||||||
|
protocol: str = "mqtt",
|
||||||
|
connection_config: Optional[Dict[str, Any]] = None
|
||||||
|
) -> DeviceRegistrationResponse:
|
||||||
|
payload = {"name": name, "protocol": protocol}
|
||||||
|
if location:
|
||||||
|
payload["location"] = location
|
||||||
|
if connection_config:
|
||||||
|
payload["connection_config"] = connection_config
|
||||||
|
|
||||||
|
response = self._request("POST", "/devices/register", json=payload)
|
||||||
|
data = response.json()
|
||||||
|
|
||||||
|
return DeviceRegistrationResponse(
|
||||||
|
device_id=data["device_id"],
|
||||||
|
protocol=data["protocol"],
|
||||||
|
certificate_id=data.get("certificate_id"),
|
||||||
|
ca_certificate_pem=data.get("ca_certificate_pem"),
|
||||||
|
certificate_pem=data.get("certificate_pem"),
|
||||||
|
private_key_pem=data.get("private_key_pem"),
|
||||||
|
expires_at=datetime.fromisoformat(data["expires_at"].replace("Z", "+00:00")) if data.get("expires_at") else None,
|
||||||
|
onboarding_token=data.get("onboarding_token"),
|
||||||
|
credential_id=data.get("credential_id"),
|
||||||
|
api_key=data.get("api_key"),
|
||||||
|
webhook_secret=data.get("webhook_secret"),
|
||||||
|
)
|
||||||
|
|
||||||
|
def get_device(self, device_id: str) -> DeviceInfo:
|
||||||
|
response = self._request("GET", f"/devices/{device_id}")
|
||||||
|
data = response.json()
|
||||||
|
|
||||||
|
return DeviceInfo(
|
||||||
|
id=data["id"],
|
||||||
|
name=data["name"],
|
||||||
|
location=data.get("location"),
|
||||||
|
protocol=data["protocol"],
|
||||||
|
connection_config=data.get("connection_config"),
|
||||||
|
created_at=datetime.fromisoformat(data["created_at"].replace("Z", "+00:00")),
|
||||||
|
)
|
||||||
|
|
||||||
|
def list_devices(self) -> List[DeviceInfo]:
|
||||||
|
response = self._request("GET", "/devices/")
|
||||||
|
data = response.json()
|
||||||
|
|
||||||
|
return [
|
||||||
|
DeviceInfo(
|
||||||
|
id=device["id"],
|
||||||
|
name=device["name"],
|
||||||
|
location=device.get("location"),
|
||||||
|
protocol=device["protocol"],
|
||||||
|
connection_config=device.get("connection_config"),
|
||||||
|
created_at=datetime.fromisoformat(
|
||||||
|
device["created_at"].replace("Z", "+00:00")
|
||||||
|
),
|
||||||
|
)
|
||||||
|
for device in data
|
||||||
|
]
|
||||||
|
|
||||||
|
def revoke_certificate(self, device_id: str) -> Dict[str, Any]:
|
||||||
|
response = self._request("POST", f"/devices/{device_id}/revoke")
|
||||||
|
return response.json()
|
||||||
|
|
||||||
|
def renew_certificate(self, device_id: str) -> DeviceRegistrationResponse:
|
||||||
|
response = self._request("POST", f"/devices/{device_id}/renew")
|
||||||
|
data = response.json()
|
||||||
|
|
||||||
|
return DeviceRegistrationResponse(
|
||||||
|
device_id=data["device_id"],
|
||||||
|
protocol=data["protocol"],
|
||||||
|
certificate_id=data.get("certificate_id"),
|
||||||
|
ca_certificate_pem=data.get("ca_certificate_pem"),
|
||||||
|
certificate_pem=data.get("certificate_pem"),
|
||||||
|
private_key_pem=data.get("private_key_pem"),
|
||||||
|
expires_at=datetime.fromisoformat(data["expires_at"].replace("Z", "+00:00")) if data.get("expires_at") else None,
|
||||||
|
credential_id=data.get("credential_id"),
|
||||||
|
api_key=data.get("api_key"),
|
||||||
|
webhook_secret=data.get("webhook_secret"),
|
||||||
|
)
|
||||||
|
|
||||||
|
def get_ca_certificate(self) -> str:
|
||||||
|
response = self._request("GET", "/ca_certificate")
|
||||||
|
return response.text
|
||||||
|
|
||||||
|
def get_crl(self) -> str:
|
||||||
|
response = self._request("GET", "/crl")
|
||||||
|
return response.text
|
||||||
|
|
||||||
|
def get_device_credentials(self, device_id: str, token: str) -> DeviceRegistrationResponse:
|
||||||
|
"""Fetch device credentials using one-time onboarding token."""
|
||||||
|
response = self._request("GET", f"/devices/{device_id}/credentials", params={"token": token})
|
||||||
|
data = response.json()
|
||||||
|
|
||||||
|
# DeviceCertificateResponse from FastAPI doesn't include protocol
|
||||||
|
# We'll use "mqtt" as default since credentials endpoint is only for MQTT devices
|
||||||
|
return DeviceRegistrationResponse(
|
||||||
|
device_id=data["device_id"],
|
||||||
|
protocol="mqtt", # Credentials endpoint is only for MQTT devices
|
||||||
|
certificate_id=data.get("certificate_id"),
|
||||||
|
ca_certificate_pem=data.get("ca_certificate_pem"),
|
||||||
|
certificate_pem=data.get("certificate_pem"),
|
||||||
|
private_key_pem=data.get("private_key_pem"),
|
||||||
|
expires_at=datetime.fromisoformat(data["expires_at"].replace("Z", "+00:00")) if data.get("expires_at") else None,
|
||||||
|
credential_id=None,
|
||||||
|
api_key=None,
|
||||||
|
webhook_secret=None,
|
||||||
|
)
|
||||||
|
|
||||||
|
def health_check(self) -> bool:
|
||||||
|
try:
|
||||||
|
response = self.session.get(f"{self.base_url}/docs", timeout=2)
|
||||||
|
return response.status_code == 200
|
||||||
|
except requests.exceptions.RequestException:
|
||||||
|
return False
|
||||||
|
|
||||||
|
|
||||||
|
default_client = DeviceManagerClient()
|
||||||
|
|
||||||
|
|
||||||
|
def register_device(
|
||||||
|
name: str,
|
||||||
|
location: Optional[str] = None,
|
||||||
|
protocol: str = "mqtt",
|
||||||
|
connection_config: Optional[Dict[str, Any]] = None
|
||||||
|
) -> DeviceRegistrationResponse:
|
||||||
|
return default_client.register_device(name, location, protocol, connection_config)
|
||||||
|
|
||||||
|
|
||||||
|
def get_device(device_id: str) -> DeviceInfo:
|
||||||
|
return default_client.get_device(device_id)
|
||||||
|
|
||||||
|
|
||||||
|
def list_devices() -> List[DeviceInfo]:
|
||||||
|
return default_client.list_devices()
|
||||||
|
|
||||||
|
|
||||||
|
def revoke_certificate(device_id: str) -> Dict[str, Any]:
|
||||||
|
return default_client.revoke_certificate(device_id)
|
||||||
|
|
||||||
|
|
||||||
|
def renew_certificate(device_id: str) -> Dict[str, Any]:
|
||||||
|
return default_client.renew_certificate(device_id)
|
||||||
@@ -1,64 +1,40 @@
|
|||||||
|
"""
|
||||||
|
Django forms for the IoT Dashboard.
|
||||||
|
|
||||||
|
Note: Device registration is handled through the device_manager API.
|
||||||
|
These forms are used for the legacy Django UI only.
|
||||||
|
"""
|
||||||
|
|
||||||
from django import forms
|
from django import forms
|
||||||
from iotDashboard.models import Device, Sensor, SensorType
|
from iotDashboard.models import Device
|
||||||
|
|
||||||
|
|
||||||
class DeviceForm(forms.ModelForm):
|
class DeviceForm(forms.ModelForm):
|
||||||
|
"""
|
||||||
|
Form for creating/editing devices.
|
||||||
|
|
||||||
|
Note: This is for the Django UI only. Actual device registration
|
||||||
|
happens through the device_manager microservice API.
|
||||||
|
"""
|
||||||
|
|
||||||
|
protocol = forms.ChoiceField(
|
||||||
|
choices=[
|
||||||
|
("mqtt", "MQTT"),
|
||||||
|
("http", "HTTP"),
|
||||||
|
("webhook", "Webhook"),
|
||||||
|
],
|
||||||
|
initial="mqtt",
|
||||||
|
help_text="Communication protocol for this device",
|
||||||
|
)
|
||||||
|
|
||||||
class Meta:
|
class Meta:
|
||||||
model = Device
|
model = Device
|
||||||
fields = ['name', 'ip', 'protocol'] # Exclude sensors from the fields
|
fields = ["name", "location", "protocol"]
|
||||||
|
widgets = {
|
||||||
def __init__(self, *args, **kwargs):
|
"name": forms.TextInput(attrs={"class": "form-control", "placeholder": "Device name"}),
|
||||||
# No need to handle sensors in the form
|
"location": forms.TextInput(attrs={"class": "form-control", "placeholder": "Device location (optional)"}),
|
||||||
super(DeviceForm, self).__init__(*args, **kwargs)
|
}
|
||||||
|
help_texts = {
|
||||||
def save(self, commit=True):
|
"name": "Unique identifier for this device",
|
||||||
# Save the device instance
|
"location": "Physical location or description",
|
||||||
device = super(DeviceForm, self).save(commit=False)
|
}
|
||||||
|
|
||||||
if commit:
|
|
||||||
device.save()
|
|
||||||
|
|
||||||
return device
|
|
||||||
class SensorWithTypeForm(forms.ModelForm):
|
|
||||||
# Add fields for SensorType directly in the form
|
|
||||||
type_name = forms.CharField(max_length=50, label="Sensor Type Name")
|
|
||||||
unit = forms.CharField(max_length=20, label="Unit", required=False)
|
|
||||||
protocol = forms.ChoiceField(
|
|
||||||
choices=[('mqtt', 'MQTT'), ('http', 'HTTP')],
|
|
||||||
label="Protocol"
|
|
||||||
)
|
|
||||||
topic = forms.CharField(max_length=100, label="Topic", required=False)
|
|
||||||
endpoint = forms.CharField(max_length=100, label="Endpoint", required=False)
|
|
||||||
|
|
||||||
class Meta:
|
|
||||||
model = Sensor
|
|
||||||
fields = ['enabled'] # Exclude 'device' from the form fields
|
|
||||||
|
|
||||||
def __init__(self, *args, **kwargs):
|
|
||||||
self.device = kwargs.pop('device', None) # Get the device from kwargs
|
|
||||||
super(SensorWithTypeForm, self).__init__(*args, **kwargs)
|
|
||||||
|
|
||||||
def save(self, commit=True):
|
|
||||||
# Create or get the SensorType
|
|
||||||
try:
|
|
||||||
sensor_type = SensorType.objects.get(name=self.cleaned_data['type_name'])
|
|
||||||
except SensorType.DoesNotExist:
|
|
||||||
sensor_type = SensorType(
|
|
||||||
name=self.cleaned_data['type_name'],
|
|
||||||
unit=self.cleaned_data['unit'],
|
|
||||||
protocol=self.cleaned_data['protocol'],
|
|
||||||
topic=self.cleaned_data['topic'],
|
|
||||||
endpoint=self.cleaned_data['endpoint']
|
|
||||||
)
|
|
||||||
if commit:
|
|
||||||
sensor_type.save()
|
|
||||||
|
|
||||||
# Create Sensor with the SensorType found or created
|
|
||||||
sensor = super(SensorWithTypeForm, self).save(commit=False)
|
|
||||||
sensor.type = sensor_type
|
|
||||||
sensor.device = self.device # Associate the sensor with the device
|
|
||||||
|
|
||||||
if commit:
|
|
||||||
sensor.save()
|
|
||||||
|
|
||||||
return sensor
|
|
||||||
|
|||||||
267
iotDashboard/gpt_service_client.py
Normal file
267
iotDashboard/gpt_service_client.py
Normal file
@@ -0,0 +1,267 @@
|
|||||||
|
"""
|
||||||
|
Client for GPT Service microservice.
|
||||||
|
|
||||||
|
Handles communication between Django and the GPT analysis service.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
import logging
|
||||||
|
from typing import List, Dict, Any, Optional, Literal
|
||||||
|
from django.conf import settings
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class GPTServiceError(Exception):
|
||||||
|
"""Exception raised when GPT service request fails."""
|
||||||
|
|
||||||
|
def __init__(self, message: str, status_code: Optional[int] = None, details: Optional[Dict] = None):
|
||||||
|
self.message = message
|
||||||
|
self.status_code = status_code
|
||||||
|
self.details = details or {}
|
||||||
|
super().__init__(self.message)
|
||||||
|
|
||||||
|
|
||||||
|
class GPTServiceClient:
|
||||||
|
"""Client for interacting with the GPT analysis microservice."""
|
||||||
|
|
||||||
|
def __init__(self, base_url: Optional[str] = None, timeout: float = 30.0):
|
||||||
|
"""
|
||||||
|
Initialize GPT service client.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
base_url: Base URL of GPT service (default: from settings or http://localhost:8001)
|
||||||
|
timeout: Request timeout in seconds
|
||||||
|
"""
|
||||||
|
self.base_url = base_url or getattr(settings, 'GPT_SERVICE_URL', 'http://localhost:8001')
|
||||||
|
self.timeout = timeout
|
||||||
|
self.client = httpx.AsyncClient(base_url=self.base_url, timeout=timeout)
|
||||||
|
logger.info(f"Initialized GPTServiceClient with base_url={self.base_url}")
|
||||||
|
|
||||||
|
async def health_check(self) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Check if GPT service is healthy.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dict with status information
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
httpx.HTTPError: If service is unreachable
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
response = await self.client.get("/health")
|
||||||
|
response.raise_for_status()
|
||||||
|
return response.json()
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"GPT service health check failed: {e}")
|
||||||
|
raise
|
||||||
|
|
||||||
|
async def analyze_telemetry(
|
||||||
|
self,
|
||||||
|
telemetry_data: List[Dict[str, Any]],
|
||||||
|
device_info: Optional[Dict[str, Any]] = None,
|
||||||
|
prompt_type: Literal["anomaly_detection", "trend_summary", "custom"] = "trend_summary",
|
||||||
|
custom_prompt: Optional[str] = None
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Analyze telemetry data using GPT service.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
telemetry_data: List of telemetry data points with keys:
|
||||||
|
- device_id: str
|
||||||
|
- metric: str
|
||||||
|
- value: float
|
||||||
|
- timestamp: str (ISO format)
|
||||||
|
device_info: Optional device metadata (name, location, etc.)
|
||||||
|
prompt_type: Type of analysis to perform
|
||||||
|
custom_prompt: Custom prompt for 'custom' type
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dict containing:
|
||||||
|
- analysis: str (AI-generated analysis)
|
||||||
|
- prompt_type: str
|
||||||
|
- data_points_analyzed: int
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ValueError: If telemetry_data is empty
|
||||||
|
httpx.HTTPError: If service request fails
|
||||||
|
"""
|
||||||
|
if not telemetry_data:
|
||||||
|
raise ValueError("telemetry_data cannot be empty")
|
||||||
|
|
||||||
|
payload = {
|
||||||
|
"telemetry_data": telemetry_data,
|
||||||
|
"device_info": device_info or {},
|
||||||
|
"prompt_type": prompt_type,
|
||||||
|
}
|
||||||
|
|
||||||
|
if custom_prompt:
|
||||||
|
payload["custom_prompt"] = custom_prompt
|
||||||
|
|
||||||
|
try:
|
||||||
|
logger.info(
|
||||||
|
f"Requesting {prompt_type} analysis for {len(telemetry_data)} data points"
|
||||||
|
)
|
||||||
|
response = await self.client.post("/analyze", json=payload)
|
||||||
|
response.raise_for_status()
|
||||||
|
result = response.json()
|
||||||
|
logger.info(
|
||||||
|
f"Analysis completed: {result.get('data_points_analyzed')} points analyzed"
|
||||||
|
)
|
||||||
|
return result
|
||||||
|
except httpx.HTTPStatusError as e:
|
||||||
|
error_detail = e.response.text
|
||||||
|
logger.error(
|
||||||
|
f"GPT service returned error {e.response.status_code}: {error_detail}"
|
||||||
|
)
|
||||||
|
raise GPTServiceError(
|
||||||
|
message=f"GPT service error: {error_detail}",
|
||||||
|
status_code=e.response.status_code,
|
||||||
|
details={"response": error_detail}
|
||||||
|
)
|
||||||
|
except httpx.RequestError as e:
|
||||||
|
logger.error(f"Failed to connect to GPT service: {e}")
|
||||||
|
raise GPTServiceError(
|
||||||
|
message=f"GPT service unavailable: {str(e)}",
|
||||||
|
status_code=503,
|
||||||
|
details={"error": str(e)}
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to analyze telemetry: {e}")
|
||||||
|
raise GPTServiceError(
|
||||||
|
message=f"Analysis failed: {str(e)}",
|
||||||
|
details={"error": str(e)}
|
||||||
|
)
|
||||||
|
|
||||||
|
async def detect_anomalies(
|
||||||
|
self,
|
||||||
|
telemetry_data: List[Dict[str, Any]],
|
||||||
|
device_info: Optional[Dict[str, Any]] = None
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Detect anomalies in telemetry data.
|
||||||
|
|
||||||
|
Convenience method for anomaly_detection analysis.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dict with analysis containing:
|
||||||
|
- status: normal|warning|critical
|
||||||
|
- summary: Brief overview
|
||||||
|
- anomalies: List of detected anomalies
|
||||||
|
- impacts: List of potential impacts
|
||||||
|
- actions: List of recommended actions
|
||||||
|
"""
|
||||||
|
result = await self.analyze_telemetry(
|
||||||
|
telemetry_data=telemetry_data,
|
||||||
|
device_info=device_info,
|
||||||
|
prompt_type="anomaly_detection"
|
||||||
|
)
|
||||||
|
return result
|
||||||
|
|
||||||
|
async def summarize_trends(
|
||||||
|
self,
|
||||||
|
telemetry_data: List[Dict[str, Any]],
|
||||||
|
device_info: Optional[Dict[str, Any]] = None
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Summarize trends in telemetry data.
|
||||||
|
|
||||||
|
Convenience method for trend_summary analysis.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dict with analysis containing:
|
||||||
|
- status: excellent|good|fair|poor
|
||||||
|
- summary: Brief overview
|
||||||
|
- trends: List of metric trends
|
||||||
|
- comfort_score: Overall comfort rating
|
||||||
|
- patterns: Identified patterns
|
||||||
|
- recommendations: Suggested actions
|
||||||
|
"""
|
||||||
|
result = await self.analyze_telemetry(
|
||||||
|
telemetry_data=telemetry_data,
|
||||||
|
device_info=device_info,
|
||||||
|
prompt_type="trend_summary"
|
||||||
|
)
|
||||||
|
return result
|
||||||
|
|
||||||
|
async def close(self):
|
||||||
|
"""Close the HTTP client connection."""
|
||||||
|
await self.client.aclose()
|
||||||
|
|
||||||
|
async def __aenter__(self):
|
||||||
|
"""Async context manager entry."""
|
||||||
|
return self
|
||||||
|
|
||||||
|
async def __aexit__(self, exc_type, exc_val, exc_tb):
|
||||||
|
"""Async context manager exit."""
|
||||||
|
await self.close()
|
||||||
|
|
||||||
|
|
||||||
|
def format_telemetry_for_gpt(queryset, device_id: Optional[str] = None) -> List[Dict[str, Any]]:
|
||||||
|
"""
|
||||||
|
Format Django Telemetry queryset for GPT service.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
queryset: Django queryset of Telemetry objects
|
||||||
|
device_id: Optional device_id to include in each point
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of dicts formatted for GPT service
|
||||||
|
"""
|
||||||
|
data = []
|
||||||
|
for point in queryset:
|
||||||
|
data.append({
|
||||||
|
"device_id": device_id or point.device_id,
|
||||||
|
"metric": point.metric,
|
||||||
|
"value": float(point.value),
|
||||||
|
"timestamp": point.time.isoformat(),
|
||||||
|
})
|
||||||
|
return data
|
||||||
|
|
||||||
|
|
||||||
|
_client_instance = None
|
||||||
|
|
||||||
|
|
||||||
|
def get_gpt_client() -> GPTServiceClient:
|
||||||
|
"""
|
||||||
|
Get or create singleton GPT service client instance.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
GPTServiceClient instance
|
||||||
|
"""
|
||||||
|
global _client_instance
|
||||||
|
if _client_instance is None:
|
||||||
|
_client_instance = GPTServiceClient()
|
||||||
|
return _client_instance
|
||||||
|
|
||||||
|
|
||||||
|
async def analyze_telemetry(
|
||||||
|
telemetry_data: List[Dict[str, Any]],
|
||||||
|
device_info: Optional[Dict[str, Any]] = None,
|
||||||
|
prompt_type: Literal["anomaly_detection", "trend_summary", "custom"] = "trend_summary",
|
||||||
|
custom_prompt: Optional[str] = None
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Module-level function to analyze telemetry data.
|
||||||
|
|
||||||
|
Uses singleton client instance. Convenience wrapper for Django views.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
telemetry_data: List of telemetry data points
|
||||||
|
device_info: Optional device metadata
|
||||||
|
prompt_type: Type of analysis to perform
|
||||||
|
custom_prompt: Custom prompt for 'custom' type
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dict containing analysis results
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
GPTServiceError: If analysis fails
|
||||||
|
"""
|
||||||
|
client = get_gpt_client()
|
||||||
|
return await client.analyze_telemetry(
|
||||||
|
telemetry_data=telemetry_data,
|
||||||
|
device_info=device_info,
|
||||||
|
prompt_type=prompt_type,
|
||||||
|
custom_prompt=custom_prompt
|
||||||
|
)
|
||||||
6
iotDashboard/main.py
Normal file
6
iotDashboard/main.py
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
def main():
|
||||||
|
print("Hello from iotdashboard!")
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
main()
|
||||||
89
iotDashboard/migrations/0001_initial.py
Normal file
89
iotDashboard/migrations/0001_initial.py
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
# Generated by Django 5.1.7 on 2025-03-27 10:47
|
||||||
|
|
||||||
|
import django.db.models.deletion
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
initial = True
|
||||||
|
|
||||||
|
dependencies = []
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.CreateModel(
|
||||||
|
name="Device",
|
||||||
|
fields=[
|
||||||
|
(
|
||||||
|
"id",
|
||||||
|
models.BigAutoField(
|
||||||
|
auto_created=True,
|
||||||
|
primary_key=True,
|
||||||
|
serialize=False,
|
||||||
|
verbose_name="ID",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
("name", models.CharField(max_length=50)),
|
||||||
|
("ip", models.CharField(max_length=20)),
|
||||||
|
(
|
||||||
|
"protocol",
|
||||||
|
models.CharField(
|
||||||
|
choices=[("mqtt", "MQTT"), ("http", "HTTP")], max_length=20
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name="SensorType",
|
||||||
|
fields=[
|
||||||
|
(
|
||||||
|
"id",
|
||||||
|
models.BigAutoField(
|
||||||
|
auto_created=True,
|
||||||
|
primary_key=True,
|
||||||
|
serialize=False,
|
||||||
|
verbose_name="ID",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
("name", models.CharField(max_length=50, unique=True)),
|
||||||
|
("unit", models.CharField(max_length=20)),
|
||||||
|
(
|
||||||
|
"protocol",
|
||||||
|
models.CharField(
|
||||||
|
choices=[("mqtt", "MQTT"), ("http", "HTTP")], max_length=20
|
||||||
|
),
|
||||||
|
),
|
||||||
|
("topic", models.CharField(blank=True, max_length=100, null=True)),
|
||||||
|
("endpoint", models.CharField(blank=True, max_length=100, null=True)),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name="Sensor",
|
||||||
|
fields=[
|
||||||
|
(
|
||||||
|
"id",
|
||||||
|
models.BigAutoField(
|
||||||
|
auto_created=True,
|
||||||
|
primary_key=True,
|
||||||
|
serialize=False,
|
||||||
|
verbose_name="ID",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
("enabled", models.BooleanField(default=True)),
|
||||||
|
(
|
||||||
|
"device",
|
||||||
|
models.ForeignKey(
|
||||||
|
on_delete=django.db.models.deletion.CASCADE,
|
||||||
|
related_name="sensors",
|
||||||
|
to="iotDashboard.device",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"type",
|
||||||
|
models.ForeignKey(
|
||||||
|
on_delete=django.db.models.deletion.CASCADE,
|
||||||
|
to="iotDashboard.sensortype",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -5,43 +5,78 @@ import django.db.models.deletion
|
|||||||
|
|
||||||
|
|
||||||
class Migration(migrations.Migration):
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
dependencies = [
|
dependencies = [
|
||||||
('iotDashboard', '0001_initial'),
|
("iotDashboard", "0001_initial"),
|
||||||
]
|
]
|
||||||
|
|
||||||
operations = [
|
operations = [
|
||||||
migrations.CreateModel(
|
migrations.CreateModel(
|
||||||
name='SensorType',
|
name="SensorType",
|
||||||
fields=[
|
fields=[
|
||||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
(
|
||||||
('name', models.CharField(max_length=50, unique=True)),
|
"id",
|
||||||
('unit', models.CharField(max_length=20)),
|
models.BigAutoField(
|
||||||
('protocol', models.CharField(choices=[('mqtt', 'MQTT'), ('http', 'HTTP')], max_length=20)),
|
auto_created=True,
|
||||||
('topic', models.CharField(blank=True, max_length=100, null=True)),
|
primary_key=True,
|
||||||
('endpoint', models.CharField(blank=True, max_length=100, null=True)),
|
serialize=False,
|
||||||
|
verbose_name="ID",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
("name", models.CharField(max_length=50, unique=True)),
|
||||||
|
("unit", models.CharField(max_length=20)),
|
||||||
|
(
|
||||||
|
"protocol",
|
||||||
|
models.CharField(
|
||||||
|
choices=[("mqtt", "MQTT"), ("http", "HTTP")], max_length=20
|
||||||
|
),
|
||||||
|
),
|
||||||
|
("topic", models.CharField(blank=True, max_length=100, null=True)),
|
||||||
|
("endpoint", models.CharField(blank=True, max_length=100, null=True)),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
migrations.RemoveField(
|
migrations.RemoveField(
|
||||||
model_name='device',
|
model_name="device",
|
||||||
name='humidity',
|
name="humidity",
|
||||||
),
|
),
|
||||||
migrations.RemoveField(
|
migrations.RemoveField(
|
||||||
model_name='device',
|
model_name="device",
|
||||||
name='temperature',
|
name="temperature",
|
||||||
),
|
),
|
||||||
migrations.AlterField(
|
migrations.AlterField(
|
||||||
model_name='device',
|
model_name="device",
|
||||||
name='protocol',
|
name="protocol",
|
||||||
field=models.CharField(choices=[('mqtt', 'MQTT'), ('http', 'HTTP')], max_length=20),
|
field=models.CharField(
|
||||||
|
choices=[("mqtt", "MQTT"), ("http", "HTTP")], max_length=20
|
||||||
|
),
|
||||||
),
|
),
|
||||||
migrations.CreateModel(
|
migrations.CreateModel(
|
||||||
name='Sensor',
|
name="Sensor",
|
||||||
fields=[
|
fields=[
|
||||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
(
|
||||||
('enabled', models.BooleanField(default=True)),
|
"id",
|
||||||
('device', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='sensors', to='iotDashboard.device')),
|
models.BigAutoField(
|
||||||
('type', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='iotDashboard.sensortype')),
|
auto_created=True,
|
||||||
|
primary_key=True,
|
||||||
|
serialize=False,
|
||||||
|
verbose_name="ID",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
("enabled", models.BooleanField(default=True)),
|
||||||
|
(
|
||||||
|
"device",
|
||||||
|
models.ForeignKey(
|
||||||
|
on_delete=django.db.models.deletion.CASCADE,
|
||||||
|
related_name="sensors",
|
||||||
|
to="iotDashboard.device",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
(
|
||||||
|
"type",
|
||||||
|
models.ForeignKey(
|
||||||
|
on_delete=django.db.models.deletion.CASCADE,
|
||||||
|
to="iotDashboard.sensortype",
|
||||||
|
),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
]
|
]
|
||||||
|
|||||||
0
iotDashboard/migrations/__init__.py
Normal file
0
iotDashboard/migrations/__init__.py
Normal file
@@ -1,26 +1,177 @@
|
|||||||
|
"""
|
||||||
|
Django models that mirror the SQLAlchemy schema from db_migrations/models.py.
|
||||||
|
|
||||||
|
These models are read-only (managed=False) and query the microservices database.
|
||||||
|
For write operations, use the device_manager API client instead.
|
||||||
|
"""
|
||||||
|
|
||||||
from django.db import models
|
from django.db import models
|
||||||
|
|
||||||
class SensorType(models.Model):
|
|
||||||
name = models.CharField(max_length=50, unique=True) # Sensor name, e.g., "CO2", "Noise", etc.
|
|
||||||
unit = models.CharField(max_length=20) # Unit of measurement, e.g., "ppm", "dB", "lux"
|
|
||||||
protocol = models.CharField(max_length=20, choices=[('mqtt', 'MQTT'), ('http', 'HTTP')]) # Protocol for communication
|
|
||||||
topic = models.CharField(max_length=100, null=True, blank=True) # Topic for MQTT communication
|
|
||||||
endpoint = models.CharField(max_length=100, null=True, blank=True) # Endpoint for HTTP communication
|
|
||||||
|
|
||||||
def __str__(self):
|
|
||||||
return f"{self.name} ({self.unit})"
|
|
||||||
|
|
||||||
class Device(models.Model):
|
class Device(models.Model):
|
||||||
name = models.CharField(max_length=50) # Device name
|
"""IoT devices registered in the system."""
|
||||||
ip = models.CharField(max_length=20) # Device IP address
|
|
||||||
protocol = models.CharField(max_length=20, choices=[('mqtt', 'MQTT'), ('http', 'HTTP')])
|
id = models.CharField(max_length=8, primary_key=True)
|
||||||
|
name = models.CharField(max_length=255)
|
||||||
|
location = models.CharField(max_length=255, null=True, blank=True)
|
||||||
|
protocol = models.CharField(max_length=50, default="mqtt")
|
||||||
|
connection_config = models.JSONField(null=True, blank=True)
|
||||||
|
is_active = models.BooleanField(default=True)
|
||||||
|
created_at = models.DateTimeField(auto_now_add=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
managed = False
|
||||||
|
db_table = "devices"
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
return self.name
|
return f"{self.name} ({self.id}) [{self.protocol}]"
|
||||||
|
|
||||||
|
@property
|
||||||
|
def active_certificate(self):
|
||||||
|
"""Get the active (non-revoked) certificate for this device."""
|
||||||
|
return self.certificates.filter(revoked_at__isnull=True).first()
|
||||||
|
|
||||||
|
@property
|
||||||
|
def certificate_status(self):
|
||||||
|
"""Get human-readable certificate status for MQTT devices."""
|
||||||
|
if self.protocol != "mqtt":
|
||||||
|
return "N/A"
|
||||||
|
cert = self.active_certificate
|
||||||
|
if not cert:
|
||||||
|
return "No Certificate"
|
||||||
|
if cert.is_expired:
|
||||||
|
return "Expired"
|
||||||
|
if cert.is_expiring_soon:
|
||||||
|
return "Expiring Soon"
|
||||||
|
return "Valid"
|
||||||
|
|
||||||
|
|
||||||
|
class DeviceCertificate(models.Model):
|
||||||
|
"""X.509 certificates issued to devices for mTLS authentication."""
|
||||||
|
|
||||||
|
id = models.CharField(
|
||||||
|
max_length=255, primary_key=True
|
||||||
|
) # Certificate serial number (hex)
|
||||||
|
device = models.ForeignKey(
|
||||||
|
Device, on_delete=models.CASCADE, related_name="certificates", db_column="device_id"
|
||||||
|
)
|
||||||
|
certificate_pem = models.TextField()
|
||||||
|
private_key_pem = models.TextField(null=True, blank=True) # Optional backup
|
||||||
|
issued_at = models.DateTimeField()
|
||||||
|
expires_at = models.DateTimeField()
|
||||||
|
revoked_at = models.DateTimeField(null=True, blank=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
managed = False # Don't create/modify this table
|
||||||
|
db_table = "device_certificates"
|
||||||
|
indexes = [
|
||||||
|
models.Index(fields=["device"]),
|
||||||
|
models.Index(fields=["device", "revoked_at"]),
|
||||||
|
]
|
||||||
|
|
||||||
class Sensor(models.Model):
|
|
||||||
device = models.ForeignKey(Device, related_name='sensors', on_delete=models.CASCADE)
|
|
||||||
type = models.ForeignKey(SensorType, on_delete=models.CASCADE)
|
|
||||||
enabled = models.BooleanField(default=True)
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
return f"{self.type.name} Sensor on {self.device.name}"
|
status = "Revoked" if self.revoked_at else "Active"
|
||||||
|
return f"Certificate {self.id[:8]}... for {self.device.name} ({status})"
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_revoked(self):
|
||||||
|
"""Check if certificate is revoked."""
|
||||||
|
return self.revoked_at is not None
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_expired(self):
|
||||||
|
"""Check if certificate is expired."""
|
||||||
|
from django.utils import timezone
|
||||||
|
|
||||||
|
return timezone.now() > self.expires_at
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_expiring_soon(self):
|
||||||
|
"""Check if certificate expires within 30 days."""
|
||||||
|
from django.utils import timezone
|
||||||
|
from datetime import timedelta
|
||||||
|
|
||||||
|
return (
|
||||||
|
not self.is_expired
|
||||||
|
and self.expires_at < timezone.now() + timedelta(days=30)
|
||||||
|
)
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_valid(self):
|
||||||
|
"""Check if certificate is valid (not revoked and not expired)."""
|
||||||
|
return not self.is_revoked and not self.is_expired
|
||||||
|
|
||||||
|
@property
|
||||||
|
def days_until_expiry(self):
|
||||||
|
"""Calculate days until certificate expires."""
|
||||||
|
from django.utils import timezone
|
||||||
|
|
||||||
|
if self.is_expired:
|
||||||
|
return 0
|
||||||
|
delta = self.expires_at - timezone.now()
|
||||||
|
return delta.days
|
||||||
|
|
||||||
|
|
||||||
|
class DeviceCredential(models.Model):
|
||||||
|
"""Authentication credentials for non-mTLS protocols (HTTP, webhook, etc)."""
|
||||||
|
|
||||||
|
id = models.CharField(max_length=255, primary_key=True)
|
||||||
|
device = models.ForeignKey(
|
||||||
|
Device, on_delete=models.CASCADE, related_name="credentials", db_column="device_id"
|
||||||
|
)
|
||||||
|
credential_type = models.CharField(max_length=50)
|
||||||
|
credential_hash = models.TextField()
|
||||||
|
created_at = models.DateTimeField()
|
||||||
|
expires_at = models.DateTimeField(null=True, blank=True)
|
||||||
|
revoked_at = models.DateTimeField(null=True, blank=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
managed = False
|
||||||
|
db_table = "device_credentials"
|
||||||
|
indexes = [
|
||||||
|
models.Index(fields=["device"]),
|
||||||
|
models.Index(fields=["device", "revoked_at"]),
|
||||||
|
]
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
status = "Revoked" if self.revoked_at else "Active"
|
||||||
|
return f"{self.credential_type} for {self.device.name} ({status})"
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_revoked(self):
|
||||||
|
return self.revoked_at is not None
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_expired(self):
|
||||||
|
from django.utils import timezone
|
||||||
|
return self.expires_at and timezone.now() > self.expires_at
|
||||||
|
|
||||||
|
@property
|
||||||
|
def is_valid(self):
|
||||||
|
return not self.is_revoked and not self.is_expired
|
||||||
|
|
||||||
|
|
||||||
|
class Telemetry(models.Model):
|
||||||
|
"""Time-series telemetry data from devices (TimescaleDB hypertable).
|
||||||
|
|
||||||
|
Note: This table has a composite primary key (time, device_id, metric).
|
||||||
|
Since Django doesn't support composite PKs well, we mark time as the PK
|
||||||
|
but queries should filter by (time, device_id, metric) together.
|
||||||
|
"""
|
||||||
|
|
||||||
|
time = models.DateTimeField(primary_key=True)
|
||||||
|
device_id = models.CharField(max_length=255, db_column="device_id")
|
||||||
|
metric = models.CharField(max_length=255)
|
||||||
|
value = models.FloatField()
|
||||||
|
unit = models.CharField(max_length=50, null=True, blank=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
managed = False
|
||||||
|
db_table = "telemetry"
|
||||||
|
# Note: The actual database has composite PK (time, device_id, metric)
|
||||||
|
# Django limitation: can only mark one field as PK
|
||||||
|
unique_together = [["time", "device_id", "metric"]]
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return f"{self.device.name} - {self.metric}: {self.value} at {self.time}"
|
||||||
|
|
||||||
|
|||||||
5
iotDashboard/pyproject.toml
Normal file
5
iotDashboard/pyproject.toml
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
[project]
|
||||||
|
name = "dashboard"
|
||||||
|
version = "0.1.0"
|
||||||
|
requires-python = ">=3.13"
|
||||||
|
dependencies = []
|
||||||
@@ -9,10 +9,10 @@ https://docs.djangoproject.com/en/4.2/topics/settings/
|
|||||||
For the full list of settings and their values, see
|
For the full list of settings and their values, see
|
||||||
https://docs.djangoproject.com/en/4.2/ref/settings/
|
https://docs.djangoproject.com/en/4.2/ref/settings/
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from dotenv import load_dotenv
|
from dotenv import load_dotenv
|
||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
import os
|
import os
|
||||||
from huey import SqliteHuey
|
|
||||||
|
|
||||||
|
|
||||||
# Build paths inside the project like this: BASE_DIR / 'subdir'.
|
# Build paths inside the project like this: BASE_DIR / 'subdir'.
|
||||||
@@ -21,82 +21,82 @@ BASE_DIR = Path(__file__).resolve().parent.parent
|
|||||||
load_dotenv()
|
load_dotenv()
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
# Quick-start development settings - unsuitable for production
|
# Quick-start development settings - unsuitable for production
|
||||||
# See https://docs.djangoproject.com/en/4.2/howto/deployment/checklist/
|
# See https://docs.djangoproject.com/en/4.2/howto/deployment/checklist/
|
||||||
|
|
||||||
# SECURITY WARNING: keep the secret key used in production secret!
|
# SECURITY WARNING: keep the secret key used in production secret!
|
||||||
SECRET_KEY = os.getenv('SECRET_KEY')
|
SECRET_KEY = os.getenv("SECRET_KEY")
|
||||||
CONNECTION_STRING = os.getenv('CONNECTION_STRING')
|
|
||||||
|
POSTGRES_HOST = os.getenv("POSTGRES_HOST")
|
||||||
|
POSTGRES_PORT = os.getenv("POSTGRES_PORT")
|
||||||
|
POSTGRES_USER = os.getenv("POSTGRES_USER")
|
||||||
|
POSTGRES_DB = os.getenv("POSTGRES_DB")
|
||||||
|
POSTGRES_PASSWORD = os.getenv("POSTGRES_PASSWORD")
|
||||||
|
|
||||||
|
GPT_SERVICE_URL = os.getenv("GPT_SERVICE_URL", "http://localhost:8001")
|
||||||
|
|
||||||
# SECURITY WARNING: don't run with debug turned on in production!
|
# SECURITY WARNING: don't run with debug turned on in production!
|
||||||
DEBUG = True
|
DEBUG = True
|
||||||
|
|
||||||
ALLOWED_HOSTS = []
|
ALLOWED_HOSTS = ['*']
|
||||||
|
|
||||||
|
|
||||||
# Application definition
|
# Application definition
|
||||||
|
|
||||||
INSTALLED_APPS = [
|
INSTALLED_APPS = [
|
||||||
'django.contrib.admin',
|
"django.contrib.admin",
|
||||||
'django.contrib.auth',
|
"django.contrib.auth",
|
||||||
'django.contrib.contenttypes',
|
"django.contrib.contenttypes",
|
||||||
'django.contrib.sessions',
|
"django.contrib.sessions",
|
||||||
'django.contrib.messages',
|
"django.contrib.messages",
|
||||||
'django.contrib.staticfiles',
|
"django.contrib.staticfiles",
|
||||||
'iotDashboard',
|
"rest_framework",
|
||||||
'huey.contrib.djhuey',
|
"iotDashboard",
|
||||||
|
|
||||||
]
|
]
|
||||||
|
|
||||||
MIDDLEWARE = [
|
MIDDLEWARE = [
|
||||||
'django.middleware.security.SecurityMiddleware',
|
"django.middleware.security.SecurityMiddleware",
|
||||||
'django.contrib.sessions.middleware.SessionMiddleware',
|
"django.contrib.sessions.middleware.SessionMiddleware",
|
||||||
'django.middleware.common.CommonMiddleware',
|
"django.middleware.common.CommonMiddleware",
|
||||||
'django.middleware.csrf.CsrfViewMiddleware',
|
"django.middleware.csrf.CsrfViewMiddleware",
|
||||||
'django.contrib.auth.middleware.AuthenticationMiddleware',
|
"django.contrib.auth.middleware.AuthenticationMiddleware",
|
||||||
'django.contrib.messages.middleware.MessageMiddleware',
|
"django.contrib.messages.middleware.MessageMiddleware",
|
||||||
'django.middleware.clickjacking.XFrameOptionsMiddleware',
|
"django.middleware.clickjacking.XFrameOptionsMiddleware",
|
||||||
]
|
]
|
||||||
|
|
||||||
ROOT_URLCONF = 'iotDashboard.urls'
|
ROOT_URLCONF = "iotDashboard.urls"
|
||||||
|
|
||||||
TEMPLATES = [
|
TEMPLATES = [
|
||||||
{
|
{
|
||||||
'BACKEND': 'django.template.backends.django.DjangoTemplates',
|
"BACKEND": "django.template.backends.django.DjangoTemplates",
|
||||||
'DIRS': [os.path.join(BASE_DIR, 'iotDashboard/templates')],
|
"DIRS": [os.path.join(BASE_DIR, "iotDashboard/templates")],
|
||||||
'APP_DIRS': True,
|
"APP_DIRS": True,
|
||||||
'OPTIONS': {
|
"OPTIONS": {
|
||||||
'context_processors': [
|
"context_processors": [
|
||||||
'django.template.context_processors.debug',
|
"django.template.context_processors.debug",
|
||||||
'django.template.context_processors.request',
|
"django.template.context_processors.request",
|
||||||
'django.contrib.auth.context_processors.auth',
|
"django.contrib.auth.context_processors.auth",
|
||||||
'django.contrib.messages.context_processors.messages',
|
"django.contrib.messages.context_processors.messages",
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
WSGI_APPLICATION = 'iotDashboard.wsgi.application'
|
WSGI_APPLICATION = "iotDashboard.wsgi.application"
|
||||||
|
|
||||||
|
|
||||||
# Database
|
# Database
|
||||||
# https://docs.djangoproject.com/en/4.2/ref/settings/#databases
|
# https://docs.djangoproject.com/en/4.2/ref/settings/#databases
|
||||||
|
|
||||||
DATABASES = {
|
DATABASES = {
|
||||||
'default': {
|
"default": {
|
||||||
'ENGINE': 'django.db.backends.sqlite3',
|
"ENGINE": "django.db.backends.postgresql",
|
||||||
'NAME': BASE_DIR / 'db.sqlite3',
|
"NAME": POSTGRES_DB,
|
||||||
|
"USER": POSTGRES_USER,
|
||||||
|
"PASSWORD": POSTGRES_PASSWORD,
|
||||||
|
"HOST": POSTGRES_HOST,
|
||||||
|
"PORT": POSTGRES_PORT,
|
||||||
},
|
},
|
||||||
'data': {
|
|
||||||
"ENGINE": "django.db.backends.postgresql",
|
|
||||||
"NAME" : "example",
|
|
||||||
"USER": "postgres",
|
|
||||||
"PASSWORD": os.getenv('PASSWORD'),
|
|
||||||
"HOST": '10.10.0.1',
|
|
||||||
"PORT": '5555',
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@@ -105,16 +105,16 @@ DATABASES = {
|
|||||||
|
|
||||||
AUTH_PASSWORD_VALIDATORS = [
|
AUTH_PASSWORD_VALIDATORS = [
|
||||||
{
|
{
|
||||||
'NAME': 'django.contrib.auth.password_validation.UserAttributeSimilarityValidator',
|
"NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
'NAME': 'django.contrib.auth.password_validation.MinimumLengthValidator',
|
"NAME": "django.contrib.auth.password_validation.MinimumLengthValidator",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
'NAME': 'django.contrib.auth.password_validation.CommonPasswordValidator',
|
"NAME": "django.contrib.auth.password_validation.CommonPasswordValidator",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
'NAME': 'django.contrib.auth.password_validation.NumericPasswordValidator',
|
"NAME": "django.contrib.auth.password_validation.NumericPasswordValidator",
|
||||||
},
|
},
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -122,9 +122,9 @@ AUTH_PASSWORD_VALIDATORS = [
|
|||||||
# Internationalization
|
# Internationalization
|
||||||
# https://docs.djangoproject.com/en/4.2/topics/i18n/
|
# https://docs.djangoproject.com/en/4.2/topics/i18n/
|
||||||
|
|
||||||
LANGUAGE_CODE = 'en-us'
|
LANGUAGE_CODE = "en-us"
|
||||||
|
|
||||||
TIME_ZONE = 'UTC'
|
TIME_ZONE = "UTC"
|
||||||
|
|
||||||
USE_I18N = True
|
USE_I18N = True
|
||||||
|
|
||||||
@@ -134,19 +134,20 @@ USE_TZ = True
|
|||||||
# Static files (CSS, JavaScript, Images)
|
# Static files (CSS, JavaScript, Images)
|
||||||
# https://docs.djangoproject.com/en/4.2/howto/static-files/
|
# https://docs.djangoproject.com/en/4.2/howto/static-files/
|
||||||
|
|
||||||
STATIC_URL = 'static/'
|
STATIC_URL = "static/"
|
||||||
|
|
||||||
# Default primary key field type
|
# Default primary key field type
|
||||||
# https://docs.djangoproject.com/en/4.2/ref/settings/#default-auto-field
|
# https://docs.djangoproject.com/en/4.2/ref/settings/#default-auto-field
|
||||||
|
|
||||||
DEFAULT_AUTO_FIELD = 'django.db.models.BigAutoField'
|
DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField"
|
||||||
|
|
||||||
|
# Django REST Framework
|
||||||
HUEY = {
|
# https://www.django-rest-framework.org/api-guide/settings/
|
||||||
'huey_class': 'huey.SqliteHuey', # Or 'huey.RedisHuey' for Redis
|
REST_FRAMEWORK = {
|
||||||
'filename': 'demo.db', # SQLite file for task storage
|
'DEFAULT_RENDERER_CLASSES': [
|
||||||
'results': True,
|
'rest_framework.renderers.JSONRenderer',
|
||||||
'store_none': False,
|
],
|
||||||
'immediate': False,
|
'DEFAULT_PARSER_CLASSES': [
|
||||||
'utc': True,
|
'rest_framework.parsers.JSONParser',
|
||||||
}
|
],
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,174 +0,0 @@
|
|||||||
import json
|
|
||||||
import datetime
|
|
||||||
import os
|
|
||||||
import requests
|
|
||||||
import psycopg2
|
|
||||||
import redis
|
|
||||||
from django.conf import settings
|
|
||||||
from huey import crontab
|
|
||||||
from huey.contrib.djhuey import periodic_task
|
|
||||||
from .models import Device
|
|
||||||
from dotenv import load_dotenv
|
|
||||||
|
|
||||||
load_dotenv()
|
|
||||||
|
|
||||||
REDIS_HOST = os.getenv('REDIS_HOST', 'localhost') # Default to localhost if not set
|
|
||||||
try:
|
|
||||||
redis_client = redis.StrictRedis(host=REDIS_HOST, port=6379, db=0)
|
|
||||||
print(redis_client)
|
|
||||||
redis_client.ping()
|
|
||||||
print('Connected!')
|
|
||||||
except Exception as ex:
|
|
||||||
print
|
|
||||||
'Error:', ex
|
|
||||||
exit('Failed to connect, terminating.')
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
def devices_to_redis():
|
|
||||||
"""Fetch devices and their sensors' topics from Django and store them in Redis."""
|
|
||||||
devices = Device.objects.all()
|
|
||||||
devices_list = []
|
|
||||||
for device in devices:
|
|
||||||
for sensor in device.sensors.all():
|
|
||||||
sensor_data = {
|
|
||||||
'device_name': device.name,
|
|
||||||
'sensor_name': sensor.type.name,
|
|
||||||
'topic': sensor.type.topic # Assuming the topic is stored in SensorType
|
|
||||||
}
|
|
||||||
devices_list.append(sensor_data)
|
|
||||||
redis_client.set('mqtt_devices', json.dumps(devices_list))
|
|
||||||
print("Devices with sensors stored in Redis.")
|
|
||||||
|
|
||||||
|
|
||||||
def fetch_data_http(device, sensor):
|
|
||||||
"""Fetch data from an HTTP sensor."""
|
|
||||||
sensor_type_name = sensor.type.name.lower()
|
|
||||||
try:
|
|
||||||
response = requests.get(f"http://{device.ip}/sensor/{sensor_type_name}", timeout=5)
|
|
||||||
response.raise_for_status()
|
|
||||||
sensor_value = response.json().get('value')
|
|
||||||
if sensor_value is not None:
|
|
||||||
return {
|
|
||||||
"time": datetime.datetime.utcnow().isoformat(),
|
|
||||||
"device": device.name,
|
|
||||||
"sensor": sensor_type_name,
|
|
||||||
"sensor_value": sensor_value
|
|
||||||
}
|
|
||||||
else:
|
|
||||||
print(f"No value returned from {device.name} for {sensor_type_name}")
|
|
||||||
except requests.RequestException as e:
|
|
||||||
print(f"HTTP request failed for {device.name}: {e}")
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def fetch_data_mqtt_stream(device, sensor):
|
|
||||||
"""Fetch data from Redis Stream for a specific MQTT device and sensor."""
|
|
||||||
sensor_name = sensor.type.name.lower()
|
|
||||||
stream_key = f"mqtt_stream:{device.name}:{sensor_name}"
|
|
||||||
try:
|
|
||||||
stream_data = redis_client.xread({stream_key: '0-0'}, block=1000, count=1)
|
|
||||||
if stream_data:
|
|
||||||
_, entries = stream_data[0]
|
|
||||||
for entry_id, entry_data in entries:
|
|
||||||
sensor_value = entry_data.get(b'value')
|
|
||||||
timestamp = entry_data.get(b'time')
|
|
||||||
|
|
||||||
if sensor_value and timestamp:
|
|
||||||
return {
|
|
||||||
"time": timestamp.decode('utf-8'),
|
|
||||||
"device": device.name,
|
|
||||||
"sensor_value": float(sensor_value.decode('utf-8'))
|
|
||||||
}
|
|
||||||
except Exception as e:
|
|
||||||
print(f"Error fetching data from stream {stream_key}: {e}")
|
|
||||||
return None
|
|
||||||
|
|
||||||
|
|
||||||
def is_recent_data(timestamp):
|
|
||||||
"""Check if data is within a 1-minute freshness window."""
|
|
||||||
data_time = datetime.datetime.fromisoformat(timestamp)
|
|
||||||
return data_time > datetime.datetime.utcnow() - datetime.timedelta(minutes=1)
|
|
||||||
|
|
||||||
|
|
||||||
def insert_data(data, sensor_type):
|
|
||||||
"""Insert parsed data into the PostgreSQL database."""
|
|
||||||
if 'sensor_value' not in data:
|
|
||||||
print(f"Missing 'sensor_value' in data: {data}. Skipping insertion.")
|
|
||||||
return
|
|
||||||
|
|
||||||
insert_data_dict = {
|
|
||||||
"time": data['time'],
|
|
||||||
"device": data['device'],
|
|
||||||
"metric": sensor_type.lower(),
|
|
||||||
"value": data['sensor_value'],
|
|
||||||
}
|
|
||||||
|
|
||||||
try:
|
|
||||||
with psycopg2.connect(settings.CONNECTION_STRING) as conn:
|
|
||||||
with conn.cursor() as cursor:
|
|
||||||
insert_query = """
|
|
||||||
INSERT INTO sensor_readings (time, device_name, metric, value)
|
|
||||||
VALUES (%s, %s, %s, %s);
|
|
||||||
"""
|
|
||||||
cursor.execute(insert_query, (
|
|
||||||
insert_data_dict["time"],
|
|
||||||
insert_data_dict["device"],
|
|
||||||
insert_data_dict["metric"],
|
|
||||||
insert_data_dict["value"]
|
|
||||||
))
|
|
||||||
conn.commit()
|
|
||||||
print(f"Data inserted successfully for {insert_data_dict['device']}: {insert_data_dict}")
|
|
||||||
except Exception as e:
|
|
||||||
print(f"Failed to insert data: {e}")
|
|
||||||
|
|
||||||
|
|
||||||
@periodic_task(crontab(minute='*/1'))
|
|
||||||
def fetch_data_from_all_devices():
|
|
||||||
"""Fetch and insert data for all devices based on their protocol."""
|
|
||||||
devices = Device.objects.all()
|
|
||||||
for device in devices:
|
|
||||||
for sensor in device.sensors.all():
|
|
||||||
data = None
|
|
||||||
|
|
||||||
if device.protocol == 'http':
|
|
||||||
data = fetch_data_http(device, sensor)
|
|
||||||
elif device.protocol == 'mqtt':
|
|
||||||
data = fetch_data_mqtt_stream(device, sensor)
|
|
||||||
|
|
||||||
if data and is_recent_data(data['time']):
|
|
||||||
insert_data(data, sensor.type.name)
|
|
||||||
else:
|
|
||||||
print(f"No recent or valid data for {device.name}. Skipping.")
|
|
||||||
|
|
||||||
|
|
||||||
@periodic_task(crontab(minute='*/5'))
|
|
||||||
def last_5_minutes():
|
|
||||||
"""Fetch the last 5 readings from TimescaleDB and store them in Redis."""
|
|
||||||
try:
|
|
||||||
with psycopg2.connect(settings.CONNECTION_STRING) as conn:
|
|
||||||
with conn.cursor() as cursor:
|
|
||||||
cursor.execute("""
|
|
||||||
SELECT time, device_name, metric, value
|
|
||||||
FROM sensor_readings
|
|
||||||
ORDER BY time DESC
|
|
||||||
LIMIT 5;
|
|
||||||
""")
|
|
||||||
results = cursor.fetchall()
|
|
||||||
|
|
||||||
data = [
|
|
||||||
{
|
|
||||||
"time": reading[0].isoformat(),
|
|
||||||
"device": reading[1],
|
|
||||||
"metric": reading[2],
|
|
||||||
"value": reading[3]
|
|
||||||
}
|
|
||||||
for reading in results
|
|
||||||
]
|
|
||||||
redis_client.set("last5", json.dumps(data))
|
|
||||||
print("Last 5 readings:", data)
|
|
||||||
except Exception as e:
|
|
||||||
print(f"Error fetching or storing the last 5 readings: {e}")
|
|
||||||
|
|
||||||
|
|
||||||
devices_to_redis()
|
|
||||||
@@ -3,40 +3,39 @@
|
|||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>Add Sensor and Type</title>
|
<title>Renew Certificate</title>
|
||||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
|
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
|
||||||
</head>
|
</head>
|
||||||
<body class="bg-light">
|
<body class="bg-light">
|
||||||
<nav class="navbar navbar-expand-lg navbar-light bg-light">
|
<nav class="navbar navbar-expand-lg navbar-light bg-light">
|
||||||
<div class="container-fluid">
|
<div class="container-fluid">
|
||||||
<a class="navbar-brand" href="#">IoT Dashboard</a>
|
<a class="navbar-brand" href="#">IoT Dashboard</a>
|
||||||
|
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarNav">
|
||||||
|
<span class="navbar-toggler-icon"></span>
|
||||||
|
</button>
|
||||||
<div class="collapse navbar-collapse" id="navbarNav">
|
<div class="collapse navbar-collapse" id="navbarNav">
|
||||||
<ul class="navbar-nav me-auto mb-2 mb-lg-0">
|
<ul class="navbar-nav me-auto mb-2 mb-lg-0">
|
||||||
|
<li class="nav-item"><a class="nav-link" href="{% url 'index' %}">Chart</a></li>
|
||||||
<li class="nav-item"><a class="nav-link" href="{% url 'device_list' %}">Devices</a></li>
|
<li class="nav-item"><a class="nav-link" href="{% url 'device_list' %}">Devices</a></li>
|
||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<div class="container mt-5">
|
<div class="container mt-5">
|
||||||
<h1 class="text-center mb-4">Add Sensor and Sensor Type</h1>
|
<h1 class="text-center mb-4">Renew Certificate</h1>
|
||||||
<form method="post">
|
|
||||||
|
<div class="alert alert-info" role="alert">
|
||||||
|
<strong>Info:</strong> This will generate a new certificate for device "{{ device.name }}" and revoke the old one.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p class="text-center">Do you want to renew the certificate now?</p>
|
||||||
|
|
||||||
|
<form method="post" class="text-center">
|
||||||
{% csrf_token %}
|
{% csrf_token %}
|
||||||
{{ form.as_p }} <!-- Renders the inputs for all fields you added in your form -->
|
<button type="submit" class="btn btn-primary">Renew Certificate</button>
|
||||||
<input type="hidden" name="device_id" value="{{ device.id }}"> <!-- Hidden field for device ID -->
|
|
||||||
<button type="submit" class="btn btn-success">Save</button>
|
|
||||||
<a href="{% url 'device_list' %}" class="btn btn-secondary">Cancel</a>
|
<a href="{% url 'device_list' %}" class="btn btn-secondary">Cancel</a>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
{% if form.errors %}
|
|
||||||
<div class="alert alert-danger">
|
|
||||||
<ul>
|
|
||||||
{% for field, errors in form.errors.items %}
|
|
||||||
<li>{{ field }}: {{ errors|join:', ' }}</li>
|
|
||||||
{% endfor %}
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
{% endif %}
|
|
||||||
</div>
|
</div>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
42
iotDashboard/templates/certificate_revoke_confirm.html
Normal file
42
iotDashboard/templates/certificate_revoke_confirm.html
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Revoke Certificate</title>
|
||||||
|
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
|
||||||
|
</head>
|
||||||
|
<body class="bg-light">
|
||||||
|
<nav class="navbar navbar-expand-lg navbar-light bg-light">
|
||||||
|
<div class="container-fluid">
|
||||||
|
<a class="navbar-brand" href="#">IoT Dashboard</a>
|
||||||
|
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarNav">
|
||||||
|
<span class="navbar-toggler-icon"></span>
|
||||||
|
</button>
|
||||||
|
<div class="collapse navbar-collapse" id="navbarNav">
|
||||||
|
<ul class="navbar-nav me-auto mb-2 mb-lg-0">
|
||||||
|
<li class="nav-item"><a class="nav-link" href="{% url 'index' %}">Chart</a></li>
|
||||||
|
<li class="nav-item"><a class="nav-link" href="{% url 'device_list' %}">Devices</a></li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<div class="container mt-5">
|
||||||
|
<h1 class="text-center mb-4">Revoke Certificate</h1>
|
||||||
|
|
||||||
|
<div class="alert alert-warning" role="alert">
|
||||||
|
<strong>Warning!</strong> This action will revoke the certificate for device "{{ device.name }}".
|
||||||
|
The device will no longer be able to connect until a new certificate is issued.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p class="text-center">Are you sure you want to revoke the certificate?</p>
|
||||||
|
|
||||||
|
<form method="post" class="text-center">
|
||||||
|
{% csrf_token %}
|
||||||
|
<button type="submit" class="btn btn-danger">Revoke Certificate</button>
|
||||||
|
<a href="{% url 'device_list' %}" class="btn btn-secondary">Cancel</a>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -93,19 +93,19 @@
|
|||||||
<!-- Device Selector Dropdown -->
|
<!-- Device Selector Dropdown -->
|
||||||
<div class="row mb-4">
|
<div class="row mb-4">
|
||||||
<div class="col-md-4 offset-md-4">
|
<div class="col-md-4 offset-md-4">
|
||||||
<select id="deviceSelector" class="form-select" onchange="updateSensorOptions()">
|
<select id="deviceSelector" class="form-select" onchange="updateMetricOptions()">
|
||||||
<option value="">-- Select a Device --</option>
|
<option value="">-- Select a Device --</option>
|
||||||
<script>
|
<script>
|
||||||
const devices = JSON.parse('{{ devices_json|escapejs }}');
|
const devices = JSON.parse('{{ devices_json|escapejs }}');
|
||||||
|
|
||||||
const deviceSelector = document.getElementById('deviceSelector');
|
const deviceSelector = document.getElementById('deviceSelector');
|
||||||
const sensorSelector = document.getElementById('sensorSelector');
|
const metricSelector = document.getElementById('metricSelector');
|
||||||
|
|
||||||
// Populate device selector
|
// Populate device selector
|
||||||
devices.forEach(device => {
|
devices.forEach(device => {
|
||||||
const option = document.createElement('option');
|
const option = document.createElement('option');
|
||||||
option.value = device.name; // Device name used for identification
|
option.value = device.id; // Use device ID
|
||||||
option.textContent = device.name;
|
option.textContent = device.name + ' (' + device.protocol.toUpperCase() + ')';
|
||||||
deviceSelector.appendChild(option);
|
deviceSelector.appendChild(option);
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
@@ -113,11 +113,11 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Sensor Selector Dropdown -->
|
<!-- Metric Selector Dropdown -->
|
||||||
<div class="row mb-4">
|
<div class="row mb-4">
|
||||||
<div class="col-md-4 offset-md-4">
|
<div class="col-md-4 offset-md-4">
|
||||||
<select id="sensorSelector" class="form-select">
|
<select id="metricSelector" class="form-select">
|
||||||
<option value="">-- Select a Sensor --</option>
|
<option value="">-- Select a Metric --</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -157,39 +157,39 @@
|
|||||||
var ctx = document.getElementById('conditionsChart').getContext('2d');
|
var ctx = document.getElementById('conditionsChart').getContext('2d');
|
||||||
var conditionsChart;
|
var conditionsChart;
|
||||||
|
|
||||||
function updateSensorOptions() {
|
function updateMetricOptions() {
|
||||||
const selectedDeviceName = document.getElementById('deviceSelector').value;
|
const selectedDeviceId = document.getElementById('deviceSelector').value;
|
||||||
|
|
||||||
// Find the selected device in the devices array
|
// Find the selected device in the devices array
|
||||||
const selectedDevice = devices.find(device => device.name === selectedDeviceName);
|
const selectedDevice = devices.find(device => device.id === selectedDeviceId);
|
||||||
|
|
||||||
// Clear the existing options in the sensorSelector
|
// Clear the existing options in the metricSelector
|
||||||
const sensorSelector = document.getElementById('sensorSelector');
|
const metricSelector = document.getElementById('metricSelector');
|
||||||
sensorSelector.innerHTML = '<option value="">-- Select a Sensor --</option>';
|
metricSelector.innerHTML = '<option value="">-- Select a Metric --</option>';
|
||||||
|
|
||||||
if (selectedDevice) {
|
if (selectedDevice && selectedDevice.metrics) {
|
||||||
// Populate the sensor selector with the sensors for the selected device
|
// Populate the metric selector with the metrics for the selected device
|
||||||
selectedDevice.sensors.forEach(sensor => {
|
selectedDevice.metrics.forEach(metric => {
|
||||||
const option = document.createElement('option');
|
const option = document.createElement('option');
|
||||||
option.value = sensor.id; // Set the sensor ID as the value
|
option.value = metric;
|
||||||
option.textContent = sensor.type; // Set the sensor type as the display text
|
option.textContent = metric.charAt(0).toUpperCase() + metric.slice(1);
|
||||||
sensorSelector.appendChild(option);
|
metricSelector.appendChild(option);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function fetchDeviceData() {
|
function fetchDeviceData() {
|
||||||
var device = document.getElementById('deviceSelector').value;
|
var deviceId = document.getElementById('deviceSelector').value;
|
||||||
var sensor = document.getElementById('sensorSelector').value; // Get selected sensor
|
var metric = document.getElementById('metricSelector').value;
|
||||||
var startDate = document.getElementById('startDate').value;
|
var startDate = document.getElementById('startDate').value;
|
||||||
var endDate = document.getElementById('endDate').value;
|
var endDate = document.getElementById('endDate').value;
|
||||||
|
|
||||||
if (!device || !sensor) {
|
if (!deviceId || !metric) {
|
||||||
alert("Please select both a device and a sensor.");
|
alert("Please select both a device and a metric.");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
fetch(`/fetch_device_data/?device=${device}&sensor=${sensor}&start_date=${startDate}&end_date=${endDate}`)
|
fetch(`/fetch_device_data/?device_id=${deviceId}&metric=${metric}&start_date=${startDate}&end_date=${endDate}`)
|
||||||
.then(response => {
|
.then(response => {
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error("Network response was not ok: " + response.statusText);
|
throw new Error("Network response was not ok: " + response.statusText);
|
||||||
@@ -212,9 +212,9 @@ function fetchDeviceData() {
|
|||||||
labels: data.times,
|
labels: data.times,
|
||||||
datasets: [
|
datasets: [
|
||||||
{
|
{
|
||||||
label: `${sensor} (°C or %)`,
|
label: metric.charAt(0).toUpperCase() + metric.slice(1),
|
||||||
data: data.values,
|
data: data.values,
|
||||||
borderColor: sensor === 'Temperature' ? 'red' : 'blue',
|
borderColor: metric === 'temperature' ? 'red' : (metric === 'humidity' ? 'blue' : 'green'),
|
||||||
fill: false,
|
fill: false,
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -29,7 +29,7 @@
|
|||||||
</li>
|
</li>
|
||||||
{% else %}
|
{% else %}
|
||||||
<li class="nav-item">
|
<li class="nav-item">
|
||||||
<a class="nav-link" href="{% url 'login' %}">Login</a>
|
<a class="nav-link" href="/admin">Login</a>
|
||||||
</li>
|
</li>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
</ul>
|
</ul>
|
||||||
|
|||||||
172
iotDashboard/templates/device_credentials.html
Normal file
172
iotDashboard/templates/device_credentials.html
Normal file
@@ -0,0 +1,172 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Device Credentials - {{ device_name }}</title>
|
||||||
|
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
|
||||||
|
<style>
|
||||||
|
.credential-box {
|
||||||
|
background-color: #f8f9fa;
|
||||||
|
border: 1px solid #dee2e6;
|
||||||
|
border-radius: 5px;
|
||||||
|
padding: 15px;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
font-family: monospace;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
word-wrap: break-word;
|
||||||
|
max-height: 300px;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
.copy-btn {
|
||||||
|
margin-top: 10px;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body class="bg-light">
|
||||||
|
<nav class="navbar navbar-expand-lg navbar-light bg-light">
|
||||||
|
<div class="container-fluid">
|
||||||
|
<a class="navbar-brand" href="#">IoT Dashboard</a>
|
||||||
|
<div class="collapse navbar-collapse">
|
||||||
|
<ul class="navbar-nav me-auto mb-2 mb-lg-0">
|
||||||
|
<li class="nav-item"><a class="nav-link" href="{% url 'index' %}">Chart</a></li>
|
||||||
|
<li class="nav-item"><a class="nav-link" href="{% url 'device_list' %}">Devices</a></li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<div class="container mt-5">
|
||||||
|
<div class="alert alert-success" role="alert">
|
||||||
|
<h4 class="alert-heading">✅ Device Registered Successfully!</h4>
|
||||||
|
<p>Device <strong>{{ device_name }}</strong> has been registered.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="alert alert-warning" role="alert">
|
||||||
|
<strong>⚠️ Important:</strong> Save these credentials now! They will not be shown again.
|
||||||
|
You'll need them to configure your device.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card mb-4">
|
||||||
|
<div class="card-header">
|
||||||
|
<h5>Device Information</h5>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<table class="table">
|
||||||
|
<tr>
|
||||||
|
<th>Device ID:</th>
|
||||||
|
<td><code>{{ response.device_id }}</code></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<th>Protocol:</th>
|
||||||
|
<td><span class="badge bg-info">{{ response.protocol|upper }}</span></td>
|
||||||
|
</tr>
|
||||||
|
{% if response.certificate_id %}
|
||||||
|
<tr>
|
||||||
|
<th>Certificate ID:</th>
|
||||||
|
<td><code>{{ response.certificate_id }}</code></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<th>Expires At:</th>
|
||||||
|
<td>{{ response.expires_at|date:"Y-m-d H:i:s" }}</td>
|
||||||
|
</tr>
|
||||||
|
{% endif %}
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if response.protocol == 'mqtt' and response.certificate_pem %}
|
||||||
|
<div class="card mb-4">
|
||||||
|
<div class="card-header">
|
||||||
|
<h5>CA Certificate</h5>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="credential-box" id="ca-cert">{{ response.ca_certificate_pem }}</div>
|
||||||
|
<button class="btn btn-sm btn-secondary copy-btn" onclick="copyToClipboard('ca-cert')">Copy CA Certificate</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card mb-4">
|
||||||
|
<div class="card-header">
|
||||||
|
<h5>Device Certificate</h5>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="credential-box" id="device-cert">{{ response.certificate_pem }}</div>
|
||||||
|
<button class="btn btn-sm btn-secondary copy-btn" onclick="copyToClipboard('device-cert')">Copy Device Certificate</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card mb-4">
|
||||||
|
<div class="card-header">
|
||||||
|
<h5>Private Key</h5>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="credential-box" id="private-key">{{ response.private_key_pem }}</div>
|
||||||
|
<button class="btn btn-sm btn-secondary copy-btn" onclick="copyToClipboard('private-key')">Copy Private Key</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="alert alert-info">
|
||||||
|
<h6>MQTT Configuration:</h6>
|
||||||
|
<ul>
|
||||||
|
<li>Broker: <code>localhost:8883</code></li>
|
||||||
|
<li>Topic: <code>devices/{{ response.device_id }}/<metric></code></li>
|
||||||
|
<li>Example: <code>devices/{{ response.device_id }}/temperature</code></li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if response.protocol == 'http' and response.api_key %}
|
||||||
|
<div class="card mb-4">
|
||||||
|
<div class="card-header">
|
||||||
|
<h5>API Key</h5>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="credential-box" id="api-key">{{ response.api_key }}</div>
|
||||||
|
<button class="btn btn-sm btn-secondary copy-btn" onclick="copyToClipboard('api-key')">Copy API Key</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
{% if response.protocol == 'webhook' and response.webhook_secret %}
|
||||||
|
<div class="card mb-4">
|
||||||
|
<div class="card-header">
|
||||||
|
<h5>Webhook Secret</h5>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="credential-box" id="webhook-secret">{{ response.webhook_secret }}</div>
|
||||||
|
<button class="btn btn-sm btn-secondary copy-btn" onclick="copyToClipboard('webhook-secret')">Copy Webhook Secret</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<div class="text-center mt-4">
|
||||||
|
<a href="{% url 'device_list' %}" class="btn btn-primary">Go to Device List</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
function copyToClipboard(elementId) {
|
||||||
|
const element = document.getElementById(elementId);
|
||||||
|
const text = element.textContent;
|
||||||
|
|
||||||
|
navigator.clipboard.writeText(text).then(() => {
|
||||||
|
// Change button text temporarily
|
||||||
|
const btn = event.target;
|
||||||
|
const originalText = btn.textContent;
|
||||||
|
btn.textContent = '✓ Copied!';
|
||||||
|
btn.classList.remove('btn-secondary');
|
||||||
|
btn.classList.add('btn-success');
|
||||||
|
|
||||||
|
setTimeout(() => {
|
||||||
|
btn.textContent = originalText;
|
||||||
|
btn.classList.remove('btn-success');
|
||||||
|
btn.classList.add('btn-secondary');
|
||||||
|
}, 2000);
|
||||||
|
}).catch(err => {
|
||||||
|
alert('Failed to copy: ' + err);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
101
iotDashboard/templates/device_detail.html
Normal file
101
iotDashboard/templates/device_detail.html
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Device Details - {{ device.name }}</title>
|
||||||
|
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
|
||||||
|
</head>
|
||||||
|
<body class="bg-light">
|
||||||
|
<nav class="navbar navbar-expand-lg navbar-light bg-light">
|
||||||
|
<div class="container-fluid">
|
||||||
|
<a class="navbar-brand" href="#">IoT Dashboard</a>
|
||||||
|
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarNav">
|
||||||
|
<span class="navbar-toggler-icon"></span>
|
||||||
|
</button>
|
||||||
|
<div class="collapse navbar-collapse" id="navbarNav">
|
||||||
|
<ul class="navbar-nav me-auto mb-2 mb-lg-0">
|
||||||
|
<li class="nav-item"><a class="nav-link" href="{% url 'index' %}">Chart</a></li>
|
||||||
|
<li class="nav-item"><a class="nav-link" href="{% url 'device_list' %}">Devices</a></li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<div class="container mt-5">
|
||||||
|
<h1 class="text-center mb-4">Device Details</h1>
|
||||||
|
|
||||||
|
{% if messages %}
|
||||||
|
{% for message in messages %}
|
||||||
|
<div class="alert alert-{{ message.tags }} alert-dismissible fade show" role="alert">
|
||||||
|
{{ message }}
|
||||||
|
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header">
|
||||||
|
<h3>{{ device.name }}</h3>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<table class="table">
|
||||||
|
<tr>
|
||||||
|
<th>Device ID:</th>
|
||||||
|
<td><code>{{ device.id }}</code></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<th>Location:</th>
|
||||||
|
<td>{{ device.location|default:"—" }}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<th>Protocol:</th>
|
||||||
|
<td><span class="badge bg-info">{{ device.protocol|upper }}</span></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<th>Created:</th>
|
||||||
|
<td>{{ device.created_at|date:"Y-m-d H:i:s" }}</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
{% if device.protocol == 'mqtt' and certificate %}
|
||||||
|
<h4 class="mt-4">Certificate Information</h4>
|
||||||
|
<table class="table">
|
||||||
|
<tr>
|
||||||
|
<th>Certificate ID:</th>
|
||||||
|
<td><code>{{ certificate.id }}</code></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<th>Issued At:</th>
|
||||||
|
<td>{{ certificate.issued_at|date:"Y-m-d H:i:s" }}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<th>Expires At:</th>
|
||||||
|
<td>{{ certificate.expires_at|date:"Y-m-d H:i:s" }}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<th>Status:</th>
|
||||||
|
<td>
|
||||||
|
{% if certificate.revoked_at %}
|
||||||
|
<span class="badge bg-danger">Revoked</span>
|
||||||
|
{% else %}
|
||||||
|
<span class="badge bg-success">Active</span>
|
||||||
|
{% endif %}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
{% endif %}
|
||||||
|
|
||||||
|
<div class="mt-4">
|
||||||
|
<a href="{% url 'device_list' %}" class="btn btn-secondary">Back to List</a>
|
||||||
|
{% if device.protocol == 'mqtt' %}
|
||||||
|
<a href="{% url 'renew_certificate' device.id %}" class="btn btn-warning">Renew Certificate</a>
|
||||||
|
<a href="{% url 'revoke_certificate' device.id %}" class="btn btn-danger">Revoke Certificate</a>
|
||||||
|
{% endif %}
|
||||||
|
<a href="{% url 'delete_device' device.id %}" class="btn btn-danger">Delete Device</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user