17 Commits

Author SHA1 Message Date
8c699bd121 Added Comfort Index widget, updated stylings on other widgets, GPT Service with problems. 2025-11-17 23:52:28 +01:00
7f2aec96dd Introduced air quality and weather, onboarding for mobile devices with qr code and otp. Cascade on delete of device with telemtry. 2025-11-14 00:45:10 +01:00
ed105fccd3 Update README to remove features and architecture
Removed features and architecture sections from README.
2025-11-10 00:23:23 +01:00
7921049f56 Introduced GPT/AI service, added dashboard widgets, drag and drop, export import. 2025-11-09 23:56:13 +01:00
8e98f5ad7d Started new initial React frontend, aadditinal changes for Django to run REST. 2025-11-04 00:25:03 +01:00
391c08a738 Merge pull request #8 from ferdzo/feature/device-manager
Device Manager Service
2025-11-03 20:51:59 +01:00
153dca9d8f Small changes 2025-11-03 20:51:46 +01:00
d2b707ea5e DB Writer fixes and compose.yml update 2025-11-03 15:57:23 +01:00
212b8d39a9 Removed unused files, updated templates, added gpt service and small fixes on Django 2025-11-03 13:05:22 +01:00
96e2377073 Added multi-protocol support for devices, improved models and updated readme.md and instructions 2025-11-02 14:09:29 +01:00
ddbc588c77 Add Dockerfile and .dockerignore; refactor import paths in app modules 2025-10-30 23:20:51 +01:00
4df582b330 Functioning device manager with renew,revoke, updated model for cert id 2025-10-30 23:00:57 +01:00
ferdzo
7446e9b4ac Working device manager, added folders for better organization. 2025-10-30 14:26:08 +01:00
Andrej Mickov
12d3720421 Functioning mqtt ingestion and db write, formating changes, device manager initiated 2025-10-30 00:32:25 +01:00
Andrej Mickov
0b96c72f45 Migration to UV, introducing db_write, mqtt_ingestion, db_migrations, half working prototype. 2025-10-28 23:14:58 +01:00
5028dae200 .gitignore edits 2025-10-12 20:21:31 +02:00
39a9f91cfc Rewrite start 2025-10-12 20:21:04 +02:00
162 changed files with 20018 additions and 945 deletions

20
.env.sample Normal file
View 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
View 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
View 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
View File

@@ -8,3 +8,8 @@ demo.db-wal
/iotDashboard/demo1.db
**/__pycache__/
iotDashboard/db_create.py
.venv/
.ruff_cache/
*.key
*.crt
*.csr

1
.python-version Normal file
View File

@@ -0,0 +1 @@
3.13

23
.vscode/launch.json vendored Normal file
View 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
}
]
}

View File

@@ -1,2 +1,17 @@
# iotDashboard
iotDashboard - IOT dashboard with Django, TimescaleDB and Redis
# IoT Dashboard
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

View File

@@ -0,0 +1 @@
CONNECTION_STRING=postgresql://user:password@localhost:5432/iotdashboard

5
db_migrations/.gitignore vendored Normal file
View File

@@ -0,0 +1,5 @@
__pycache__/
*.py[cod]
*.so
.Python
*.egg-info/

111
db_migrations/README.md Normal file
View 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
View 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

View 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()

View 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"}

View File

@@ -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 ###

View File

@@ -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 ###

View File

@@ -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')

View File

@@ -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 ###

View File

@@ -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 ###

View File

@@ -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
View 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})>"

View 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
View 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
View 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
View 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
View 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
View 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
View 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

File diff suppressed because it is too large Load Diff

30
frontend/package.json Normal file
View 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
View 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
View 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
View 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
View 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 } }),
};

View 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

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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'

View 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,
}

View 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 */
}

View 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'

View 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,
}
}

View 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,
}
}

View 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
View 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;
}
}

View 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
View 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
View 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>,
)

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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
View 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[];
}[];
}

View 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
}
}

View 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
View File

@@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}

View 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
View 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
View File

@@ -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
View File

@@ -0,0 +1,3 @@
mosquitto/certs/
mosquitto/data/
mosquitto/logs/

View 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:

View 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

View File

@@ -1,6 +1,7 @@
PASSWORD='postgres'
SECRET_KEY='django_key'
CONNECTION_STRING='postgres://postgres:postgres@localhost:5555/example'
REDIS_HOST="localhost"
MQTT_BROKER="localhost"
OPENAI_API_KEY="example"
WEATHER_API_KEY=your_weather_api_key_here
DEVICE_MANAGER_URL=http://localhost:8000

View File

@@ -0,0 +1 @@
"""REST API for IoT Dashboard."""

View 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
View 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
View 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
)

View File

@@ -1,6 +1,6 @@
from django.apps import AppConfig
class IotDashboardConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField'
name = 'iotDashboard'
default_auto_field = "django.db.models.BigAutoField"
name = "iotDashboard"

View File

@@ -11,6 +11,6 @@ import os
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()

View 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)

View 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)

View File

@@ -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 iotDashboard.models import Device, Sensor, SensorType
from iotDashboard.models import Device
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:
model = Device
fields = ['name', 'ip', 'protocol'] # Exclude sensors from the fields
def __init__(self, *args, **kwargs):
# No need to handle sensors in the form
super(DeviceForm, self).__init__(*args, **kwargs)
def save(self, commit=True):
# Save the device instance
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
fields = ["name", "location", "protocol"]
widgets = {
"name": forms.TextInput(attrs={"class": "form-control", "placeholder": "Device name"}),
"location": forms.TextInput(attrs={"class": "form-control", "placeholder": "Device location (optional)"}),
}
help_texts = {
"name": "Unique identifier for this device",
"location": "Physical location or description",
}

View 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
View File

@@ -0,0 +1,6 @@
def main():
print("Hello from iotdashboard!")
if __name__ == "__main__":
main()

View 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",
),
),
],
),
]

View File

@@ -5,43 +5,78 @@ import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
('iotDashboard', '0001_initial'),
("iotDashboard", "0001_initial"),
]
operations = [
migrations.CreateModel(
name='SensorType',
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)),
(
"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.RemoveField(
model_name='device',
name='humidity',
model_name="device",
name="humidity",
),
migrations.RemoveField(
model_name='device',
name='temperature',
model_name="device",
name="temperature",
),
migrations.AlterField(
model_name='device',
name='protocol',
field=models.CharField(choices=[('mqtt', 'MQTT'), ('http', 'HTTP')], max_length=20),
model_name="device",
name="protocol",
field=models.CharField(
choices=[("mqtt", "MQTT"), ("http", "HTTP")], max_length=20
),
),
migrations.CreateModel(
name='Sensor',
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')),
(
"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",
),
),
],
),
]

View File

View 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
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):
name = models.CharField(max_length=50) # Device name
ip = models.CharField(max_length=20) # Device IP address
protocol = models.CharField(max_length=20, choices=[('mqtt', 'MQTT'), ('http', 'HTTP')])
"""IoT devices registered in the system."""
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):
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):
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}"

View File

@@ -0,0 +1,5 @@
[project]
name = "dashboard"
version = "0.1.0"
requires-python = ">=3.13"
dependencies = []

View File

@@ -9,10 +9,10 @@ https://docs.djangoproject.com/en/4.2/topics/settings/
For the full list of settings and their values, see
https://docs.djangoproject.com/en/4.2/ref/settings/
"""
from dotenv import load_dotenv
from pathlib import Path
import os
from huey import SqliteHuey
# Build paths inside the project like this: BASE_DIR / 'subdir'.
@@ -21,82 +21,82 @@ BASE_DIR = Path(__file__).resolve().parent.parent
load_dotenv()
# Quick-start development settings - unsuitable for production
# See https://docs.djangoproject.com/en/4.2/howto/deployment/checklist/
# SECURITY WARNING: keep the secret key used in production secret!
SECRET_KEY = os.getenv('SECRET_KEY')
CONNECTION_STRING = os.getenv('CONNECTION_STRING')
SECRET_KEY = os.getenv("SECRET_KEY")
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!
DEBUG = True
ALLOWED_HOSTS = []
ALLOWED_HOSTS = ['*']
# Application definition
INSTALLED_APPS = [
'django.contrib.admin',
'django.contrib.auth',
'django.contrib.contenttypes',
'django.contrib.sessions',
'django.contrib.messages',
'django.contrib.staticfiles',
'iotDashboard',
'huey.contrib.djhuey',
"django.contrib.admin",
"django.contrib.auth",
"django.contrib.contenttypes",
"django.contrib.sessions",
"django.contrib.messages",
"django.contrib.staticfiles",
"rest_framework",
"iotDashboard",
]
MIDDLEWARE = [
'django.middleware.security.SecurityMiddleware',
'django.contrib.sessions.middleware.SessionMiddleware',
'django.middleware.common.CommonMiddleware',
'django.middleware.csrf.CsrfViewMiddleware',
'django.contrib.auth.middleware.AuthenticationMiddleware',
'django.contrib.messages.middleware.MessageMiddleware',
'django.middleware.clickjacking.XFrameOptionsMiddleware',
"django.middleware.security.SecurityMiddleware",
"django.contrib.sessions.middleware.SessionMiddleware",
"django.middleware.common.CommonMiddleware",
"django.middleware.csrf.CsrfViewMiddleware",
"django.contrib.auth.middleware.AuthenticationMiddleware",
"django.contrib.messages.middleware.MessageMiddleware",
"django.middleware.clickjacking.XFrameOptionsMiddleware",
]
ROOT_URLCONF = 'iotDashboard.urls'
ROOT_URLCONF = "iotDashboard.urls"
TEMPLATES = [
{
'BACKEND': 'django.template.backends.django.DjangoTemplates',
'DIRS': [os.path.join(BASE_DIR, 'iotDashboard/templates')],
'APP_DIRS': True,
'OPTIONS': {
'context_processors': [
'django.template.context_processors.debug',
'django.template.context_processors.request',
'django.contrib.auth.context_processors.auth',
'django.contrib.messages.context_processors.messages',
"BACKEND": "django.template.backends.django.DjangoTemplates",
"DIRS": [os.path.join(BASE_DIR, "iotDashboard/templates")],
"APP_DIRS": True,
"OPTIONS": {
"context_processors": [
"django.template.context_processors.debug",
"django.template.context_processors.request",
"django.contrib.auth.context_processors.auth",
"django.contrib.messages.context_processors.messages",
],
},
},
]
WSGI_APPLICATION = 'iotDashboard.wsgi.application'
WSGI_APPLICATION = "iotDashboard.wsgi.application"
# Database
# https://docs.djangoproject.com/en/4.2/ref/settings/#databases
DATABASES = {
'default': {
'ENGINE': 'django.db.backends.sqlite3',
'NAME': BASE_DIR / 'db.sqlite3',
"default": {
"ENGINE": "django.db.backends.postgresql",
"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 = [
{
'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
# 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
@@ -134,19 +134,20 @@ USE_TZ = True
# Static files (CSS, JavaScript, Images)
# https://docs.djangoproject.com/en/4.2/howto/static-files/
STATIC_URL = 'static/'
STATIC_URL = "static/"
# Default primary key field type
# 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"
HUEY = {
'huey_class': 'huey.SqliteHuey', # Or 'huey.RedisHuey' for Redis
'filename': 'demo.db', # SQLite file for task storage
'results': True,
'store_none': False,
'immediate': False,
'utc': True,
# Django REST Framework
# https://www.django-rest-framework.org/api-guide/settings/
REST_FRAMEWORK = {
'DEFAULT_RENDERER_CLASSES': [
'rest_framework.renderers.JSONRenderer',
],
'DEFAULT_PARSER_CLASSES': [
'rest_framework.parsers.JSONParser',
],
}

View File

@@ -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()

View File

@@ -3,15 +3,19 @@
<head>
<meta charset="UTF-8">
<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">
</head>
<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">
<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>
@@ -19,24 +23,19 @@
</nav>
<div class="container mt-5">
<h1 class="text-center mb-4">Add Sensor and Sensor Type</h1>
<form method="post">
<h1 class="text-center mb-4">Renew Certificate</h1>
<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 %}
{{ form.as_p }} <!-- Renders the inputs for all fields you added in your form -->
<input type="hidden" name="device_id" value="{{ device.id }}"> <!-- Hidden field for device ID -->
<button type="submit" class="btn btn-success">Save</button>
<button type="submit" class="btn btn-primary">Renew Certificate</button>
<a href="{% url 'device_list' %}" class="btn btn-secondary">Cancel</a>
</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>
</body>
</html>

View 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>

View File

@@ -93,19 +93,19 @@
<!-- Device Selector Dropdown -->
<div class="row mb-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>
<script>
const devices = JSON.parse('{{ devices_json|escapejs }}');
const deviceSelector = document.getElementById('deviceSelector');
const sensorSelector = document.getElementById('sensorSelector');
const metricSelector = document.getElementById('metricSelector');
// Populate device selector
devices.forEach(device => {
const option = document.createElement('option');
option.value = device.name; // Device name used for identification
option.textContent = device.name;
option.value = device.id; // Use device ID
option.textContent = device.name + ' (' + device.protocol.toUpperCase() + ')';
deviceSelector.appendChild(option);
});
</script>
@@ -113,11 +113,11 @@
</div>
</div>
<!-- Sensor Selector Dropdown -->
<!-- Metric Selector Dropdown -->
<div class="row mb-4">
<div class="col-md-4 offset-md-4">
<select id="sensorSelector" class="form-select">
<option value="">-- Select a Sensor --</option>
<select id="metricSelector" class="form-select">
<option value="">-- Select a Metric --</option>
</select>
</div>
</div>
@@ -157,39 +157,39 @@
var ctx = document.getElementById('conditionsChart').getContext('2d');
var conditionsChart;
function updateSensorOptions() {
const selectedDeviceName = document.getElementById('deviceSelector').value;
function updateMetricOptions() {
const selectedDeviceId = document.getElementById('deviceSelector').value;
// 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
const sensorSelector = document.getElementById('sensorSelector');
sensorSelector.innerHTML = '<option value="">-- Select a Sensor --</option>';
// Clear the existing options in the metricSelector
const metricSelector = document.getElementById('metricSelector');
metricSelector.innerHTML = '<option value="">-- Select a Metric --</option>';
if (selectedDevice) {
// Populate the sensor selector with the sensors for the selected device
selectedDevice.sensors.forEach(sensor => {
if (selectedDevice && selectedDevice.metrics) {
// Populate the metric selector with the metrics for the selected device
selectedDevice.metrics.forEach(metric => {
const option = document.createElement('option');
option.value = sensor.id; // Set the sensor ID as the value
option.textContent = sensor.type; // Set the sensor type as the display text
sensorSelector.appendChild(option);
option.value = metric;
option.textContent = metric.charAt(0).toUpperCase() + metric.slice(1);
metricSelector.appendChild(option);
});
}
}
function fetchDeviceData() {
var device = document.getElementById('deviceSelector').value;
var sensor = document.getElementById('sensorSelector').value; // Get selected sensor
var deviceId = document.getElementById('deviceSelector').value;
var metric = document.getElementById('metricSelector').value;
var startDate = document.getElementById('startDate').value;
var endDate = document.getElementById('endDate').value;
if (!device || !sensor) {
alert("Please select both a device and a sensor.");
if (!deviceId || !metric) {
alert("Please select both a device and a metric.");
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 => {
if (!response.ok) {
throw new Error("Network response was not ok: " + response.statusText);
@@ -212,9 +212,9 @@ function fetchDeviceData() {
labels: data.times,
datasets: [
{
label: `${sensor} (°C or %)`,
label: metric.charAt(0).toUpperCase() + metric.slice(1),
data: data.values,
borderColor: sensor === 'Temperature' ? 'red' : 'blue',
borderColor: metric === 'temperature' ? 'red' : (metric === 'humidity' ? 'blue' : 'green'),
fill: false,
}
]

View File

@@ -29,7 +29,7 @@
</li>
{% else %}
<li class="nav-item">
<a class="nav-link" href="{% url 'login' %}">Login</a>
<a class="nav-link" href="/admin">Login</a>
</li>
{% endif %}
</ul>

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