mirror of
https://github.com/ferdzo/iotDashboard.git
synced 2026-04-05 17:16:26 +00:00
Compare commits
1 Commits
6c0dc5f3d8
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| 9d8efc5eca |
@@ -1,34 +0,0 @@
|
||||
.venv
|
||||
.env
|
||||
.env.local
|
||||
__pycache__
|
||||
*.pyc
|
||||
*.pyo
|
||||
*.pyd
|
||||
.Python
|
||||
*.so
|
||||
*.egg
|
||||
*.egg-info
|
||||
dist
|
||||
build
|
||||
.pytest_cache
|
||||
.coverage
|
||||
htmlcov
|
||||
.tox
|
||||
.mypy_cache
|
||||
.ruff_cache
|
||||
db.sqlite3
|
||||
*.log
|
||||
.DS_Store
|
||||
.vscode
|
||||
.idea
|
||||
.git
|
||||
.gitignore
|
||||
README.md
|
||||
docs/
|
||||
frontend/
|
||||
services/
|
||||
infrastructure/
|
||||
thesis/
|
||||
scripts/
|
||||
demo.db
|
||||
20
.env.sample
20
.env.sample
@@ -1,20 +0,0 @@
|
||||
# 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
116
.github/copilot-instructions.md
vendored
@@ -1,116 +0,0 @@
|
||||
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
19
.github/prompts/planing.prompt.md
vendored
@@ -1,19 +0,0 @@
|
||||
---
|
||||
mode: agent
|
||||
description: 'Start planning'
|
||||
tools: ['getNotebookSummary', 'readNotebookCellOutput', 'search', 'getTerminalOutput', 'terminalSelection', 'terminalLastCommand', 'usages', 'vscodeAPI', 'think', 'problems', 'changes', 'testFailure', 'fetch', 'githubRepo', 'todos', 'get_issue', 'get_issue_comments', 'get_me','haystackFiles','haystackSearch']
|
||||
---
|
||||
Your goal is to prepare a detailed plan to fix the bug or add the new feature, for this you first need to:
|
||||
* Understand the context of the bug or feature by reading the issue description and comments.
|
||||
* Understand the codebase by reading the relevant instruction files.
|
||||
* If its a bug, then identify the root cause of the bug, and explain this to the user.
|
||||
|
||||
Based on your above understanding generate a plan to fix the bug or add the new feature.
|
||||
Ensure the plan consists of a Markdown document that has the following sections:
|
||||
|
||||
* Overview: A brief description of the bug/feature.
|
||||
* Root Cause: A detailed explanation of the root cause of the bug, including any relevant code snippets or references to the codebase. (only if it's a bug)
|
||||
* Requirements: A list of requirements to resolve the bug or add the new feature.
|
||||
* Implementation Steps: A detailed list of steps to implement the bug fix or new feature.
|
||||
|
||||
Remember, do not make any code edits, just generate a plan. Use thinking and reasoning skills to outline the steps needed to achieve the desired outcome.
|
||||
5
.gitignore
vendored
5
.gitignore
vendored
@@ -8,8 +8,3 @@ demo.db-wal
|
||||
/iotDashboard/demo1.db
|
||||
**/__pycache__/
|
||||
iotDashboard/db_create.py
|
||||
.venv/
|
||||
.ruff_cache/
|
||||
*.key
|
||||
*.crt
|
||||
*.csr
|
||||
@@ -1 +0,0 @@
|
||||
3.13
|
||||
23
.vscode/launch.json
vendored
23
.vscode/launch.json
vendored
@@ -1,23 +0,0 @@
|
||||
{
|
||||
// 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
|
||||
}
|
||||
]
|
||||
}
|
||||
49
Dockerfile
49
Dockerfile
@@ -1,49 +0,0 @@
|
||||
# Django Backend Dockerfile
|
||||
FROM ghcr.io/astral-sh/uv:python3.13-alpine AS builder
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
ENV UV_COMPILE_BYTECODE=1
|
||||
|
||||
# Copy dependency files
|
||||
COPY pyproject.toml uv.lock ./
|
||||
|
||||
# Install dependencies
|
||||
RUN uv sync --frozen --no-dev --no-install-project
|
||||
|
||||
# Copy application code
|
||||
COPY iotDashboard/ ./iotDashboard/
|
||||
COPY manage.py ./
|
||||
COPY create_user.py ./
|
||||
|
||||
# Sync the project
|
||||
RUN uv sync --frozen --no-dev
|
||||
|
||||
|
||||
# Stage 2: Runtime
|
||||
FROM python:3.13-alpine
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
# Install runtime dependencies
|
||||
RUN apk add --no-cache postgresql-client
|
||||
|
||||
# Copy virtual environment and application
|
||||
COPY --from=builder /app/.venv /app/.venv
|
||||
COPY --from=builder /app/iotDashboard/ /app/iotDashboard/
|
||||
COPY --from=builder /app/manage.py /app/
|
||||
COPY --from=builder /app/create_user.py /app/
|
||||
|
||||
# Create non-root user
|
||||
RUN adduser -D -u 1000 appuser && \
|
||||
chown -R appuser:appuser /app
|
||||
|
||||
USER appuser
|
||||
|
||||
ENV PATH="/app/.venv/bin:$PATH"
|
||||
ENV PYTHONUNBUFFERED=1
|
||||
|
||||
EXPOSE 3000
|
||||
|
||||
# Run Django with uvicorn for ASGI
|
||||
CMD ["python", "-m", "uvicorn", "iotDashboard.asgi:application", "--host", "0.0.0.0", "--port", "3000"]
|
||||
19
README.md
19
README.md
@@ -1,17 +1,2 @@
|
||||
# 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
|
||||
# iotDashboard
|
||||
iotDashboard - IOT dashboard with Django, TimescaleDB and Redis
|
||||
|
||||
107
create_user.py
107
create_user.py
@@ -1,107 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Create initial admin user for IoT Dashboard.
|
||||
Run this script once after running the database migration.
|
||||
|
||||
Usage:
|
||||
python create_user.py
|
||||
|
||||
Or with custom credentials:
|
||||
python create_user.py --username admin --password yourpassword --email admin@example.com
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
import secrets
|
||||
import argparse
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
|
||||
# Add project root to path
|
||||
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||
|
||||
# Configure Django settings before importing make_password (but don't call setup)
|
||||
os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'iotDashboard.settings')
|
||||
|
||||
# Import Django settings and configure password hasher manually
|
||||
from django.conf import settings
|
||||
if not settings.configured:
|
||||
from iotDashboard.settings import *
|
||||
settings.configure(
|
||||
PASSWORD_HASHERS=[
|
||||
'django.contrib.auth.hashers.PBKDF2PasswordHasher',
|
||||
'django.contrib.auth.hashers.PBKDF2SHA1PasswordHasher',
|
||||
'django.contrib.auth.hashers.Argon2PasswordHasher',
|
||||
'django.contrib.auth.hashers.BCryptSHA256PasswordHasher',
|
||||
],
|
||||
SECRET_KEY=SECRET_KEY,
|
||||
)
|
||||
|
||||
from django.contrib.auth.hashers import make_password
|
||||
|
||||
from db_migrations.models import User
|
||||
|
||||
def create_user(username: str, password: str, email: str):
|
||||
"""Create a new user in the database."""
|
||||
|
||||
# Get database URL from environment
|
||||
database_url = os.getenv("DATABASE_URL")
|
||||
if not database_url:
|
||||
print("Error: DATABASE_URL environment variable not set")
|
||||
print("Example: export DATABASE_URL='postgresql://user:pass@localhost:5432/iot_data'")
|
||||
sys.exit(1)
|
||||
|
||||
# Create database connection
|
||||
engine = create_engine(database_url)
|
||||
Session = sessionmaker(bind=engine)
|
||||
db = Session()
|
||||
|
||||
try:
|
||||
# Check if user already exists
|
||||
existing_user = db.query(User).filter(User.username == username).first()
|
||||
if existing_user:
|
||||
print(f"Error: User '{username}' already exists")
|
||||
sys.exit(1)
|
||||
|
||||
# Create new user
|
||||
user = User(
|
||||
id=secrets.token_urlsafe(8),
|
||||
username=username,
|
||||
email=email,
|
||||
password_hash=make_password(password),
|
||||
is_active=True
|
||||
)
|
||||
|
||||
db.add(user)
|
||||
db.commit()
|
||||
|
||||
print("✓ User created successfully!")
|
||||
print(f" Username: {username}")
|
||||
print(f" Email: {email}")
|
||||
print(f" ID: {user.id}")
|
||||
print("\nYou can now log in at http://localhost:5173/login")
|
||||
|
||||
except Exception as e:
|
||||
db.rollback()
|
||||
print(f"Error creating user: {e}")
|
||||
sys.exit(1)
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description='Create IoT Dashboard user')
|
||||
parser.add_argument('--username', default='admin', help='Username (default: admin)')
|
||||
parser.add_argument('--password', default='admin123', help='Password (default: admin123)')
|
||||
parser.add_argument('--email', default='admin@example.com', help='Email (default: admin@example.com)')
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
print("Creating IoT Dashboard user...")
|
||||
print(f"Username: {args.username}")
|
||||
print(f"Email: {args.email}")
|
||||
print()
|
||||
|
||||
create_user(args.username, args.password, args.email)
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -1,7 +0,0 @@
|
||||
.env*
|
||||
.md
|
||||
__pycache__/
|
||||
*.pyc
|
||||
*.pyo
|
||||
*.pyd
|
||||
*.log
|
||||
@@ -1 +0,0 @@
|
||||
CONNECTION_STRING=postgresql://user:password@localhost:5432/iotdashboard
|
||||
5
db_migrations/.gitignore
vendored
5
db_migrations/.gitignore
vendored
@@ -1,5 +0,0 @@
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*.so
|
||||
.Python
|
||||
*.egg-info/
|
||||
@@ -1,12 +0,0 @@
|
||||
FROM ghcr.io/astral-sh/uv:python3.13-alpine
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY pyproject.toml ./
|
||||
|
||||
RUN uv sync
|
||||
|
||||
COPY . .
|
||||
|
||||
CMD ["uv", "run", "alembic", "upgrade", "head"]
|
||||
|
||||
@@ -1,111 +0,0 @@
|
||||
# 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
|
||||
@@ -1,40 +0,0 @@
|
||||
# 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
|
||||
@@ -1,89 +0,0 @@
|
||||
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()
|
||||
@@ -1,28 +0,0 @@
|
||||
"""${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"}
|
||||
@@ -1,64 +0,0 @@
|
||||
"""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 ###
|
||||
@@ -1,60 +0,0 @@
|
||||
"""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 ###
|
||||
@@ -1,60 +0,0 @@
|
||||
"""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')
|
||||
@@ -1,55 +0,0 @@
|
||||
"""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 ###
|
||||
@@ -1,45 +0,0 @@
|
||||
"""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 ###
|
||||
@@ -1,48 +0,0 @@
|
||||
"""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']
|
||||
)
|
||||
@@ -1,83 +0,0 @@
|
||||
"""add users table
|
||||
|
||||
Revision ID: 7c71d43d53e3
|
||||
Revises: 4b84a36e13f5
|
||||
Create Date: 2025-12-15 21:24:36.718471+00:00
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.dialects import postgresql
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = '7c71d43d53e3'
|
||||
down_revision: Union[str, Sequence[str], None] = '4b84a36e13f5'
|
||||
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('users',
|
||||
sa.Column('id', sa.Text(), nullable=False),
|
||||
sa.Column('username', sa.Text(), nullable=False),
|
||||
sa.Column('email', sa.Text(), nullable=False),
|
||||
sa.Column('password_hash', sa.Text(), nullable=False),
|
||||
sa.Column('is_active', sa.Boolean(), nullable=True),
|
||||
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=True),
|
||||
sa.PrimaryKeyConstraint('id'),
|
||||
sa.UniqueConstraint('email'),
|
||||
sa.UniqueConstraint('username')
|
||||
)
|
||||
op.create_index('idx_users_email', 'users', ['email'], unique=False)
|
||||
op.create_index('idx_users_username', 'users', ['username'], unique=False)
|
||||
op.drop_index(op.f('dashboard_l_is_defa_033b71_idx'), table_name='dashboard_layouts')
|
||||
op.drop_index(op.f('dashboard_l_name_c36020_idx'), table_name='dashboard_layouts')
|
||||
op.drop_index(op.f('dashboard_layouts_name_349f3640_like'), table_name='dashboard_layouts', postgresql_ops={'name': 'varchar_pattern_ops'})
|
||||
op.drop_table('dashboard_layouts')
|
||||
op.drop_table('django_migrations')
|
||||
op.drop_table('iotDashboard_device')
|
||||
op.drop_constraint(op.f('telemetry_device_id_fkey'), 'telemetry', type_='foreignkey')
|
||||
op.create_foreign_key(None, 'telemetry', 'devices', ['device_id'], ['id'])
|
||||
# ### end Alembic commands ###
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
"""Downgrade schema."""
|
||||
# ### commands auto generated by Alembic - please adjust! ###
|
||||
op.drop_constraint(None, 'telemetry', type_='foreignkey')
|
||||
op.create_foreign_key(op.f('telemetry_device_id_fkey'), 'telemetry', 'devices', ['device_id'], ['id'], ondelete='CASCADE')
|
||||
op.create_table('iotDashboard_device',
|
||||
sa.Column('id', sa.BIGINT(), sa.Identity(always=False, start=1, increment=1, minvalue=1, maxvalue=9223372036854775807, cycle=False, cache=1), autoincrement=True, nullable=False),
|
||||
sa.Column('name', sa.VARCHAR(length=50), autoincrement=False, nullable=False),
|
||||
sa.Column('ip', sa.VARCHAR(length=20), autoincrement=False, nullable=False),
|
||||
sa.Column('protocol', sa.VARCHAR(length=20), autoincrement=False, nullable=False),
|
||||
sa.PrimaryKeyConstraint('id', name=op.f('iotDashboard_device_pkey'))
|
||||
)
|
||||
op.create_table('django_migrations',
|
||||
sa.Column('id', sa.BIGINT(), sa.Identity(always=False, start=1, increment=1, minvalue=1, maxvalue=9223372036854775807, cycle=False, cache=1), autoincrement=True, nullable=False),
|
||||
sa.Column('app', sa.VARCHAR(length=255), autoincrement=False, nullable=False),
|
||||
sa.Column('name', sa.VARCHAR(length=255), autoincrement=False, nullable=False),
|
||||
sa.Column('applied', postgresql.TIMESTAMP(timezone=True), autoincrement=False, nullable=False),
|
||||
sa.PrimaryKeyConstraint('id', name=op.f('django_migrations_pkey'))
|
||||
)
|
||||
op.create_table('dashboard_layouts',
|
||||
sa.Column('id', sa.BIGINT(), sa.Identity(always=False, start=1, increment=1, minvalue=1, maxvalue=9223372036854775807, cycle=False, cache=1), autoincrement=True, nullable=False),
|
||||
sa.Column('name', sa.VARCHAR(length=255), autoincrement=False, nullable=False),
|
||||
sa.Column('config', postgresql.JSONB(astext_type=sa.Text()), autoincrement=False, nullable=False),
|
||||
sa.Column('is_default', sa.BOOLEAN(), autoincrement=False, nullable=False),
|
||||
sa.Column('created_at', postgresql.TIMESTAMP(timezone=True), autoincrement=False, nullable=False),
|
||||
sa.Column('updated_at', postgresql.TIMESTAMP(timezone=True), autoincrement=False, nullable=False),
|
||||
sa.PrimaryKeyConstraint('id', name=op.f('dashboard_layouts_pkey')),
|
||||
sa.UniqueConstraint('name', name=op.f('dashboard_layouts_name_key'), postgresql_include=[], postgresql_nulls_not_distinct=False)
|
||||
)
|
||||
op.create_index(op.f('dashboard_layouts_name_349f3640_like'), 'dashboard_layouts', ['name'], unique=False, postgresql_ops={'name': 'varchar_pattern_ops'})
|
||||
op.create_index(op.f('dashboard_l_name_c36020_idx'), 'dashboard_layouts', ['name'], unique=False)
|
||||
op.create_index(op.f('dashboard_l_is_defa_033b71_idx'), 'dashboard_layouts', ['is_default'], unique=False)
|
||||
op.drop_index('idx_users_username', table_name='users')
|
||||
op.drop_index('idx_users_email', table_name='users')
|
||||
op.drop_table('users')
|
||||
# ### end Alembic commands ###
|
||||
@@ -1,45 +0,0 @@
|
||||
"""add cascade delete to telemetry foreign key
|
||||
|
||||
Revision ID: 1dfb0bb45f93
|
||||
Revises: 7c71d43d53e3
|
||||
Create Date: 2025-12-15 21:56:13.260281+00:00
|
||||
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.dialects import postgresql
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision: str = '1dfb0bb45f93'
|
||||
down_revision: Union[str, Sequence[str], None] = '7c71d43d53e3'
|
||||
branch_labels: Union[str, Sequence[str], None] = None
|
||||
depends_on: Union[str, Sequence[str], None] = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
"""Upgrade schema - only update telemetry foreign key."""
|
||||
# Drop old foreign key constraint
|
||||
op.drop_constraint('telemetry_device_id_fkey', 'telemetry', type_='foreignkey')
|
||||
|
||||
# Add new foreign key constraint with CASCADE delete
|
||||
op.create_foreign_key(
|
||||
'telemetry_device_id_fkey',
|
||||
'telemetry', 'devices',
|
||||
['device_id'], ['id'],
|
||||
ondelete='CASCADE'
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
"""Downgrade schema - revert foreign key change."""
|
||||
# Drop CASCADE foreign key
|
||||
op.drop_constraint('telemetry_device_id_fkey', 'telemetry', type_='foreignkey')
|
||||
|
||||
# Add back original foreign key without CASCADE
|
||||
op.create_foreign_key(
|
||||
'telemetry_device_id_fkey',
|
||||
'telemetry', 'devices',
|
||||
['device_id'], ['id']
|
||||
)
|
||||
@@ -1,141 +0,0 @@
|
||||
"""
|
||||
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", ondelete="CASCADE"), 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})>"
|
||||
|
||||
|
||||
class User(Base):
|
||||
"""Dashboard users for authentication."""
|
||||
|
||||
__tablename__ = "users"
|
||||
|
||||
id = Column(Text, primary_key=True)
|
||||
username = Column(Text, unique=True, nullable=False)
|
||||
email = Column(Text, unique=True, nullable=False)
|
||||
password_hash = Column(Text, nullable=False)
|
||||
is_active = Column(Boolean, default=True)
|
||||
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||
|
||||
__table_args__ = (
|
||||
Index("idx_users_username", "username"),
|
||||
Index("idx_users_email", "email"),
|
||||
)
|
||||
|
||||
def __repr__(self):
|
||||
return f"<User(username={self.username}, email={self.email})>"
|
||||
@@ -1,10 +0,0 @@
|
||||
[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",
|
||||
]
|
||||
@@ -1,9 +0,0 @@
|
||||
# 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.
|
||||
|
||||
@@ -1,13 +0,0 @@
|
||||
node_modules
|
||||
dist
|
||||
.env
|
||||
.env.local
|
||||
.env.*.local
|
||||
npm-debug.log*
|
||||
.DS_Store
|
||||
.vscode
|
||||
.idea
|
||||
*.swp
|
||||
*.swo
|
||||
*.log
|
||||
coverage
|
||||
@@ -1,8 +0,0 @@
|
||||
# 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
25
frontend/.gitignore
vendored
@@ -1,25 +0,0 @@
|
||||
# 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?
|
||||
@@ -1,22 +0,0 @@
|
||||
FROM node:22-alpine AS builder
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY package*.json ./
|
||||
|
||||
RUN npm ci
|
||||
|
||||
COPY . .
|
||||
|
||||
RUN npm run build
|
||||
|
||||
|
||||
FROM nginx:alpine
|
||||
|
||||
COPY --from=builder /app/dist /usr/share/nginx/html
|
||||
|
||||
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||
|
||||
EXPOSE 80
|
||||
|
||||
CMD ["nginx", "-g", "daemon off;"]
|
||||
@@ -1,111 +0,0 @@
|
||||
# IoT Dashboard Frontend
|
||||
|
||||
React-based dashboard for visualizing IoT telemetry data with customizable widgets.
|
||||
|
||||
## Technology Stack
|
||||
|
||||
| Technology | Purpose |
|
||||
|------------|---------|
|
||||
| React 19 | UI framework |
|
||||
| Vite | Build tool and dev server |
|
||||
| TypeScript | Type safety |
|
||||
| DaisyUI | Component library |
|
||||
| Tailwind CSS | Styling |
|
||||
| React Query | Data fetching and caching |
|
||||
| Recharts | Data visualization |
|
||||
| React Grid Layout | Drag-and-drop widget layout |
|
||||
|
||||
## Features
|
||||
|
||||
- Customizable widget-based dashboard
|
||||
- Drag-and-drop layout editing
|
||||
- Multiple widget types (weather, charts, calendar, AI briefings)
|
||||
- Responsive design
|
||||
- Dark/light theme support
|
||||
|
||||
## Widget Types
|
||||
|
||||
| Widget | Description |
|
||||
|--------|-------------|
|
||||
| WeatherWidget | Current weather and forecast |
|
||||
| AirQualityWidget | PM2.5, PM10 levels from pulse.eco |
|
||||
| ComfortIndexWidget | Indoor comfort based on temperature/humidity |
|
||||
| RunSuitabilityWidget | Outdoor running conditions analysis |
|
||||
| CalendarWidget | iCal calendar integration |
|
||||
| DailyBriefingWidget | AI-generated daily summary |
|
||||
| HealthStatsWidget | Health metrics from wearables |
|
||||
| TelemetryChartWidget | Time-series data visualization |
|
||||
|
||||
## Project Structure
|
||||
|
||||
```
|
||||
frontend/
|
||||
├── src/
|
||||
│ ├── api/ # API client functions
|
||||
│ ├── components/ # React components
|
||||
│ │ ├── widgets/ # Widget components
|
||||
│ │ └── ...
|
||||
│ ├── hooks/ # Custom React hooks
|
||||
│ ├── types/ # TypeScript type definitions
|
||||
│ ├── utils/ # Utility functions
|
||||
│ ├── App.tsx # Main application component
|
||||
│ └── main.tsx # Entry point
|
||||
├── public/ # Static assets
|
||||
├── package.json # Dependencies
|
||||
└── vite.config.ts # Vite configuration
|
||||
```
|
||||
|
||||
## Running
|
||||
|
||||
```bash
|
||||
cd frontend
|
||||
npm install
|
||||
npm run dev
|
||||
```
|
||||
|
||||
Development server runs at http://localhost:5173
|
||||
|
||||
## Configuration
|
||||
|
||||
The frontend connects to the Django API. Configure the API URL in `vite.config.ts`:
|
||||
|
||||
```typescript
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: 'http://localhost:8000',
|
||||
changeOrigin: true,
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
## Building for Production
|
||||
|
||||
```bash
|
||||
npm run build
|
||||
```
|
||||
|
||||
Output is in the `dist/` directory.
|
||||
|
||||
## Key Components
|
||||
|
||||
| Component | Purpose |
|
||||
|-----------|---------|
|
||||
| Dashboard.tsx | Main dashboard with widget grid |
|
||||
| WidgetWrapper.tsx | Generic widget container |
|
||||
| EditWidgetModal.tsx | Widget configuration modal |
|
||||
| AddWidgetMenu.tsx | Widget type selection |
|
||||
|
||||
## API Integration
|
||||
|
||||
All API calls are in `src/api/index.ts`. Uses React Query for:
|
||||
- Automatic caching
|
||||
- Background refetching
|
||||
- Loading/error states
|
||||
|
||||
Example:
|
||||
```typescript
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: ['weather', city],
|
||||
queryFn: () => fetchWeather(city),
|
||||
});
|
||||
```
|
||||
@@ -1,23 +0,0 @@
|
||||
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,
|
||||
},
|
||||
},
|
||||
])
|
||||
@@ -1,13 +0,0 @@
|
||||
<!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>Lyncis</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,36 +0,0 @@
|
||||
server {
|
||||
listen 80;
|
||||
server_name localhost;
|
||||
root /usr/share/nginx/html;
|
||||
index index.html;
|
||||
|
||||
# Gzip compression
|
||||
gzip on;
|
||||
gzip_vary on;
|
||||
gzip_min_length 1024;
|
||||
gzip_types text/plain text/css text/xml text/javascript application/x-javascript application/xml+rss application/javascript application/json;
|
||||
|
||||
# SPA routing - serve index.html for all routes
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
|
||||
# Proxy API requests to Django backend
|
||||
location /api/ {
|
||||
proxy_pass http://django:3000;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection 'upgrade';
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
proxy_cache_bypass $http_upgrade;
|
||||
}
|
||||
|
||||
# Cache static assets
|
||||
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
|
||||
expires 1y;
|
||||
add_header Cache-Control "public, immutable";
|
||||
}
|
||||
}
|
||||
5396
frontend/package-lock.json
generated
5396
frontend/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -1,46 +0,0 @@
|
||||
{
|
||||
"name": "frontend",
|
||||
"private": true,
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc -b && vite build",
|
||||
"lint": "eslint .",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"dependencies": {
|
||||
"@radix-ui/react-alert-dialog": "^1.1.15",
|
||||
"@radix-ui/react-dialog": "^1.1.15",
|
||||
"@tailwindcss/vite": "^4.1.17",
|
||||
"@tanstack/react-query": "^5.90.10",
|
||||
"@types/react-grid-layout": "^1.3.6",
|
||||
"axios": "^1.13.2",
|
||||
"daisyui": "^5.5.5",
|
||||
"gridstack": "^12.3.3",
|
||||
"ical.js": "^2.2.1",
|
||||
"qrcode.react": "^4.2.0",
|
||||
"react": "^19.1.1",
|
||||
"react-dom": "^19.1.1",
|
||||
"react-grid-layout": "^1.5.2",
|
||||
"react-hook-form": "^7.66.1",
|
||||
"react-hot-toast": "^2.6.0",
|
||||
"react-router-dom": "^7.9.6",
|
||||
"recharts": "^3.4.1",
|
||||
"tailwind-merge": "^3.4.0"
|
||||
},
|
||||
"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"
|
||||
}
|
||||
}
|
||||
@@ -1,165 +0,0 @@
|
||||
/* 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));
|
||||
}
|
||||
@@ -1,127 +0,0 @@
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import { BrowserRouter, Routes, Route, Link, NavLink, Navigate } from 'react-router-dom'
|
||||
import { Toaster } from 'react-hot-toast'
|
||||
import { WellnessStateProvider } from './hooks/useWellnessState'
|
||||
import { AuthProvider, useAuth } from './contexts/AuthContext'
|
||||
import Dashboard from './pages/Dashboard'
|
||||
import DeviceList from './pages/DeviceList'
|
||||
import DeviceDetail from './pages/DeviceDetail'
|
||||
import AddDevice from './pages/AddDevice'
|
||||
import Login from './pages/Login'
|
||||
import './App.css'
|
||||
|
||||
const queryClient = new QueryClient()
|
||||
|
||||
function ProtectedRoute({ children }: { children: React.ReactNode }) {
|
||||
const { isAuthenticated } = useAuth()
|
||||
return isAuthenticated ? <>{children}</> : <Navigate to="/login" />
|
||||
}
|
||||
|
||||
function AppLayout({ children }: { children: React.ReactNode }) {
|
||||
const { logout } = useAuth()
|
||||
|
||||
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 className="flex-none">
|
||||
<button onClick={logout} className="btn btn-ghost btn-sm">
|
||||
Logout
|
||||
</button>
|
||||
</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 flex flex-col">
|
||||
<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>Lyncis</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>
|
||||
|
||||
<div className="mt-auto p-4">
|
||||
<button onClick={logout} className="btn btn-ghost btn-sm w-full">
|
||||
<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="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1" />
|
||||
</svg>
|
||||
Logout
|
||||
</button>
|
||||
</div>
|
||||
</aside>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function App() {
|
||||
return (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<AuthProvider>
|
||||
<WellnessStateProvider>
|
||||
<BrowserRouter>
|
||||
<Toaster position="top-right" />
|
||||
<Routes>
|
||||
<Route path="/login" element={<Login />} />
|
||||
<Route path="/" element={<ProtectedRoute><AppLayout><Dashboard /></AppLayout></ProtectedRoute>} />
|
||||
<Route path="/dashboard" element={<ProtectedRoute><AppLayout><Dashboard /></AppLayout></ProtectedRoute>} />
|
||||
<Route path="/devices" element={<ProtectedRoute><AppLayout><DeviceList /></AppLayout></ProtectedRoute>} />
|
||||
<Route path="/devices/add" element={<ProtectedRoute><AppLayout><AddDevice /></AppLayout></ProtectedRoute>} />
|
||||
<Route path="/devices/:id" element={<ProtectedRoute><AppLayout><DeviceDetail /></AppLayout></ProtectedRoute>} />
|
||||
</Routes>
|
||||
</BrowserRouter>
|
||||
</WellnessStateProvider>
|
||||
</AuthProvider>
|
||||
</QueryClientProvider>
|
||||
)
|
||||
}
|
||||
|
||||
export default App
|
||||
@@ -1,402 +0,0 @@
|
||||
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
|
||||
@@ -1,295 +0,0 @@
|
||||
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 } }),
|
||||
};
|
||||
|
||||
// Dashboard Layout API
|
||||
export const dashboardLayoutApi = {
|
||||
getAll: () =>
|
||||
apiClient.get<Array<{
|
||||
id: number
|
||||
name: string
|
||||
config: any
|
||||
is_default: boolean
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}>>('/dashboard-layouts/'),
|
||||
|
||||
getDefault: () =>
|
||||
apiClient.get<{
|
||||
id: number
|
||||
name: string
|
||||
config: any
|
||||
is_default: boolean
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}>('/dashboard-layouts/default/'),
|
||||
|
||||
create: (data: {
|
||||
name: string
|
||||
config: any
|
||||
is_default?: boolean
|
||||
}) =>
|
||||
apiClient.post('/dashboard-layouts/', data),
|
||||
|
||||
update: (id: number, data: {
|
||||
name?: string
|
||||
config?: any
|
||||
is_default?: boolean
|
||||
}) =>
|
||||
apiClient.put(`/dashboard-layouts/${id}/`, data),
|
||||
|
||||
delete: (id: number) =>
|
||||
apiClient.delete(`/dashboard-layouts/${id}/`),
|
||||
|
||||
setDefault: (id: number) =>
|
||||
apiClient.post(`/dashboard-layouts/${id}/set_default/`),
|
||||
}
|
||||
|
||||
// Wellness API
|
||||
export const wellnessApi = {
|
||||
getRunSuitability: (healthDeviceId: string, city: string, timeOfDay?: string) =>
|
||||
apiClient.get<{
|
||||
status: 'GO' | 'MODERATE' | 'NO';
|
||||
overall_score: number;
|
||||
scores: {
|
||||
weather: number;
|
||||
air_quality: number;
|
||||
health: number;
|
||||
};
|
||||
primary_reason: string;
|
||||
detailed_insights: string[];
|
||||
time_recommendations: string[];
|
||||
suggestions: string[];
|
||||
weather_data: {
|
||||
temperature: number;
|
||||
wind_speed: number;
|
||||
precipitation: number;
|
||||
description: string;
|
||||
};
|
||||
air_quality_data: {
|
||||
pm25: number | null;
|
||||
pm10: number | null;
|
||||
status: string;
|
||||
};
|
||||
health_data: {
|
||||
steps: number;
|
||||
active_calories: number;
|
||||
heart_rate: number | null;
|
||||
};
|
||||
}>('/wellness/run_suitability/', {
|
||||
params: {
|
||||
health_device_id: healthDeviceId,
|
||||
city,
|
||||
...(timeOfDay && { time_of_day: timeOfDay }),
|
||||
},
|
||||
}),
|
||||
|
||||
getHealthInsights: (healthDeviceId: string, city?: string) =>
|
||||
apiClient.get<{
|
||||
health_metrics: {
|
||||
steps: number;
|
||||
active_calories: number;
|
||||
heart_rate: number | null;
|
||||
resting_heart_rate: number | null;
|
||||
};
|
||||
environmental_context: {
|
||||
temperature: number | null;
|
||||
humidity: number | null;
|
||||
pm25: number | null;
|
||||
pm10: number | null;
|
||||
air_quality_status: string | null;
|
||||
weather_description: string | null;
|
||||
};
|
||||
insights: Array<{
|
||||
metric: string;
|
||||
value: number;
|
||||
context: string;
|
||||
correlation: string | null;
|
||||
recommendation: string | null;
|
||||
}>;
|
||||
correlations: string[];
|
||||
recommendations: string[];
|
||||
trend_indicators: string[];
|
||||
}>('/wellness/health_insights/', {
|
||||
params: {
|
||||
health_device_id: healthDeviceId,
|
||||
...(city && { city }),
|
||||
},
|
||||
}),
|
||||
|
||||
getDailyBriefing: (params: {
|
||||
briefing_type: 'schedule' | 'environment' | 'full';
|
||||
city: string;
|
||||
health_device_id?: string;
|
||||
calendar_url?: string;
|
||||
calendar_range_hours?: number;
|
||||
}) =>
|
||||
apiClient.post<{
|
||||
status_emoji: string;
|
||||
status_line: string;
|
||||
insights: string[];
|
||||
recommendations: string[];
|
||||
briefing_type: string;
|
||||
generated_at: string;
|
||||
context: {
|
||||
indoor: Record<string, number> | null;
|
||||
outdoor: Record<string, number | string | null> | null;
|
||||
health: Record<string, number> | null;
|
||||
calendar_event_count: number;
|
||||
};
|
||||
}>('/wellness/daily_briefing/', params),
|
||||
};
|
||||
|
||||
// Calendar API
|
||||
export const calendarApi = {
|
||||
fetch: (calendarUrl: string, options?: { signal?: AbortSignal }) =>
|
||||
apiClient.get<{ calendar_data: string }>('/calendar/fetch/', {
|
||||
params: { calendar_url: calendarUrl },
|
||||
signal: options?.signal,
|
||||
}),
|
||||
};
|
||||
@@ -1 +0,0 @@
|
||||
<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>
|
||||
|
Before Width: | Height: | Size: 4.0 KiB |
@@ -1,832 +0,0 @@
|
||||
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(3)
|
||||
const [city, setCity] = useState('Skopje')
|
||||
const [calendarUrl, setCalendarUrl] = useState('')
|
||||
const [showCalendarUrl, setShowCalendarUrl] = useState(false)
|
||||
const [calendarRangeHours, setCalendarRangeHours] = useState(72)
|
||||
const [briefingType, setBriefingType] = useState<'schedule' | 'environment' | 'full'>('full')
|
||||
|
||||
// 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 || []
|
||||
|
||||
// Validation helpers
|
||||
const needsDevice = !['weather', 'air-quality', 'calendar', 'daily-briefing'].includes(widgetType)
|
||||
const needsMetrics = !['weather', 'air-quality', 'run-suitability', 'health-stats', 'calendar', 'daily-briefing'].includes(widgetType)
|
||||
|
||||
// Reset form when modal opens
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
setStep(1)
|
||||
setWidgetType('stat')
|
||||
setTitle('')
|
||||
setSelectedDevices([])
|
||||
setSelectedMetrics([])
|
||||
setTimeframeHours(24)
|
||||
setWidgetWidth(1)
|
||||
setWidgetHeight(3)
|
||||
setCalendarUrl('')
|
||||
setShowCalendarUrl(false)
|
||||
setCalendarRangeHours(72)
|
||||
setBriefingType('full')
|
||||
}
|
||||
}, [isOpen])
|
||||
|
||||
// Reset metrics when device changes
|
||||
useEffect(() => {
|
||||
setSelectedMetrics([])
|
||||
}, [selectedDevices])
|
||||
|
||||
const handleSubmit = () => {
|
||||
// Validation
|
||||
if (needsDevice && selectedDevices.length === 0) {
|
||||
alert('Please select a device')
|
||||
return
|
||||
}
|
||||
|
||||
if (needsMetrics && selectedMetrics.length === 0) {
|
||||
alert('Please select at least one metric')
|
||||
return
|
||||
}
|
||||
|
||||
const wellnessWidgets = ['run-suitability', 'health-stats']
|
||||
if (wellnessWidgets.includes(widgetType) && !city) {
|
||||
alert('Please enter a city for wellness widgets')
|
||||
return
|
||||
}
|
||||
|
||||
if (widgetType === 'calendar' && !calendarUrl.trim()) {
|
||||
alert('Please provide an iCal URL')
|
||||
return
|
||||
}
|
||||
|
||||
if (widgetType === 'daily-briefing' && !city) {
|
||||
alert('Please enter a city for daily briefing')
|
||||
return
|
||||
}
|
||||
|
||||
// Create title
|
||||
let defaultTitle = ''
|
||||
if (widgetType === 'weather') {
|
||||
defaultTitle = `Weather - ${city}`
|
||||
} else if (widgetType === 'air-quality') {
|
||||
defaultTitle = `Air Quality - ${city}`
|
||||
} else if (widgetType === 'run-suitability') {
|
||||
defaultTitle = `Run Suitability - ${city}`
|
||||
} else if (widgetType === 'health-stats') {
|
||||
const selectedDevice = devices.find(d => d.id === selectedDevices[0])
|
||||
defaultTitle = `Health Stats - ${selectedDevice?.name || 'Device'}`
|
||||
} else if (widgetType === 'calendar') {
|
||||
defaultTitle = 'Calendar Agenda'
|
||||
} else if (widgetType === 'daily-briefing') {
|
||||
const briefingLabels = { schedule: 'Schedule', environment: 'Environment', full: 'Full' }
|
||||
defaultTitle = `${briefingLabels[briefingType]} Briefing`
|
||||
} 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: needsDevice ? selectedDevices : [],
|
||||
metricIds: needsMetrics ? selectedMetrics : [],
|
||||
timeframe: {
|
||||
hours: timeframeHours,
|
||||
},
|
||||
visualization: {
|
||||
showLegend: true,
|
||||
showGrid: true,
|
||||
height: widgetType === 'line-chart' ? 300 : undefined,
|
||||
city: (widgetType === 'weather' || widgetType === 'air-quality' || wellnessWidgets.includes(widgetType) || widgetType === 'daily-briefing') ? city : undefined,
|
||||
},
|
||||
calendar: widgetType === 'calendar' ? {
|
||||
icalUrl: calendarUrl.trim(),
|
||||
timeRangeHours: calendarRangeHours,
|
||||
} : undefined,
|
||||
briefing: widgetType === 'daily-briefing' ? {
|
||||
briefingType,
|
||||
city,
|
||||
healthDeviceId: selectedDevices[0],
|
||||
calendarUrl: calendarUrl.trim() || undefined,
|
||||
calendarRangeHours: calendarRangeHours,
|
||||
} : undefined,
|
||||
position: {
|
||||
x: 0,
|
||||
y: 0,
|
||||
w: widgetWidth,
|
||||
h: ['run-suitability', 'health-stats', 'daily-briefing'].includes(widgetType) ? 2 : 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>
|
||||
|
||||
<button
|
||||
className={`btn ${widgetType === 'run-suitability' ? 'btn-primary' : 'btn-outline'} justify-start`}
|
||||
onClick={() => setWidgetType('run-suitability')}
|
||||
>
|
||||
<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="M13 10V3L4 14h7v7l9-11h-7z" />
|
||||
</svg>
|
||||
<div className="text-left">
|
||||
<div className="font-semibold">Run Suitability</div>
|
||||
<div className="text-xs opacity-70">Go / No Go</div>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<button
|
||||
className={`btn ${widgetType === 'health-stats' ? 'btn-primary' : 'btn-outline'} justify-start`}
|
||||
onClick={() => setWidgetType('health-stats')}
|
||||
>
|
||||
<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="M4.318 6.318a4.5 4.5 0 000 6.364L12 20.364l7.682-7.682a4.5 4.5 0 00-6.364-6.364L12 7.636l-1.318-1.318a4.5 4.5 0 00-6.364 0z" />
|
||||
</svg>
|
||||
<div className="text-left">
|
||||
<div className="font-semibold">Health Stats</div>
|
||||
<div className="text-xs opacity-70">Steps & HR</div>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<button
|
||||
className={`btn ${widgetType === 'calendar' ? 'btn-primary' : 'btn-outline'} justify-start`}
|
||||
onClick={() => setWidgetType('calendar')}
|
||||
>
|
||||
<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 7V3m8 4V3m-9 8h10m-12 8h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v10a2 2 0 002 2z" />
|
||||
</svg>
|
||||
<div className="text-left">
|
||||
<div className="font-semibold">Calendar</div>
|
||||
<div className="text-xs opacity-70">Agenda (iCal)</div>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<button
|
||||
className={`btn ${widgetType === 'daily-briefing' ? 'btn-primary' : 'btn-outline'} justify-start`}
|
||||
onClick={() => setWidgetType('daily-briefing')}
|
||||
>
|
||||
<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">Daily Briefing</div>
|
||||
<div className="text-xs opacity-70">AI Summary</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' || widgetType === 'calendar' || widgetType === 'daily-briefing') {
|
||||
setStep(3)
|
||||
} else {
|
||||
setStep(2)
|
||||
}
|
||||
}}
|
||||
>
|
||||
Next
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Step 2: Data Source */}
|
||||
{step === 2 && widgetType !== 'weather' && widgetType !== 'air-quality' && widgetType !== 'calendar' && widgetType !== 'daily-briefing' && (
|
||||
<div className="space-y-4">
|
||||
{/* City input for wellness widgets */}
|
||||
{['run-suitability', 'health-stats'].includes(widgetType) && (
|
||||
<div className="form-control">
|
||||
<label className="label">
|
||||
<span className="label-text font-semibold">City (for weather/air quality)</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">
|
||||
Used for weather and air quality context
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<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>
|
||||
|
||||
|
||||
|
||||
{/* Metric Selection - Skip for widgets with hardcoded metrics */}
|
||||
{!['run-suitability', 'health-stats'].includes(widgetType) && (
|
||||
<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={
|
||||
(needsDevice && selectedDevices.length === 0) ||
|
||||
(needsMetrics && 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>
|
||||
</>
|
||||
) : widgetType === 'calendar' ? (
|
||||
<>
|
||||
<div className="form-control">
|
||||
<label className="label">
|
||||
<span className="label-text font-semibold">iCal URL</span>
|
||||
</label>
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
type={showCalendarUrl ? 'text' : 'password'}
|
||||
className="input input-bordered flex-1"
|
||||
placeholder="https://calendar.google.com/calendar/ical/..."
|
||||
value={calendarUrl}
|
||||
onChange={(e) => setCalendarUrl(e.target.value)}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-outline"
|
||||
onClick={() => setShowCalendarUrl((prev) => !prev)}
|
||||
>
|
||||
{showCalendarUrl ? 'Hide' : 'Show'}
|
||||
</button>
|
||||
</div>
|
||||
<label className="label">
|
||||
<span className="label-text-alt">Link is saved with the widget configuration</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="form-control">
|
||||
<label className="label">
|
||||
<span className="label-text font-semibold">Agenda Range</span>
|
||||
</label>
|
||||
<select
|
||||
className="select select-bordered"
|
||||
value={calendarRangeHours}
|
||||
onChange={(e) => setCalendarRangeHours(Number(e.target.value))}
|
||||
>
|
||||
<option value={24}>Next 24 hours</option>
|
||||
<option value={72}>Next 3 days</option>
|
||||
<option value={168}>Next 7 days</option>
|
||||
</select>
|
||||
</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="Calendar Agenda"
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
) : widgetType === 'daily-briefing' ? (
|
||||
<>
|
||||
<div className="form-control">
|
||||
<label className="label">
|
||||
<span className="label-text font-semibold">Briefing Type</span>
|
||||
</label>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
className={`btn flex-1 ${briefingType === 'schedule' ? 'btn-primary' : 'btn-outline'}`}
|
||||
onClick={() => setBriefingType('schedule')}
|
||||
>
|
||||
📅 Schedule
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`btn flex-1 ${briefingType === 'environment' ? 'btn-primary' : 'btn-outline'}`}
|
||||
onClick={() => setBriefingType('environment')}
|
||||
>
|
||||
🌡️ Environment
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`btn flex-1 ${briefingType === 'full' ? 'btn-primary' : 'btn-outline'}`}
|
||||
onClick={() => setBriefingType('full')}
|
||||
>
|
||||
✨ Full
|
||||
</button>
|
||||
</div>
|
||||
<label className="label">
|
||||
<span className="label-text-alt">
|
||||
{briefingType === 'schedule' && 'Focus on your calendar and daily activity'}
|
||||
{briefingType === 'environment' && 'Focus on indoor/outdoor conditions for productivity'}
|
||||
{briefingType === 'full' && 'Complete briefing with all insights combined'}
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="form-control">
|
||||
<label className="label">
|
||||
<span className="label-text font-semibold">City (for weather/air quality)</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
className="input input-bordered"
|
||||
placeholder="Enter city name (e.g., Skopje)"
|
||||
value={city}
|
||||
onChange={(e) => setCity(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{(briefingType === 'schedule' || briefingType === 'full') && (
|
||||
<>
|
||||
<div className="form-control">
|
||||
<label className="label">
|
||||
<span className="label-text font-semibold">Calendar URL (Optional)</span>
|
||||
</label>
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
type={showCalendarUrl ? 'text' : 'password'}
|
||||
className="input input-bordered flex-1"
|
||||
placeholder="https://calendar.google.com/calendar/ical/..."
|
||||
value={calendarUrl}
|
||||
onChange={(e) => setCalendarUrl(e.target.value)}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-outline"
|
||||
onClick={() => setShowCalendarUrl((prev) => !prev)}
|
||||
>
|
||||
{showCalendarUrl ? 'Hide' : 'Show'}
|
||||
</button>
|
||||
</div>
|
||||
<label className="label">
|
||||
<span className="label-text-alt">Optional: Include calendar events in your briefing</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{calendarUrl && (
|
||||
<div className="form-control">
|
||||
<label className="label">
|
||||
<span className="label-text font-semibold">Calendar Range</span>
|
||||
</label>
|
||||
<select
|
||||
className="select select-bordered"
|
||||
value={calendarRangeHours}
|
||||
onChange={(e) => setCalendarRangeHours(Number(e.target.value))}
|
||||
>
|
||||
<option value={24}>Next 24 hours</option>
|
||||
<option value={72}>Next 3 days</option>
|
||||
<option value={168}>Next 7 days</option>
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{devices.length > 0 && (
|
||||
<div className="form-control">
|
||||
<label className="label">
|
||||
<span className="label-text font-semibold">Health Device (Optional)</span>
|
||||
</label>
|
||||
<select
|
||||
className="select select-bordered"
|
||||
value={selectedDevices[0] || ''}
|
||||
onChange={(e) => setSelectedDevices(e.target.value ? [e.target.value] : [])}
|
||||
>
|
||||
<option value="">No health device</option>
|
||||
{devices.map((device) => (
|
||||
<option key={device.id} value={device.id}>
|
||||
{device.name} ({device.location || 'No location'})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
<label className="label">
|
||||
<span className="label-text-alt">Optional: Include step count and activity 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={`${briefingType.charAt(0).toUpperCase() + briefingType.slice(1)} Briefing`}
|
||||
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: {needsDevice ? (devices.find(d => d.id === selectedDevices[0])?.name || 'Not selected') : 'Not required'}</div>
|
||||
<div>Metrics: {needsMetrics ? (selectedMetrics.join(', ') || 'Not selected') : 'Not required'}</div>
|
||||
<div>Size: {widgetWidth} × {widgetHeight}</div>
|
||||
{widgetType === 'calendar' && (
|
||||
<div>Range: Next {calendarRangeHours}h</div>
|
||||
)}
|
||||
{widgetType === 'daily-briefing' && (
|
||||
<div>Briefing: {briefingType.charAt(0).toUpperCase() + briefingType.slice(1)}</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="modal-action">
|
||||
<button
|
||||
className="btn btn-ghost"
|
||||
onClick={() => setStep((widgetType === 'weather' || widgetType === 'air-quality' || widgetType === 'calendar' || widgetType === 'daily-briefing') ? 1 : 2)}
|
||||
>
|
||||
Back
|
||||
</button>
|
||||
<button className="btn btn-primary" onClick={handleSubmit}>
|
||||
Add Widget
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="modal-backdrop" onClick={onClose}></div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,192 +0,0 @@
|
||||
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>
|
||||
)
|
||||
}
|
||||
@@ -1,76 +0,0 @@
|
||||
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>
|
||||
)
|
||||
}
|
||||
@@ -1,40 +0,0 @@
|
||||
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>
|
||||
)
|
||||
}
|
||||
@@ -1,496 +0,0 @@
|
||||
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 [selectedDeviceId, setSelectedDeviceId] = useState('')
|
||||
const [selectedMetrics, setSelectedMetrics] = useState<string[]>([])
|
||||
const [timeframeHours, setTimeframeHours] = useState(24)
|
||||
const [widgetWidth, setWidgetWidth] = useState(1)
|
||||
const [widgetHeight, setWidgetHeight] = useState(2)
|
||||
const [calendarUrl, setCalendarUrl] = useState('')
|
||||
const [showCalendarUrl, setShowCalendarUrl] = useState(false)
|
||||
const [calendarRangeHours, setCalendarRangeHours] = useState(72)
|
||||
const [briefingType, setBriefingType] = useState<'schedule' | 'environment' | 'full'>('full')
|
||||
const [city, setCity] = useState('Skopje')
|
||||
|
||||
// Fetch all devices
|
||||
const { data: devicesData } = useQuery({
|
||||
queryKey: ['devices'],
|
||||
queryFn: async () => {
|
||||
const response = await devicesApi.getAll()
|
||||
return response.data.results
|
||||
},
|
||||
enabled: isOpen,
|
||||
})
|
||||
|
||||
// Fetch metrics for the selected device
|
||||
const { data: deviceMetricsData } = useQuery({
|
||||
queryKey: ['device-metrics', selectedDeviceId],
|
||||
queryFn: async () => {
|
||||
if (!selectedDeviceId) return []
|
||||
const response = await devicesApi.getMetrics(selectedDeviceId)
|
||||
return response.data.metrics
|
||||
},
|
||||
enabled: !!selectedDeviceId && isOpen,
|
||||
})
|
||||
|
||||
const availableMetrics = Array.isArray(deviceMetricsData) ? deviceMetricsData : []
|
||||
const devices = Array.isArray(devicesData) ? devicesData : []
|
||||
|
||||
// Load widget data when modal opens
|
||||
useEffect(() => {
|
||||
if (isOpen && widget) {
|
||||
setTitle(widget.title || '')
|
||||
setSelectedDeviceId(widget.deviceIds[0] || widget.briefing?.healthDeviceId || '')
|
||||
setSelectedMetrics(widget.metricIds || [])
|
||||
setTimeframeHours(widget.timeframe?.hours || 24)
|
||||
setWidgetWidth(widget.position?.w || 1)
|
||||
setWidgetHeight(widget.position?.h || 2)
|
||||
setCalendarUrl(widget.calendar?.icalUrl || widget.briefing?.calendarUrl || '')
|
||||
setCalendarRangeHours(widget.calendar?.timeRangeHours || widget.briefing?.calendarRangeHours || 72)
|
||||
setShowCalendarUrl(false)
|
||||
setBriefingType((widget.briefing?.briefingType as 'schedule' | 'environment' | 'full') || 'full')
|
||||
setCity(widget.visualization?.city || widget.briefing?.city || 'Skopje')
|
||||
}
|
||||
}, [isOpen, widget])
|
||||
|
||||
// Reset metrics when device changes
|
||||
useEffect(() => {
|
||||
if (selectedDeviceId && widget && selectedDeviceId !== widget.deviceIds[0]) {
|
||||
setSelectedMetrics([])
|
||||
}
|
||||
}, [selectedDeviceId, widget])
|
||||
|
||||
const handleSubmit = () => {
|
||||
if (!widget) return
|
||||
|
||||
const needsDevice = !['weather', 'air-quality', 'run-suitability', 'health-stats', 'calendar', 'daily-briefing'].includes(widget.type)
|
||||
const needsMetrics = !['weather', 'air-quality', 'run-suitability', 'health-stats', 'calendar', 'daily-briefing'].includes(widget.type)
|
||||
|
||||
if (widget.type === 'calendar') {
|
||||
if (!calendarUrl.trim()) {
|
||||
alert('Please provide an iCal URL')
|
||||
return
|
||||
}
|
||||
|
||||
onSave(widget.id, {
|
||||
title,
|
||||
calendar: {
|
||||
icalUrl: calendarUrl.trim(),
|
||||
timeRangeHours: calendarRangeHours,
|
||||
},
|
||||
position: {
|
||||
...widget.position,
|
||||
x: widget.position?.x || 0,
|
||||
y: widget.position?.y || 0,
|
||||
w: widgetWidth,
|
||||
h: widgetHeight,
|
||||
},
|
||||
})
|
||||
onClose()
|
||||
return
|
||||
}
|
||||
|
||||
if (widget.type === 'daily-briefing') {
|
||||
if (!city.trim()) {
|
||||
alert('Please enter a city')
|
||||
return
|
||||
}
|
||||
|
||||
onSave(widget.id, {
|
||||
title,
|
||||
visualization: {
|
||||
...widget.visualization,
|
||||
city,
|
||||
},
|
||||
briefing: {
|
||||
briefingType,
|
||||
city,
|
||||
healthDeviceId: selectedDeviceId || undefined,
|
||||
calendarUrl: calendarUrl.trim() || undefined,
|
||||
calendarRangeHours: calendarRangeHours,
|
||||
},
|
||||
position: {
|
||||
...widget.position,
|
||||
x: widget.position?.x || 0,
|
||||
y: widget.position?.y || 0,
|
||||
w: widgetWidth,
|
||||
h: widgetHeight,
|
||||
},
|
||||
})
|
||||
onClose()
|
||||
return
|
||||
}
|
||||
|
||||
if (needsDevice && (!selectedDeviceId)) {
|
||||
alert('Please select a device')
|
||||
return
|
||||
}
|
||||
|
||||
if (needsMetrics && selectedMetrics.length === 0) {
|
||||
alert('Please select at least one metric')
|
||||
return
|
||||
}
|
||||
|
||||
onSave(widget.id, {
|
||||
title,
|
||||
deviceIds: needsDevice ? [selectedDeviceId] : [],
|
||||
metricIds: needsMetrics ? 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>
|
||||
|
||||
{/* Device Selection */}
|
||||
{!['weather', 'air-quality', 'run-suitability', 'health-stats', 'calendar', 'daily-briefing'].includes(widget.type) && (
|
||||
<div className="form-control">
|
||||
<label className="label">
|
||||
<span className="label-text font-semibold">Device</span>
|
||||
</label>
|
||||
<select
|
||||
className="select select-bordered"
|
||||
value={selectedDeviceId}
|
||||
onChange={(e) => setSelectedDeviceId(e.target.value)}
|
||||
>
|
||||
<option value="">Select a device</option>
|
||||
{devices.map((device) => (
|
||||
<option key={device.id} value={device.id}>
|
||||
{device.name} ({device.location || 'No location'})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</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 */}
|
||||
{!['weather', 'air-quality', 'run-suitability', 'health-stats', 'calendar', 'daily-briefing'].includes(widget.type) && (
|
||||
<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">
|
||||
{!selectedDeviceId ? (
|
||||
<div className="text-center text-base-content/60 py-4">
|
||||
Please select a device first
|
||||
</div>
|
||||
) : 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 */}
|
||||
{!['calendar', 'daily-briefing'].includes(widget.type) && (
|
||||
<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>
|
||||
)}
|
||||
|
||||
{widget.type === 'calendar' && (
|
||||
<>
|
||||
<div className="form-control">
|
||||
<label className="label">
|
||||
<span className="label-text font-semibold">iCal URL</span>
|
||||
</label>
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
type={showCalendarUrl ? 'text' : 'password'}
|
||||
className="input input-bordered flex-1"
|
||||
placeholder="https://calendar.google.com/calendar/ical/..."
|
||||
value={calendarUrl}
|
||||
onChange={(e) => setCalendarUrl(e.target.value)}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-outline"
|
||||
onClick={() => setShowCalendarUrl((prev) => !prev)}
|
||||
>
|
||||
{showCalendarUrl ? 'Hide' : 'Show'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="form-control">
|
||||
<label className="label">
|
||||
<span className="label-text font-semibold">Agenda Range</span>
|
||||
</label>
|
||||
<select
|
||||
className="select select-bordered"
|
||||
value={calendarRangeHours}
|
||||
onChange={(e) => setCalendarRangeHours(Number(e.target.value))}
|
||||
>
|
||||
<option value={24}>Next 24 hours</option>
|
||||
<option value={72}>Next 3 days</option>
|
||||
<option value={168}>Next 7 days</option>
|
||||
</select>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{widget.type === 'daily-briefing' && (
|
||||
<>
|
||||
<div className="form-control">
|
||||
<label className="label">
|
||||
<span className="label-text font-semibold">Briefing Type</span>
|
||||
</label>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
className={`btn flex-1 ${briefingType === 'schedule' ? 'btn-primary' : 'btn-outline'}`}
|
||||
onClick={() => setBriefingType('schedule')}
|
||||
>
|
||||
📅 Schedule
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`btn flex-1 ${briefingType === 'environment' ? 'btn-primary' : 'btn-outline'}`}
|
||||
onClick={() => setBriefingType('environment')}
|
||||
>
|
||||
🌡️ Environment
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`btn flex-1 ${briefingType === 'full' ? 'btn-primary' : 'btn-outline'}`}
|
||||
onClick={() => setBriefingType('full')}
|
||||
>
|
||||
✨ Full
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{(briefingType === 'schedule' || briefingType === 'full') && (
|
||||
<>
|
||||
<div className="form-control">
|
||||
<label className="label">
|
||||
<span className="label-text font-semibold">Calendar URL (Optional)</span>
|
||||
</label>
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
type={showCalendarUrl ? 'text' : 'password'}
|
||||
className="input input-bordered flex-1"
|
||||
placeholder="https://calendar.google.com/calendar/ical/..."
|
||||
value={calendarUrl}
|
||||
onChange={(e) => setCalendarUrl(e.target.value)}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-outline"
|
||||
onClick={() => setShowCalendarUrl((prev) => !prev)}
|
||||
>
|
||||
{showCalendarUrl ? 'Hide' : 'Show'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{calendarUrl && (
|
||||
<div className="form-control">
|
||||
<label className="label">
|
||||
<span className="label-text font-semibold">Calendar Range</span>
|
||||
</label>
|
||||
<select
|
||||
className="select select-bordered"
|
||||
value={calendarRangeHours}
|
||||
onChange={(e) => setCalendarRangeHours(Number(e.target.value))}
|
||||
>
|
||||
<option value={24}>Next 24 hours</option>
|
||||
<option value={72}>Next 3 days</option>
|
||||
<option value={168}>Next 7 days</option>
|
||||
</select>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{devices.length > 0 && (
|
||||
<div className="form-control">
|
||||
<label className="label">
|
||||
<span className="label-text font-semibold">Health Device (Optional)</span>
|
||||
</label>
|
||||
<select
|
||||
className="select select-bordered"
|
||||
value={selectedDeviceId || ''}
|
||||
onChange={(e) => setSelectedDeviceId(e.target.value)}
|
||||
>
|
||||
<option value="">No health device</option>
|
||||
{devices.map((device) => (
|
||||
<option key={device.id} value={device.id}>
|
||||
{device.name} ({device.location || 'No location'})
|
||||
</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>
|
||||
)
|
||||
}
|
||||
@@ -1,101 +0,0 @@
|
||||
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>
|
||||
)
|
||||
}
|
||||
@@ -1,74 +0,0 @@
|
||||
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>
|
||||
)
|
||||
}
|
||||
@@ -1,84 +0,0 @@
|
||||
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>
|
||||
)
|
||||
}
|
||||
@@ -1,441 +0,0 @@
|
||||
import { useState } from 'react'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { telemetryApi } from '../../api'
|
||||
import type { WidgetConfig } from '../../hooks'
|
||||
|
||||
interface AiInsightWidgetProps {
|
||||
config: WidgetConfig
|
||||
}
|
||||
|
||||
interface TrendSummary {
|
||||
status: 'excellent' | 'good' | 'fair' | 'poor'
|
||||
summary: string
|
||||
trends: Array<{
|
||||
metric: string
|
||||
direction: 'improving' | 'stable' | 'degrading'
|
||||
description: string
|
||||
}>
|
||||
comfort_score: {
|
||||
rating: number
|
||||
description: string
|
||||
}
|
||||
patterns: string[]
|
||||
recommendations: string[]
|
||||
forecast: string
|
||||
}
|
||||
|
||||
interface AnomalyDetection {
|
||||
status: 'normal' | 'warning' | 'critical'
|
||||
summary: string
|
||||
anomalies: Array<{
|
||||
metric: string
|
||||
severity: 'low' | 'medium' | 'high' | 'critical'
|
||||
description: string
|
||||
value: string
|
||||
expected: string
|
||||
}>
|
||||
impacts: string[]
|
||||
actions: string[]
|
||||
root_causes: string[]
|
||||
}
|
||||
|
||||
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()
|
||||
}
|
||||
|
||||
// Parse JSON analysis if it's a string
|
||||
const parsedAnalysis = analysis?.analysis ? (() => {
|
||||
try {
|
||||
return typeof analysis.analysis === 'string'
|
||||
? JSON.parse(analysis.analysis)
|
||||
: analysis.analysis
|
||||
} catch {
|
||||
return null // If parsing fails, return null to show raw text
|
||||
}
|
||||
})() : null
|
||||
|
||||
const isTrendSummary = promptType === 'trend_summary' && parsedAnalysis
|
||||
const isAnomalyDetection = promptType === 'anomaly_detection' && parsedAnalysis
|
||||
|
||||
const getStatusColor = (status: string) => {
|
||||
switch (status) {
|
||||
case 'excellent': return 'badge-success'
|
||||
case 'good': return 'badge-info'
|
||||
case 'fair': return 'badge-warning'
|
||||
case 'poor': return 'badge-error'
|
||||
case 'normal': return 'badge-success'
|
||||
case 'warning': return 'badge-warning'
|
||||
case 'critical': return 'badge-error'
|
||||
default: return 'badge-ghost'
|
||||
}
|
||||
}
|
||||
|
||||
const getDirectionIcon = (direction: string) => {
|
||||
switch (direction) {
|
||||
case 'improving': return '↑'
|
||||
case 'degrading': return '↓'
|
||||
case 'stable': return '→'
|
||||
default: return '•'
|
||||
}
|
||||
}
|
||||
|
||||
const getSeverityColor = (severity: string) => {
|
||||
switch (severity) {
|
||||
case 'critical': return 'badge-error'
|
||||
case 'high': return 'badge-warning'
|
||||
case 'medium': return 'badge-warning badge-outline'
|
||||
case 'low': return 'badge-info'
|
||||
default: return 'badge-ghost'
|
||||
}
|
||||
}
|
||||
|
||||
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">
|
||||
{/* Structured Display for Trend Summary */}
|
||||
{isTrendSummary && parsedAnalysis && (
|
||||
<>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className={`badge badge-lg ${getStatusColor(parsedAnalysis.status)}`}>
|
||||
{parsedAnalysis.status.toUpperCase()}
|
||||
</div>
|
||||
<div className="text-xs text-base-content/50">
|
||||
{analysis.data_points_analyzed} data points
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Summary */}
|
||||
<div className="alert alert-info">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<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" />
|
||||
</svg>
|
||||
<div>
|
||||
<div className="font-bold">Summary</div>
|
||||
<div className="text-sm">{parsedAnalysis.summary}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Comfort Score */}
|
||||
{parsedAnalysis.comfort_score && (
|
||||
<div className="card bg-base-200">
|
||||
<div className="card-body p-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="radial-progress text-primary" style={{ "--value": parsedAnalysis.comfort_score.rating } as React.CSSProperties}>
|
||||
{parsedAnalysis.comfort_score.rating}
|
||||
</div>
|
||||
<div>
|
||||
<div className="font-bold">Comfort Score</div>
|
||||
<div className="text-sm text-base-content/70">{parsedAnalysis.comfort_score.description}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Trends */}
|
||||
{parsedAnalysis.trends && parsedAnalysis.trends.length > 0 && (
|
||||
<div>
|
||||
<h4 className="font-bold text-sm mb-2">Trends</h4>
|
||||
<div className="space-y-2">
|
||||
{parsedAnalysis.trends.map((trend: TrendSummary['trends'][0], i: number) => (
|
||||
<div key={i} className="card bg-base-200">
|
||||
<div className="card-body p-3">
|
||||
<div className="flex items-start gap-2">
|
||||
<span className="text-lg">{getDirectionIcon(trend.direction)}</span>
|
||||
<div className="flex-1">
|
||||
<div className="font-semibold text-sm">{trend.metric}</div>
|
||||
<div className="text-xs text-base-content/70">{trend.description}</div>
|
||||
</div>
|
||||
<div className="badge badge-sm">{trend.direction}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Patterns */}
|
||||
{parsedAnalysis.patterns && parsedAnalysis.patterns.length > 0 && (
|
||||
<div>
|
||||
<h4 className="font-bold text-sm mb-2">Patterns Detected</h4>
|
||||
<ul className="space-y-1">
|
||||
{parsedAnalysis.patterns.map((pattern: string, i: number) => (
|
||||
<li key={i} className="text-sm flex items-start gap-2">
|
||||
<span className="text-primary mt-0.5">▸</span>
|
||||
<span>{pattern}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Recommendations */}
|
||||
{parsedAnalysis.recommendations && parsedAnalysis.recommendations.length > 0 && (
|
||||
<div>
|
||||
<h4 className="font-bold text-sm mb-2">Recommendations</h4>
|
||||
<div className="space-y-2">
|
||||
{parsedAnalysis.recommendations.map((rec: string, i: number) => (
|
||||
<div key={i} className="alert alert-success alert-sm">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-4 w-4 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<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>
|
||||
<span className="text-sm">{rec}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Forecast */}
|
||||
{parsedAnalysis.forecast && (
|
||||
<div className="alert">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 7h8m0 0v8m0-8l-8 8-4-4-6 6" />
|
||||
</svg>
|
||||
<div>
|
||||
<div className="font-bold text-sm">Forecast</div>
|
||||
<div className="text-xs">{parsedAnalysis.forecast}</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Structured Display for Anomaly Detection */}
|
||||
{isAnomalyDetection && parsedAnalysis && (
|
||||
<>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className={`badge badge-lg ${getStatusColor(parsedAnalysis.status)}`}>
|
||||
{parsedAnalysis.status.toUpperCase()}
|
||||
</div>
|
||||
<div className="text-xs text-base-content/50">
|
||||
{analysis.data_points_analyzed} data points
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Summary */}
|
||||
<div className={`alert ${parsedAnalysis.status === 'critical' ? 'alert-error' : parsedAnalysis.status === 'warning' ? 'alert-warning' : 'alert-success'}`}>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<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" />
|
||||
</svg>
|
||||
<div>
|
||||
<div className="font-bold">Summary</div>
|
||||
<div className="text-sm">{parsedAnalysis.summary}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Anomalies */}
|
||||
{parsedAnalysis.anomalies && parsedAnalysis.anomalies.length > 0 && (
|
||||
<div>
|
||||
<h4 className="font-bold text-sm mb-2">Anomalies Detected</h4>
|
||||
<div className="space-y-2">
|
||||
{parsedAnalysis.anomalies.map((anomaly: AnomalyDetection['anomalies'][0], i: number) => (
|
||||
<div key={i} className="card bg-base-200 border-l-4 border-error">
|
||||
<div className="card-body p-3">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<span className="font-semibold text-sm">{anomaly.metric}</span>
|
||||
<span className={`badge badge-sm ${getSeverityColor(anomaly.severity)}`}>
|
||||
{anomaly.severity}
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-xs text-base-content/70 mb-1">{anomaly.description}</div>
|
||||
<div className="text-xs">
|
||||
<span className="text-error font-semibold">Current: {anomaly.value}</span>
|
||||
{' • '}
|
||||
<span className="text-base-content/60">Expected: {anomaly.expected}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Impacts */}
|
||||
{parsedAnalysis.impacts && parsedAnalysis.impacts.length > 0 && (
|
||||
<div>
|
||||
<h4 className="font-bold text-sm mb-2">Potential Impacts</h4>
|
||||
<ul className="space-y-1">
|
||||
{parsedAnalysis.impacts.map((impact: string, i: number) => (
|
||||
<li key={i} className="text-sm flex items-start gap-2">
|
||||
<span className="text-warning mt-0.5">▸</span>
|
||||
<span>{impact}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Actions */}
|
||||
{parsedAnalysis.actions && parsedAnalysis.actions.length > 0 && (
|
||||
<div>
|
||||
<h4 className="font-bold text-sm mb-2">Recommended Actions</h4>
|
||||
<div className="space-y-2">
|
||||
{parsedAnalysis.actions.map((action: string, i: number) => (
|
||||
<div key={i} className="alert alert-warning alert-sm">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-4 w-4 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<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-sm">{action}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Root Causes */}
|
||||
{parsedAnalysis.root_causes && parsedAnalysis.root_causes.length > 0 && (
|
||||
<div>
|
||||
<h4 className="font-bold text-sm mb-2">Possible Root Causes</h4>
|
||||
<ul className="space-y-1">
|
||||
{parsedAnalysis.root_causes.map((cause: string, i: number) => (
|
||||
<li key={i} className="text-sm flex items-start gap-2">
|
||||
<span className="text-info mt-0.5">▸</span>
|
||||
<span>{cause}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Fallback: Raw Text Display */}
|
||||
{!parsedAnalysis && (
|
||||
<>
|
||||
<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>
|
||||
)
|
||||
}
|
||||
@@ -1,141 +0,0 @@
|
||||
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>
|
||||
)
|
||||
}
|
||||
@@ -1,308 +0,0 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import type { AxiosError } from 'axios'
|
||||
import ICAL from 'ical.js'
|
||||
import { calendarApi } from '../../api'
|
||||
import type { WidgetConfig } from '../../hooks'
|
||||
import './widget-styles.css'
|
||||
|
||||
const REFRESH_INTERVAL_MS = 5 * 60 * 1000
|
||||
const MAX_EVENTS = 25
|
||||
|
||||
interface CalendarWidgetProps {
|
||||
config: WidgetConfig
|
||||
}
|
||||
|
||||
interface AgendaEvent {
|
||||
id: string
|
||||
summary: string
|
||||
start: Date
|
||||
end: Date
|
||||
location?: string | null
|
||||
description?: string | null
|
||||
isAllDay: boolean
|
||||
}
|
||||
|
||||
const dayFormatter = new Intl.DateTimeFormat(undefined, {
|
||||
weekday: 'short',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
})
|
||||
|
||||
const timeFormatter = new Intl.DateTimeFormat(undefined, {
|
||||
hour: 'numeric',
|
||||
minute: '2-digit',
|
||||
})
|
||||
|
||||
const relativeFormatter = new Intl.RelativeTimeFormat(undefined, { numeric: 'auto' })
|
||||
|
||||
function formatDayLabel(date: Date) {
|
||||
const today = new Date()
|
||||
const startOfToday = new Date(today.getFullYear(), today.getMonth(), today.getDate())
|
||||
const startOfDate = new Date(date.getFullYear(), date.getMonth(), date.getDate())
|
||||
const diffMs = startOfDate.getTime() - startOfToday.getTime()
|
||||
const diffDays = Math.round(diffMs / (24 * 60 * 60 * 1000))
|
||||
|
||||
if (Math.abs(diffDays) <= 1) {
|
||||
return relativeFormatter.format(diffDays, 'day')
|
||||
}
|
||||
|
||||
return dayFormatter.format(date)
|
||||
}
|
||||
|
||||
function formatEventRange(event: AgendaEvent) {
|
||||
const sameDay = event.start.toDateString() === event.end.toDateString()
|
||||
const dayLabel = formatDayLabel(event.start)
|
||||
|
||||
if (event.isAllDay) {
|
||||
return `${dayLabel} - All day`
|
||||
}
|
||||
|
||||
if (sameDay) {
|
||||
return `${dayLabel} - ${timeFormatter.format(event.start)} to ${timeFormatter.format(event.end)}`
|
||||
}
|
||||
|
||||
return `${dayLabel} ${timeFormatter.format(event.start)} -> ${formatDayLabel(event.end)} ${timeFormatter.format(event.end)}`
|
||||
}
|
||||
|
||||
function createAgendaEvents(
|
||||
component: ICAL.Component,
|
||||
windowStart: Date,
|
||||
windowEnd: Date,
|
||||
): AgendaEvent[] {
|
||||
const event = new ICAL.Event(component)
|
||||
const results: AgendaEvent[] = []
|
||||
const eventTemplate = {
|
||||
summary: event.summary || 'Untitled event',
|
||||
location: event.location || null,
|
||||
description: event.description || null,
|
||||
}
|
||||
|
||||
const addOccurrence = (start: ICAL.Time, end?: ICAL.Time | null) => {
|
||||
const jsStart = start.toJSDate()
|
||||
const jsEnd = (end || start).toJSDate()
|
||||
|
||||
if (jsEnd < windowStart || jsStart > windowEnd) {
|
||||
return
|
||||
}
|
||||
|
||||
results.push({
|
||||
id: `${event.uid || event.summary}-${jsStart.toISOString()}`,
|
||||
summary: eventTemplate.summary,
|
||||
location: eventTemplate.location,
|
||||
description: eventTemplate.description,
|
||||
start: jsStart,
|
||||
end: jsEnd,
|
||||
isAllDay: start.isDate,
|
||||
})
|
||||
}
|
||||
|
||||
const overlapWindowStart = new Date(windowStart.getTime() - 24 * 60 * 60 * 1000)
|
||||
const iteratorStart = ICAL.Time.fromJSDate(overlapWindowStart)
|
||||
|
||||
if (event.isRecurring()) {
|
||||
const iterator = event.iterator(iteratorStart)
|
||||
let next = iterator.next()
|
||||
while (next) {
|
||||
const occurrence = event.getOccurrenceDetails(next)
|
||||
addOccurrence(occurrence.startDate, occurrence.endDate)
|
||||
const jsStart = occurrence.startDate.toJSDate()
|
||||
if (jsStart > windowEnd) {
|
||||
break
|
||||
}
|
||||
next = iterator.next()
|
||||
}
|
||||
} else {
|
||||
addOccurrence(event.startDate, event.endDate)
|
||||
}
|
||||
|
||||
return results
|
||||
}
|
||||
|
||||
export default function CalendarWidget({ config }: CalendarWidgetProps) {
|
||||
const calendarConfig = config.calendar
|
||||
const [events, setEvents] = useState<AgendaEvent[]>([])
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [lastUpdated, setLastUpdated] = useState<Date | null>(null)
|
||||
const abortRef = useRef<AbortController | null>(null)
|
||||
const requestIdRef = useRef(0)
|
||||
|
||||
const fetchEvents = useCallback(async () => {
|
||||
if (!calendarConfig?.icalUrl) {
|
||||
setEvents([])
|
||||
setError('Calendar URL is missing')
|
||||
return
|
||||
}
|
||||
|
||||
abortRef.current?.abort()
|
||||
const controller = new AbortController()
|
||||
abortRef.current = controller
|
||||
const requestId = requestIdRef.current + 1
|
||||
requestIdRef.current = requestId
|
||||
setIsLoading(true)
|
||||
setError(null)
|
||||
|
||||
try {
|
||||
const response = await calendarApi.fetch(calendarConfig.icalUrl, {
|
||||
signal: controller.signal,
|
||||
})
|
||||
|
||||
const raw = response.data?.calendar_data
|
||||
if (!raw) {
|
||||
throw new Error('Calendar feed was empty')
|
||||
}
|
||||
const parsed = ICAL.parse(raw)
|
||||
const comp = new ICAL.Component(parsed)
|
||||
const vevents = comp.getAllSubcomponents('vevent') || []
|
||||
|
||||
const now = new Date()
|
||||
const windowStart = new Date(now.getTime() - 30 * 60 * 1000) // keep events that started recently
|
||||
const windowEnd = new Date(now.getTime() + (calendarConfig.timeRangeHours || 72) * 60 * 60 * 1000)
|
||||
|
||||
const agendaEvents = vevents
|
||||
.flatMap((vevent) => createAgendaEvents(vevent, windowStart, windowEnd))
|
||||
.sort((a, b) => a.start.getTime() - b.start.getTime())
|
||||
.slice(0, MAX_EVENTS)
|
||||
|
||||
setEvents(agendaEvents)
|
||||
setLastUpdated(new Date())
|
||||
} catch (err) {
|
||||
if (controller.signal.aborted) {
|
||||
return
|
||||
}
|
||||
|
||||
const axiosError = err as AxiosError<{ error?: string; detail?: string }>
|
||||
const message = axiosError?.response?.data?.error
|
||||
|| axiosError?.response?.data?.detail
|
||||
|| axiosError?.message
|
||||
|| 'Failed to load calendar'
|
||||
setError(message)
|
||||
setEvents([])
|
||||
} finally {
|
||||
if (!controller.signal.aborted && requestId === requestIdRef.current) {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
}, [calendarConfig?.icalUrl, calendarConfig?.timeRangeHours])
|
||||
|
||||
useEffect(() => {
|
||||
if (!calendarConfig?.icalUrl) {
|
||||
return
|
||||
}
|
||||
|
||||
fetchEvents()
|
||||
const interval = setInterval(fetchEvents, REFRESH_INTERVAL_MS)
|
||||
|
||||
return () => {
|
||||
clearInterval(interval)
|
||||
abortRef.current?.abort()
|
||||
}
|
||||
}, [calendarConfig?.icalUrl, fetchEvents])
|
||||
|
||||
const handleManualRefresh = () => {
|
||||
fetchEvents()
|
||||
}
|
||||
|
||||
if (!calendarConfig) {
|
||||
return (
|
||||
<div className="widget-card card bg-base-100 h-full">
|
||||
<div className="card-body p-4 text-sm">
|
||||
<h2 className="card-title text-sm mb-2">{config.title || 'Calendar'}</h2>
|
||||
<p className="opacity-70">
|
||||
Configure an iCal URL to see your agenda.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const rangeLabel = `Next ${calendarConfig.timeRangeHours || 72}h`
|
||||
|
||||
return (
|
||||
<div className="widget-card card bg-base-100 h-full">
|
||||
<div className="card-body p-3 h-full flex flex-col gap-3">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div>
|
||||
<p className="text-xs uppercase tracking-wide opacity-60">Agenda</p>
|
||||
<h2 className="card-title text-sm leading-tight">{config.title || 'Calendar'}</h2>
|
||||
<p className="text-xs opacity-60">{rangeLabel}</p>
|
||||
</div>
|
||||
<div className="flex flex-col items-end gap-1">
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-xs btn-outline"
|
||||
onClick={handleManualRefresh}
|
||||
disabled={isLoading}
|
||||
>
|
||||
{isLoading ? 'Refreshing...' : 'Refresh'}
|
||||
</button>
|
||||
{lastUpdated && (
|
||||
<span className="text-[10px] opacity-60">
|
||||
Updated {timeFormatter.format(lastUpdated)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="alert alert-error text-xs">
|
||||
<span>{error}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!error && events.length === 0 && !isLoading && (
|
||||
<div className="flex-1 flex items-center justify-center text-sm opacity-60 text-center">
|
||||
No upcoming events in this window.
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isLoading && events.length === 0 && (
|
||||
<div className="flex-1 flex items-center justify-center">
|
||||
<span className="loading loading-spinner"></span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{events.length > 0 && (
|
||||
<ul className="flex-1 overflow-y-auto divide-y divide-base-200">
|
||||
{events.map((event) => (
|
||||
<li key={event.id} className="py-2">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div>
|
||||
<p className="text-sm font-semibold leading-tight">
|
||||
{event.summary || 'Untitled event'}
|
||||
</p>
|
||||
<p className="text-xs opacity-70">
|
||||
{formatEventRange(event)}
|
||||
</p>
|
||||
{event.location && (
|
||||
<p className="text-[11px] opacity-70 mt-1 flex items-center gap-1">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className="h-3 w-3"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M12 11c1.38 0 2.5-1.12 2.5-2.5S13.38 6 12 6s-2.5 1.12-2.5 2.5S10.62 11 12 11zm0 0c-4 0-5 4-5 4v.5a2.5 2.5 0 002.5 2.5h5a2.5 2.5 0 002.5-2.5V15s-1-4-5-4z"
|
||||
/>
|
||||
</svg>
|
||||
{event.location}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-xs font-semibold text-right whitespace-nowrap">
|
||||
{event.isAllDay ? 'All day' : timeFormatter.format(event.start)}
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,209 +0,0 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { devicesApi } from '../../api'
|
||||
import type { WidgetConfig } from '../../hooks'
|
||||
import './widget-styles.css'
|
||||
|
||||
type IconProps = { className?: string }
|
||||
|
||||
const IconBase = ({ className, children }: IconProps & { children: ReactNode }) => (
|
||||
<svg
|
||||
className={className}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth={1.7}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
{children}
|
||||
</svg>
|
||||
)
|
||||
|
||||
const ThermometerIcon = ({ className }: IconProps) => (
|
||||
<IconBase className={className}>
|
||||
<path d="M14 14.5V5a2 2 0 00-4 0v9.5a3.5 3.5 0 104 0z" />
|
||||
<line x1="12" y1="8" x2="12" y2="11" />
|
||||
</IconBase>
|
||||
)
|
||||
|
||||
const DropletIcon = ({ className }: IconProps) => (
|
||||
<IconBase className={className}>
|
||||
<path d="M12 3.5s-4 5-4 8.5a4 4 0 108 0c0-3.5-4-8.5-4-8.5z" />
|
||||
</IconBase>
|
||||
)
|
||||
|
||||
const AirQualityIcon = ({ className }: IconProps) => (
|
||||
<IconBase className={className}>
|
||||
<path d="M4 12h9a3 3 0 10-3-3" />
|
||||
<path d="M6 17h8a3 3 0 11-3 3" />
|
||||
</IconBase>
|
||||
)
|
||||
|
||||
const AcousticIcon = ({ className }: IconProps) => (
|
||||
<IconBase className={className}>
|
||||
<path d="M5 9v6h3l4 4V5l-4 4H5z" />
|
||||
<path d="M16 9a4 4 0 010 6" />
|
||||
<path d="M18 7a6 6 0 010 10" />
|
||||
</IconBase>
|
||||
)
|
||||
|
||||
const LightIcon = ({ className }: IconProps) => (
|
||||
<IconBase className={className}>
|
||||
<path d="M12 3a5 5 0 00-3 9v3h6v-3a5 5 0 00-3-9z" />
|
||||
<path d="M10 18h4" />
|
||||
<path d="M10 21h4" />
|
||||
</IconBase>
|
||||
)
|
||||
|
||||
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">
|
||||
<ThermometerIcon className="w-4 h-4" />
|
||||
<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">
|
||||
<DropletIcon className="w-4 h-4" />
|
||||
<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">
|
||||
<AirQualityIcon className="w-4 h-4" />
|
||||
<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">
|
||||
<AcousticIcon className="w-4 h-4" />
|
||||
<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">
|
||||
<LightIcon className="w-4 h-4" />
|
||||
<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="flex items-center gap-1 text-xs font-semibold mb-1">
|
||||
<LightIcon className="w-3.5 h-3.5" />
|
||||
<span>Suggestions</span>
|
||||
</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>
|
||||
)
|
||||
}
|
||||
@@ -1,266 +0,0 @@
|
||||
import { useState, useCallback } from 'react'
|
||||
import { wellnessApi } from '../../api'
|
||||
import type { WidgetConfig } from '../../hooks'
|
||||
import './widget-styles.css'
|
||||
|
||||
interface DailyBriefingWidgetProps {
|
||||
config: WidgetConfig
|
||||
}
|
||||
|
||||
interface BriefingData {
|
||||
status_emoji: string
|
||||
status_line: string
|
||||
insights: string[]
|
||||
recommendations: string[]
|
||||
briefing_type: string
|
||||
generated_at: string
|
||||
context: {
|
||||
indoor: Record<string, number> | null
|
||||
outdoor: Record<string, number | string | null> | null
|
||||
health: Record<string, number> | null
|
||||
calendar_event_count: number
|
||||
}
|
||||
}
|
||||
|
||||
const CalendarIcon = () => (
|
||||
<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="M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z" />
|
||||
</svg>
|
||||
)
|
||||
|
||||
const BuildingIcon = () => (
|
||||
<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="M19 21V5a2 2 0 00-2-2H7a2 2 0 00-2 2v16m14 0h2m-2 0h-5m-9 0H3m2 0h5M9 7h1m-1 4h1m4-4h1m-1 4h1m-5 10v-5a1 1 0 011-1h2a1 1 0 011 1v5m-4 0h4" />
|
||||
</svg>
|
||||
)
|
||||
|
||||
const ClipboardIcon = () => (
|
||||
<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 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2m-3 7h3m-3 4h3m-6-4h.01M9 16h.01" />
|
||||
</svg>
|
||||
)
|
||||
|
||||
const LightbulbIcon = () => (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5 text-primary" 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>
|
||||
)
|
||||
|
||||
const StatusIcon = ({ status }: { status: string }) => {
|
||||
if (status.includes('good') || status.includes('great') || status.includes('optimal')) {
|
||||
return (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-6 w-6 text-success" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<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>
|
||||
)
|
||||
}
|
||||
if (status.includes('warning') || status.includes('moderate') || status.includes('attention')) {
|
||||
return (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-6 w-6 text-warning" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<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>
|
||||
)
|
||||
}
|
||||
return (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-6 w-6 text-info" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<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" />
|
||||
</svg>
|
||||
)
|
||||
}
|
||||
|
||||
const BRIEFING_TYPES = [
|
||||
{ value: 'schedule', label: 'Schedule', Icon: CalendarIcon, description: 'Calendar & activity focus' },
|
||||
{ value: 'environment', label: 'Environment', Icon: BuildingIcon, description: 'Workspace conditions' },
|
||||
{ value: 'full', label: 'Full', Icon: ClipboardIcon, description: 'Complete overview' },
|
||||
] as const
|
||||
|
||||
type BriefingType = typeof BRIEFING_TYPES[number]['value']
|
||||
|
||||
const timeFormatter = new Intl.DateTimeFormat(undefined, {
|
||||
hour: 'numeric',
|
||||
minute: '2-digit',
|
||||
})
|
||||
|
||||
export default function DailyBriefingWidget({ config }: DailyBriefingWidgetProps) {
|
||||
const briefingConfig = config.briefing
|
||||
const [briefingType, setBriefingType] = useState<BriefingType>(
|
||||
(briefingConfig?.briefingType as BriefingType) || 'full'
|
||||
)
|
||||
const [briefingData, setBriefingData] = useState<BriefingData | null>(null)
|
||||
const [isLoading, setIsLoading] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
const city = config.visualization?.city || briefingConfig?.city || 'Skopje'
|
||||
const healthDeviceId = config.deviceIds?.[0] || briefingConfig?.healthDeviceId
|
||||
const calendarUrl = briefingConfig?.calendarUrl
|
||||
|
||||
const generateBriefing = useCallback(async () => {
|
||||
setIsLoading(true)
|
||||
setError(null)
|
||||
|
||||
try {
|
||||
const response = await wellnessApi.getDailyBriefing({
|
||||
briefing_type: briefingType,
|
||||
city,
|
||||
health_device_id: healthDeviceId,
|
||||
calendar_url: calendarUrl,
|
||||
calendar_range_hours: briefingConfig?.calendarRangeHours || 24,
|
||||
})
|
||||
|
||||
setBriefingData(response.data)
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : 'Failed to generate briefing'
|
||||
setError(message)
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}, [briefingType, city, healthDeviceId, calendarUrl, briefingConfig?.calendarRangeHours])
|
||||
|
||||
const getBriefingTypeInfo = (type: BriefingType) => {
|
||||
return BRIEFING_TYPES.find(t => t.value === type) || BRIEFING_TYPES[2]
|
||||
}
|
||||
|
||||
const CurrentIcon = getBriefingTypeInfo(briefingType).Icon
|
||||
|
||||
// No config state - show setup message
|
||||
if (!city) {
|
||||
return (
|
||||
<div className="widget-card card bg-base-100 h-full">
|
||||
<div className="card-body p-4">
|
||||
<h2 className="card-title text-sm">{config.title || 'Daily Briefing'}</h2>
|
||||
<p className="text-sm opacity-70">
|
||||
Configure a city to generate briefings.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="widget-card card bg-base-100 h-full flex flex-col">
|
||||
<div className="card-body p-3 flex-1 flex flex-col gap-2 min-h-0">
|
||||
{/* Header */}
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div>
|
||||
<p className="text-xs uppercase tracking-wide opacity-60 flex items-center gap-1">
|
||||
<CurrentIcon /> {getBriefingTypeInfo(briefingType).label} Briefing
|
||||
</p>
|
||||
<h2 className="card-title text-sm leading-tight">{config.title || 'Daily Briefing'}</h2>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Briefing Type Selector */}
|
||||
<div className="flex gap-1">
|
||||
{BRIEFING_TYPES.map((type) => (
|
||||
<button
|
||||
key={type.value}
|
||||
type="button"
|
||||
className={`btn btn-xs flex-1 gap-1 ${
|
||||
briefingType === type.value ? 'btn-primary' : 'btn-outline'
|
||||
}`}
|
||||
onClick={() => setBriefingType(type.value)}
|
||||
title={type.description}
|
||||
>
|
||||
<type.Icon /> {type.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Generate Button or Content */}
|
||||
{!briefingData && !isLoading && (
|
||||
<div className="flex-1 flex flex-col items-center justify-center gap-3">
|
||||
<p className="text-sm opacity-60 text-center">
|
||||
Get AI-powered insights for your day
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-primary"
|
||||
onClick={generateBriefing}
|
||||
disabled={isLoading}
|
||||
>
|
||||
Generate Briefing
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Loading State */}
|
||||
{isLoading && (
|
||||
<div className="flex-1 flex flex-col items-center justify-center gap-2">
|
||||
<span className="loading loading-spinner loading-lg"></span>
|
||||
<p className="text-sm opacity-60">Analyzing your environment...</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Error State */}
|
||||
{error && !isLoading && (
|
||||
<div className="flex-1 flex flex-col gap-2">
|
||||
<div className="alert alert-error text-xs">
|
||||
<span>{error}</span>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-sm btn-outline"
|
||||
onClick={generateBriefing}
|
||||
>
|
||||
Try Again
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Briefing Content */}
|
||||
{briefingData && !isLoading && (
|
||||
<div className="flex-1 flex flex-col gap-2 overflow-y-auto">
|
||||
{/* Status Line */}
|
||||
<div className="bg-base-200 rounded-lg p-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<StatusIcon status={briefingData.status_line.toLowerCase()} />
|
||||
<p className="text-sm font-medium">{briefingData.status_line}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Insights */}
|
||||
{briefingData.insights.length > 0 && (
|
||||
<div className="space-y-1.5">
|
||||
{briefingData.insights.map((insight, i) => (
|
||||
<div key={i} className="flex items-start gap-2 text-sm">
|
||||
<span className="text-primary mt-0.5">•</span>
|
||||
<span className="opacity-90">{insight}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Recommendations */}
|
||||
{briefingData.recommendations.length > 0 && (
|
||||
<div className="bg-primary/10 rounded-lg p-2 mt-auto">
|
||||
<div className="flex items-start gap-2">
|
||||
<LightbulbIcon />
|
||||
<div className="space-y-1">
|
||||
{briefingData.recommendations.map((rec, i) => (
|
||||
<p key={i} className="text-sm opacity-90">{rec}</p>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Footer with timestamp and refresh */}
|
||||
<div className="flex items-center justify-between pt-1 border-t border-base-200 mt-auto">
|
||||
<span className="text-[10px] opacity-50">
|
||||
Generated {timeFormatter.format(new Date(briefingData.generated_at))}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-xs btn-ghost"
|
||||
onClick={generateBriefing}
|
||||
disabled={isLoading}
|
||||
>
|
||||
Refresh
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,103 +0,0 @@
|
||||
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>
|
||||
)
|
||||
}
|
||||
@@ -1,178 +0,0 @@
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { wellnessApi } from '../../api'
|
||||
import { useWellnessState } from '../../hooks/useWellnessState'
|
||||
import type { WidgetConfig } from '../../hooks'
|
||||
import './widget-styles.css'
|
||||
|
||||
interface HealthStatsWidgetProps {
|
||||
config: WidgetConfig
|
||||
}
|
||||
|
||||
export default function HealthStatsWidget({ config }: HealthStatsWidgetProps) {
|
||||
const { healthDeviceId, city } = useWellnessState()
|
||||
|
||||
// Use device from config or shared state
|
||||
const deviceId = config.deviceIds[0] || healthDeviceId
|
||||
const widgetCity = (config.visualization as Record<string, unknown>)?.city as string || city
|
||||
|
||||
const { data, isLoading, error } = useQuery({
|
||||
queryKey: ['health-insights', deviceId, widgetCity],
|
||||
queryFn: async () => {
|
||||
if (!deviceId) {
|
||||
throw new Error('Device ID is required')
|
||||
}
|
||||
const response = await wellnessApi.getHealthInsights(deviceId, widgetCity || undefined)
|
||||
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) {
|
||||
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 text-xs">
|
||||
<span>
|
||||
{error instanceof Error ? error.message : 'Failed to load health data'}
|
||||
</span>
|
||||
</div>
|
||||
{!deviceId && (
|
||||
<p className="text-xs text-base-content/60 mt-2">Select a health device</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!data) return null
|
||||
|
||||
const StepIcon = () => (
|
||||
<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>
|
||||
)
|
||||
|
||||
const FireIcon = () => (
|
||||
<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="M17.657 18.657A8 8 0 016.343 7.343S7 9 9 10c0-2 .5-5 2.986-7C14 5 16.09 5.777 17.656 7.343A7.975 7.975 0 0120 13a7.975 7.975 0 01-2.343 5.657z" />
|
||||
</svg>
|
||||
)
|
||||
|
||||
const HeartIcon = () => (
|
||||
<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="M4.318 6.318a4.5 4.5 0 000 6.364L12 20.364l7.682-7.682a4.5 4.5 0 00-6.364-6.364L12 7.636l-1.318-1.318a4.5 4.5 0 00-6.364 0z" />
|
||||
</svg>
|
||||
)
|
||||
|
||||
const CheckIcon = () => (
|
||||
<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="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
)
|
||||
|
||||
const WarningIcon = () => (
|
||||
<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="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>
|
||||
)
|
||||
|
||||
const stats = [
|
||||
{
|
||||
label: 'Steps',
|
||||
value: data.health_metrics.steps,
|
||||
unit: '',
|
||||
icon: <StepIcon />,
|
||||
color: 'text-primary'
|
||||
},
|
||||
{
|
||||
label: 'Calories',
|
||||
value: data.health_metrics.active_calories,
|
||||
unit: 'kcal',
|
||||
icon: <FireIcon />,
|
||||
color: 'text-secondary'
|
||||
},
|
||||
{
|
||||
label: 'Heart Rate',
|
||||
value: data.health_metrics.heart_rate,
|
||||
unit: 'bpm',
|
||||
icon: <HeartIcon />,
|
||||
color: 'text-error'
|
||||
},
|
||||
]
|
||||
|
||||
// Find insights for each stat
|
||||
const getInsightForMetric = (metric: string) => {
|
||||
return data.insights.find(i => i.metric === metric)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="widget-card card bg-base-100 h-full flex flex-col">
|
||||
<div className="card-body p-3 flex-1 min-h-0">
|
||||
<h2 className="card-title text-sm mb-3">{config.title}</h2>
|
||||
|
||||
{/* Health Stats */}
|
||||
<div className="space-y-3">
|
||||
{stats.map((stat, i) => {
|
||||
const insight = getInsightForMetric(stat.label)
|
||||
const hasValue = stat.value !== null && stat.value !== undefined
|
||||
|
||||
return (
|
||||
<div key={i} className="space-y-1">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={stat.color}>{stat.icon}</span>
|
||||
<span className="text-sm opacity-70">{stat.label}</span>
|
||||
</div>
|
||||
<div className="flex items-baseline gap-1">
|
||||
<span className={`text-xl font-bold ${stat.color}`}>
|
||||
{hasValue ? Math.round(Number(stat.value)).toLocaleString() : '—'}
|
||||
</span>
|
||||
{hasValue && <span className="text-xs opacity-50">{stat.unit}</span>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Context/Correlation */}
|
||||
{insight && (
|
||||
<div className="text-xs space-y-0.5 ml-7">
|
||||
{insight.context && (
|
||||
<div className="opacity-70">{insight.context}</div>
|
||||
)}
|
||||
{insight.correlation && (
|
||||
<div className="text-warning opacity-80 flex items-center gap-1">
|
||||
<WarningIcon />
|
||||
{insight.correlation}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Recommendations */}
|
||||
{data.recommendations.length > 0 && (
|
||||
<div className="mt-2 pt-2 border-t border-base-300">
|
||||
<div className="text-xs">
|
||||
<div className="flex items-start gap-1.5">
|
||||
<span className="text-success"><CheckIcon /></span>
|
||||
<span className="opacity-80">{data.recommendations[0]}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,284 +0,0 @@
|
||||
import { useMemo, memo } 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
|
||||
}
|
||||
|
||||
// Optimized date formatter - cache formatters to avoid recreating
|
||||
const timeFormatter = new Intl.DateTimeFormat('en-US', {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
})
|
||||
|
||||
const dateTimeFormatter = new Intl.DateTimeFormat('en-US', {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
year: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
})
|
||||
|
||||
// Helper to format date efficiently
|
||||
function formatTime(date: Date): string {
|
||||
return timeFormatter.format(date)
|
||||
}
|
||||
|
||||
function formatDateTime(date: Date): string {
|
||||
return dateTimeFormatter.format(date)
|
||||
}
|
||||
|
||||
// Helper component to fetch data for a single metric
|
||||
function useMultiMetricData(deviceId: string, metricIds: string[], timeframe: WidgetConfig['timeframe']) {
|
||||
// Only fetch data for metrics that exist (up to 5)
|
||||
const metric1 = useTelemetrySeries({
|
||||
deviceId,
|
||||
metric: metricIds[0] || '',
|
||||
hours: timeframe.hours,
|
||||
startTime: timeframe.startTime,
|
||||
endTime: timeframe.endTime,
|
||||
limit: 500, // Limit data points for performance
|
||||
})
|
||||
|
||||
const metric2 = useTelemetrySeries({
|
||||
deviceId,
|
||||
metric: metricIds[1] || '',
|
||||
hours: timeframe.hours,
|
||||
startTime: timeframe.startTime,
|
||||
endTime: timeframe.endTime,
|
||||
limit: 500,
|
||||
enabled: metricIds.length > 1,
|
||||
})
|
||||
|
||||
const metric3 = useTelemetrySeries({
|
||||
deviceId,
|
||||
metric: metricIds[2] || '',
|
||||
hours: timeframe.hours,
|
||||
startTime: timeframe.startTime,
|
||||
endTime: timeframe.endTime,
|
||||
limit: 500,
|
||||
enabled: metricIds.length > 2,
|
||||
})
|
||||
|
||||
const metric4 = useTelemetrySeries({
|
||||
deviceId,
|
||||
metric: metricIds[3] || '',
|
||||
hours: timeframe.hours,
|
||||
startTime: timeframe.startTime,
|
||||
endTime: timeframe.endTime,
|
||||
limit: 500,
|
||||
enabled: metricIds.length > 3,
|
||||
})
|
||||
|
||||
const metric5 = useTelemetrySeries({
|
||||
deviceId,
|
||||
metric: metricIds[4] || '',
|
||||
hours: timeframe.hours,
|
||||
startTime: timeframe.startTime,
|
||||
endTime: timeframe.endTime,
|
||||
limit: 500,
|
||||
enabled: metricIds.length > 4,
|
||||
})
|
||||
|
||||
const queries = [metric1, metric2, metric3, metric4, metric5].slice(0, metricIds.length)
|
||||
|
||||
return { queries, metricIds }
|
||||
}
|
||||
|
||||
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 (optimized)
|
||||
const chartData = useMemo(() => {
|
||||
if (queries.length === 0 || !queries[0]?.data || queries[0].data.length === 0) return []
|
||||
|
||||
// Limit total data points for performance (max 300 points)
|
||||
const MAX_POINTS = 300
|
||||
const totalPoints = queries.reduce((sum, q) => sum + (q.data?.length || 0), 0)
|
||||
const shouldDownsample = totalPoints > MAX_POINTS
|
||||
const step = shouldDownsample ? Math.ceil(totalPoints / MAX_POINTS) : 1
|
||||
|
||||
// Create a map of timestamp -> data point (using timestamp as key for better performance)
|
||||
const timeMap = new Map<number, Record<string, number | string>>()
|
||||
|
||||
queries.forEach((query, index) => {
|
||||
const metric = metricIds[index]
|
||||
if (!query.data || query.data.length === 0) return
|
||||
|
||||
// Process data points efficiently (with downsampling if needed)
|
||||
query.data.forEach((point, pointIndex) => {
|
||||
// Skip points if downsampling
|
||||
if (shouldDownsample && pointIndex % step !== 0) return
|
||||
|
||||
const timestamp = new Date(point.time).getTime()
|
||||
|
||||
if (!timeMap.has(timestamp)) {
|
||||
const date = new Date(timestamp)
|
||||
timeMap.set(timestamp, {
|
||||
time: formatTime(date),
|
||||
fullDateTime: formatDateTime(date),
|
||||
timestamp
|
||||
})
|
||||
}
|
||||
|
||||
const entry = timeMap.get(timestamp)!
|
||||
entry[metric] = point.value
|
||||
})
|
||||
})
|
||||
|
||||
// Sort by timestamp and convert to array
|
||||
const result = Array.from(timeMap.values()).sort((a, b) => {
|
||||
return (a.timestamp as number) - (b.timestamp as number)
|
||||
})
|
||||
|
||||
return result
|
||||
}, [queries, metricIds])
|
||||
|
||||
// Memoize colors to avoid recreating array
|
||||
const colors = useMemo(() =>
|
||||
visualization?.colors || [
|
||||
'#3b82f6', // blue
|
||||
'#10b981', // green
|
||||
'#f59e0b', // amber
|
||||
'#ef4444', // red
|
||||
'#8b5cf6', // purple
|
||||
'#ec4899', // pink
|
||||
],
|
||||
[visualization?.colors]
|
||||
)
|
||||
|
||||
// Memoize lines to avoid recreating on every render
|
||||
const lines = useMemo(() =>
|
||||
metricIds.map((metric, index) => (
|
||||
<Line
|
||||
key={metric}
|
||||
type="monotone"
|
||||
dataKey={metric}
|
||||
stroke={colors[index % colors.length]}
|
||||
strokeWidth={2}
|
||||
dot={false} // Disable dots for better performance
|
||||
activeDot={{ r: 4 }}
|
||||
connectNulls={true}
|
||||
name={formatMetricName(metric)}
|
||||
isAnimationActive={false} // Disable animations for better performance
|
||||
/>
|
||||
)),
|
||||
[metricIds, colors]
|
||||
)
|
||||
|
||||
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 }}
|
||||
syncId="dashboard-charts" // Sync charts for better performance
|
||||
>
|
||||
{visualization?.showGrid !== false && (
|
||||
<CartesianGrid strokeDasharray="3 3" opacity={0.3} />
|
||||
)}
|
||||
<XAxis
|
||||
dataKey="time"
|
||||
tick={{ fontSize: 11 }}
|
||||
angle={-45}
|
||||
textAnchor="end"
|
||||
height={50}
|
||||
interval="preserveStartEnd" // Reduce number of ticks
|
||||
/>
|
||||
<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 />}
|
||||
{lines}
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// Memoize the component to prevent unnecessary re-renders
|
||||
export default memo(LineChartWidget)
|
||||
@@ -1,162 +0,0 @@
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { wellnessApi } from '../../api'
|
||||
import { useWellnessState } from '../../hooks/useWellnessState'
|
||||
import type { WidgetConfig } from '../../hooks'
|
||||
import './widget-styles.css'
|
||||
|
||||
const CheckIcon = () => (
|
||||
<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="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
)
|
||||
|
||||
const StepIcon = () => (
|
||||
<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 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>
|
||||
)
|
||||
|
||||
interface RunSuitabilityWidgetProps {
|
||||
config: WidgetConfig
|
||||
}
|
||||
|
||||
export default function RunSuitabilityWidget({ config }: RunSuitabilityWidgetProps) {
|
||||
const { healthDeviceId, city } = useWellnessState()
|
||||
|
||||
// Use device from config or shared state
|
||||
const deviceId = config.deviceIds[0] || healthDeviceId
|
||||
const widgetCity = (config.visualization as Record<string, unknown>)?.city as string || city
|
||||
|
||||
const { data, isLoading, error } = useQuery({
|
||||
queryKey: ['run-suitability', deviceId, widgetCity],
|
||||
queryFn: async () => {
|
||||
if (!deviceId || !widgetCity) {
|
||||
throw new Error('Device ID and city are required')
|
||||
}
|
||||
const response = await wellnessApi.getRunSuitability(deviceId, widgetCity)
|
||||
return response.data
|
||||
},
|
||||
refetchInterval: 300000, // Refresh every 5 minutes
|
||||
enabled: !!deviceId && !!widgetCity,
|
||||
})
|
||||
|
||||
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 flex-col items-center justify-center text-center gap-2">
|
||||
<div className="alert alert-error text-xs">
|
||||
<span>
|
||||
{error instanceof Error ? error.message : 'No data available'}
|
||||
</span>
|
||||
</div>
|
||||
{(!deviceId || !widgetCity) && (
|
||||
<p className="text-xs text-base-content/60">
|
||||
{!deviceId && 'Select a health device'}
|
||||
{!deviceId && !widgetCity && ' and '}
|
||||
{!widgetCity && 'Select a city'}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const getStatusColor = (status: string) => {
|
||||
switch (status) {
|
||||
case 'GO': return 'text-success'
|
||||
case 'MODERATE': return 'text-warning'
|
||||
case 'NO': return 'text-error'
|
||||
default: return 'text-base-content'
|
||||
}
|
||||
}
|
||||
|
||||
const getBgColor = (status: string) => {
|
||||
switch (status) {
|
||||
case 'GO': return 'bg-success/10 border-success/20'
|
||||
case 'MODERATE': return 'bg-warning/10 border-warning/20'
|
||||
case 'NO': return 'bg-error/10 border-error/20'
|
||||
default: return 'bg-base-200'
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="widget-card card bg-base-100 h-full flex flex-col">
|
||||
<div className="card-body p-3 flex flex-col gap-2 flex-1 min-h-0">
|
||||
<h2 className="card-title text-sm mb-2">{config.title}</h2>
|
||||
|
||||
{/* Status Badge */}
|
||||
<div className="flex flex-col items-center justify-center text-center gap-2">
|
||||
<div className={`w-20 h-20 rounded-full flex items-center justify-center border-4 ${getStatusColor(data.status)} ${getBgColor(data.status)}`}>
|
||||
<span className={`text-2xl font-black ${getStatusColor(data.status)}`}>
|
||||
{data.status}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Primary Reason */}
|
||||
<div className="space-y-1">
|
||||
<p className="font-medium text-sm">{data.primary_reason}</p>
|
||||
|
||||
{/* Score Breakdown */}
|
||||
<div className="flex gap-2 text-xs text-base-content/60 justify-center">
|
||||
<span>Weather: {data.scores.weather.toFixed(0)}</span>
|
||||
<span>•</span>
|
||||
<span>Air: {data.scores.air_quality.toFixed(0)}</span>
|
||||
<span>•</span>
|
||||
<span>Health: {data.scores.health.toFixed(0)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Detailed Insights */}
|
||||
{data.detailed_insights.length > 0 && (
|
||||
<div className="space-y-1 mt-1">
|
||||
<div className="text-xs font-semibold opacity-70">Conditions:</div>
|
||||
<ul className="text-xs space-y-0.5">
|
||||
{data.detailed_insights.slice(0, 2).map((insight, i) => (
|
||||
<li key={i} className="opacity-80">• {insight}</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Time Recommendations */}
|
||||
{data.time_recommendations.length > 0 && (
|
||||
<div className="space-y-1 mt-1">
|
||||
<div className="text-xs font-semibold opacity-70">Best Time:</div>
|
||||
<div className="text-xs opacity-80">• {data.time_recommendations[0]}</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Suggestions */}
|
||||
{data.suggestions.length > 0 && (
|
||||
<div className="mt-1 pt-1 border-t border-base-300">
|
||||
<div className="text-xs">
|
||||
<div className="flex items-start gap-1.5">
|
||||
<span className="text-success"><CheckIcon /></span>
|
||||
<span className="opacity-80">{data.suggestions[0]}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Quick Health Stats */}
|
||||
<div className="text-xs text-base-content/60 flex gap-2 justify-center pt-1 border-t border-base-300 flex-shrink-0 mt-auto">
|
||||
<span className="flex items-center gap-1">
|
||||
<StepIcon />
|
||||
{data.health_data.steps.toLocaleString()} steps
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,78 +0,0 @@
|
||||
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>
|
||||
)
|
||||
}
|
||||
@@ -1,222 +0,0 @@
|
||||
import type { ReactNode } from 'react'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import type { WidgetConfig } from '../../hooks'
|
||||
import { weatherApi } from '../../api'
|
||||
|
||||
type IconProps = {
|
||||
className?: string
|
||||
}
|
||||
|
||||
const IconBase = ({ className, children }: IconProps & { children: ReactNode }) => (
|
||||
<svg
|
||||
className={className}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth={1.7}
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
{children}
|
||||
</svg>
|
||||
)
|
||||
|
||||
const SunIcon = ({ className }: IconProps) => (
|
||||
<IconBase className={className}>
|
||||
<circle cx="12" cy="12" r="4" />
|
||||
<line x1="12" y1="2" x2="12" y2="5" />
|
||||
<line x1="12" y1="19" x2="12" y2="22" />
|
||||
<line x1="4.22" y1="4.22" x2="6.34" y2="6.34" />
|
||||
<line x1="17.66" y1="17.66" x2="19.78" y2="19.78" />
|
||||
<line x1="2" y1="12" x2="5" y2="12" />
|
||||
<line x1="19" y1="12" x2="22" y2="12" />
|
||||
<line x1="4.22" y1="19.78" x2="6.34" y2="17.66" />
|
||||
<line x1="17.66" y1="6.34" x2="19.78" y2="4.22" />
|
||||
</IconBase>
|
||||
)
|
||||
|
||||
const CloudIcon = ({ className }: IconProps) => (
|
||||
<IconBase className={className}>
|
||||
<path d="M5 15a4 4 0 010-8 5 5 0 019.7-.7A4 4 0 0118 15H5z" />
|
||||
</IconBase>
|
||||
)
|
||||
|
||||
const PartlyCloudyIcon = ({ className }: IconProps) => (
|
||||
<IconBase className={className}>
|
||||
<circle cx="8" cy="8" r="3" />
|
||||
<path d="M5 17a4 4 0 010-8 5 5 0 019.7-.7A4 4 0 0118 17H5z" />
|
||||
</IconBase>
|
||||
)
|
||||
|
||||
const FogIcon = ({ className }: IconProps) => (
|
||||
<IconBase className={className}>
|
||||
<path d="M4 10h11a3 3 0 000-6 4.5 4.5 0 00-8.91 1" />
|
||||
<line x1="3" y1="15" x2="17" y2="15" />
|
||||
<line x1="5" y1="19" x2="19" y2="19" />
|
||||
</IconBase>
|
||||
)
|
||||
|
||||
const RainIcon = ({ className }: IconProps) => (
|
||||
<IconBase className={className}>
|
||||
<path d="M5 15a4 4 0 010-8 5 5 0 019.7-.7A4 4 0 0118 15H5z" />
|
||||
<line x1="8" y1="17" x2="8" y2="21" />
|
||||
<line x1="12" y1="17" x2="12" y2="22" />
|
||||
<line x1="16" y1="17" x2="16" y2="21" />
|
||||
</IconBase>
|
||||
)
|
||||
|
||||
const SnowIcon = ({ className }: IconProps) => (
|
||||
<IconBase className={className}>
|
||||
<path d="M5 14a4 4 0 010-8 5 5 0 019.7-.7A4 4 0 0118 14H5z" />
|
||||
<line x1="11" y1="16" x2="11" y2="22" />
|
||||
<line x1="8.5" y1="18" x2="13.5" y2="20" />
|
||||
<line x1="8.5" y1="20" x2="13.5" y2="18" />
|
||||
</IconBase>
|
||||
)
|
||||
|
||||
const ThunderIcon = ({ className }: IconProps) => (
|
||||
<IconBase className={className}>
|
||||
<path d="M5 15a4 4 0 010-8 5 5 0 019.7-.7A4 4 0 0118 15H5z" />
|
||||
<polyline points="12 16 10 20 14 20 12 24" />
|
||||
</IconBase>
|
||||
)
|
||||
|
||||
const ThermometerIcon = ({ className }: IconProps) => (
|
||||
<IconBase className={className}>
|
||||
<path d="M14 14.5V5a2 2 0 00-4 0v9.5a3.5 3.5 0 104 0z" />
|
||||
<line x1="12" y1="8" x2="12" y2="11" />
|
||||
</IconBase>
|
||||
)
|
||||
|
||||
const DropletIcon = ({ className }: IconProps) => (
|
||||
<IconBase className={className}>
|
||||
<path d="M12 3.5s-4 5-4 8.5a4 4 0 108 0c0-3.5-4-8.5-4-8.5z" />
|
||||
</IconBase>
|
||||
)
|
||||
|
||||
const WindIcon = ({ className }: IconProps) => (
|
||||
<IconBase className={className}>
|
||||
<path d="M3 12h9a3 3 0 10-3-3" />
|
||||
<path d="M5 18h11a3 3 0 11-3 3" />
|
||||
</IconBase>
|
||||
)
|
||||
|
||||
const CloudCoverIcon = ({ className }: IconProps) => (
|
||||
<IconBase className={className}>
|
||||
<path d="M6 17a4 4 0 010-8 5 5 0 019.7-.7A4 4 0 0119 17H6z" />
|
||||
</IconBase>
|
||||
)
|
||||
|
||||
const RainDropIcon = ({ className }: IconProps) => (
|
||||
<IconBase className={className}>
|
||||
<path d="M7 14a5 5 0 0010 0c0-4-5-9-5-9s-5 5-5 9z" />
|
||||
</IconBase>
|
||||
)
|
||||
|
||||
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
|
||||
|
||||
const getWeatherIcon = (code: number) => {
|
||||
if (code === 0 || code === 1) return <SunIcon className="w-16 h-16 text-warning" />
|
||||
if (code === 2) return <PartlyCloudyIcon className="w-16 h-16 text-primary" />
|
||||
if (code === 3) return <CloudIcon className="w-16 h-16 text-primary" />
|
||||
if (code >= 45 && code <= 48) return <FogIcon className="w-16 h-16 text-primary" />
|
||||
if (code >= 51 && code <= 55) return <RainIcon className="w-16 h-16 text-primary" />
|
||||
if (code >= 61 && code <= 65) return <RainIcon className="w-16 h-16 text-primary" />
|
||||
if (code >= 71 && code <= 77) return <SnowIcon className="w-16 h-16 text-primary" />
|
||||
if (code >= 80 && code <= 82) return <RainIcon className="w-16 h-16 text-primary" />
|
||||
if (code >= 85 && code <= 86) return <SnowIcon className="w-16 h-16 text-primary" />
|
||||
if (code >= 95) return <ThunderIcon className="w-16 h-16 text-primary" />
|
||||
return <ThermometerIcon className="w-16 h-16 text-primary" />
|
||||
}
|
||||
|
||||
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="mb-1 text-primary">{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">
|
||||
<DropletIcon className="w-4 h-4 opacity-70" />
|
||||
<span>{weather.humidity}%</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<WindIcon className="w-4 h-4 opacity-70" />
|
||||
<span>{weather.wind_speed.toFixed(1)} km/h</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<CloudCoverIcon className="w-4 h-4 opacity-70" />
|
||||
<span>{weather.cloud_cover}%</span>
|
||||
</div>
|
||||
{weather.precipitation > 0 && (
|
||||
<div className="flex items-center gap-2">
|
||||
<RainDropIcon className="w-4 h-4 opacity-70" />
|
||||
<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>
|
||||
)
|
||||
}
|
||||
@@ -1,132 +0,0 @@
|
||||
import { memo, useEffect, useRef } from 'react'
|
||||
import type { WidgetConfig } from '../../hooks'
|
||||
import { widgetRegistry } from './registry'
|
||||
|
||||
interface WidgetContainerProps {
|
||||
config: WidgetConfig
|
||||
onRemove?: (id: string) => void
|
||||
onEdit?: (id: string) => void
|
||||
onHeightChange?: (height: number) => void
|
||||
}
|
||||
|
||||
function WidgetContainer({ config, onRemove, onEdit, onHeightChange }: WidgetContainerProps) {
|
||||
const WidgetComponent = widgetRegistry[config.type]
|
||||
const contentRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (!onHeightChange || !contentRef.current) return
|
||||
|
||||
const node = contentRef.current
|
||||
|
||||
const emitHeight = () => {
|
||||
onHeightChange(node.scrollHeight)
|
||||
}
|
||||
|
||||
emitHeight()
|
||||
|
||||
const resizeObserver = new ResizeObserver(() => {
|
||||
emitHeight()
|
||||
})
|
||||
|
||||
resizeObserver.observe(node)
|
||||
|
||||
return () => {
|
||||
resizeObserver.disconnect()
|
||||
}
|
||||
}, [onHeightChange, config.id])
|
||||
|
||||
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 w-full">
|
||||
<div className="absolute top-2 left-2 right-2 z-20 flex items-center justify-between opacity-0 group-hover:opacity-100 transition-opacity pointer-events-none">
|
||||
<div className="drag-handle cursor-move flex items-center gap-1 px-2 py-1 rounded bg-base-100 shadow-md text-xs border border-base-300 pointer-events-auto">
|
||||
<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 pointer-events-auto">
|
||||
{onEdit && (
|
||||
<button
|
||||
type="button"
|
||||
className="btn btn-xs btn-circle btn-ghost bg-base-100 shadow-md border border-base-300 hover:bg-base-200"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
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
|
||||
type="button"
|
||||
className="btn btn-xs btn-circle btn-ghost bg-base-100 shadow-md border border-base-300 hover:bg-error hover:text-error-content"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
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>
|
||||
|
||||
{/* Allow overlay to float without reserving layout space */}
|
||||
<div className="w-full" ref={contentRef}>
|
||||
<WidgetComponent config={config} />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default memo(WidgetContainer)
|
||||
@@ -1,7 +0,0 @@
|
||||
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 { default as CalendarWidget } from './CalendarWidget'
|
||||
export { widgetRegistry } from './registry'
|
||||
@@ -1,33 +0,0 @@
|
||||
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'
|
||||
import RunSuitabilityWidget from './RunSuitabilityWidget'
|
||||
import HealthStatsWidget from './HealthStatsWidget'
|
||||
import CalendarWidget from './CalendarWidget'
|
||||
import DailyBriefingWidget from './DailyBriefingWidget'
|
||||
|
||||
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,
|
||||
'run-suitability': RunSuitabilityWidget,
|
||||
'health-stats': HealthStatsWidget,
|
||||
'calendar': CalendarWidget,
|
||||
'daily-briefing': DailyBriefingWidget,
|
||||
}
|
||||
@@ -1,58 +0,0 @@
|
||||
/* Widget-specific styling overrides */
|
||||
|
||||
/* Reduce card body padding for widgets - default is 2rem (32px), we want less */
|
||||
.widget-card .card-body {
|
||||
padding: 0.5rem; /* 8px - can be overridden by inline p-2 class */
|
||||
}
|
||||
|
||||
/* 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 and align to grid */
|
||||
.widget-card {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.widget-card .card-body {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0; /* Allow flex children to shrink */
|
||||
}
|
||||
|
||||
/* Gridstack alignment */
|
||||
.grid-stack-item {
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.grid-stack-item-content {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
/* Gridstack drag handle */
|
||||
.grid-stack-item .drag-handle {
|
||||
cursor: move;
|
||||
pointer-events: auto !important;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
/* Ensure gridstack items are interactive */
|
||||
.grid-stack-item {
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
/* Gridstack resize handles */
|
||||
.grid-stack-item .ui-resizable-handle {
|
||||
pointer-events: auto !important;
|
||||
z-index: 20;
|
||||
}
|
||||
@@ -1,50 +0,0 @@
|
||||
import { createContext, useContext, useState, useEffect, type ReactNode } from 'react'
|
||||
import { apiClient } from '../lib/api-client'
|
||||
|
||||
interface AuthContextType {
|
||||
isAuthenticated: boolean
|
||||
login: (username: string, password: string) => Promise<void>
|
||||
logout: () => void
|
||||
}
|
||||
|
||||
const AuthContext = createContext<AuthContextType | undefined>(undefined)
|
||||
|
||||
export function AuthProvider({ children }: { children: ReactNode }) {
|
||||
const [token, setToken] = useState<string | null>(
|
||||
() => localStorage.getItem('access_token')
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
if (token) {
|
||||
apiClient.defaults.headers.common['Authorization'] = `Bearer ${token}`
|
||||
} else {
|
||||
delete apiClient.defaults.headers.common['Authorization']
|
||||
}
|
||||
}, [token])
|
||||
|
||||
const login = async (username: string, password: string) => {
|
||||
const response = await apiClient.post('/auth/login/', { username, password })
|
||||
const { access, refresh } = response.data
|
||||
localStorage.setItem('access_token', access)
|
||||
localStorage.setItem('refresh_token', refresh)
|
||||
setToken(access)
|
||||
}
|
||||
|
||||
const logout = () => {
|
||||
localStorage.removeItem('access_token')
|
||||
localStorage.removeItem('refresh_token')
|
||||
setToken(null)
|
||||
}
|
||||
|
||||
return (
|
||||
<AuthContext.Provider value={{ isAuthenticated: !!token, login, logout }}>
|
||||
{children}
|
||||
</AuthContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
export const useAuth = () => {
|
||||
const context = useContext(AuthContext)
|
||||
if (!context) throw new Error('useAuth must be used within AuthProvider')
|
||||
return context
|
||||
}
|
||||
@@ -1,4 +0,0 @@
|
||||
export { useTelemetrySeries } from './useTelemetrySeries'
|
||||
export { useDeviceMetrics, useAllMetrics } from './useDeviceMetrics'
|
||||
export { useDashboardConfig } from './useDashboardConfig'
|
||||
export type { WidgetConfig, DashboardConfig, WidgetType } from './useDashboardConfig'
|
||||
@@ -1,220 +0,0 @@
|
||||
import { useState, useEffect, useRef } from 'react'
|
||||
import { dashboardLayoutApi } from '../api'
|
||||
|
||||
export type WidgetType = 'line-chart' | 'gauge' | 'stat' | 'ai-insight' | 'bar-chart' | 'air-quality' | 'weather' | 'comfort-index' | 'run-suitability' | 'health-stats' | 'calendar' | 'daily-briefing'
|
||||
|
||||
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
|
||||
}
|
||||
calendar?: {
|
||||
icalUrl: string
|
||||
timeRangeHours?: number
|
||||
}
|
||||
briefing?: {
|
||||
briefingType: 'schedule' | 'environment' | 'full'
|
||||
city: string
|
||||
healthDeviceId?: string
|
||||
calendarUrl?: string
|
||||
calendarRangeHours?: number
|
||||
}
|
||||
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 backend sync and localStorage fallback
|
||||
* Single-user system: No authentication required
|
||||
*/
|
||||
export function useDashboardConfig() {
|
||||
const [config, setConfig] = useState<DashboardConfig>(DEFAULT_CONFIG)
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
const [layoutId, setLayoutId] = useState<number | null>(null)
|
||||
const isInitialLoadRef = useRef(true)
|
||||
|
||||
// Load config from backend or localStorage
|
||||
useEffect(() => {
|
||||
const loadConfig = async () => {
|
||||
setIsLoading(true)
|
||||
try {
|
||||
// Try to load from backend
|
||||
try {
|
||||
const response = await dashboardLayoutApi.getDefault()
|
||||
const layout = response.data
|
||||
setConfig(layout.config)
|
||||
setLayoutId(layout.id)
|
||||
} catch (error: any) {
|
||||
// No default layout found, try to create one or use localStorage fallback
|
||||
console.log('No default layout found, using localStorage or creating new')
|
||||
const stored = localStorage.getItem(STORAGE_KEY)
|
||||
if (stored) {
|
||||
const parsed = JSON.parse(stored) as DashboardConfig
|
||||
setConfig(parsed)
|
||||
// Save to backend
|
||||
try {
|
||||
const response = await dashboardLayoutApi.create({
|
||||
name: 'default',
|
||||
config: parsed,
|
||||
is_default: true,
|
||||
})
|
||||
setLayoutId(response.data.id)
|
||||
} catch (err) {
|
||||
console.error('Failed to save to backend:', err)
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load dashboard config:', error)
|
||||
// Fallback to localStorage
|
||||
try {
|
||||
const stored = localStorage.getItem(STORAGE_KEY)
|
||||
if (stored) {
|
||||
setConfig(JSON.parse(stored) as DashboardConfig)
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to load from localStorage:', e)
|
||||
}
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
isInitialLoadRef.current = false
|
||||
}
|
||||
}
|
||||
|
||||
loadConfig()
|
||||
}, [])
|
||||
|
||||
const saveConfig = async () => {
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(config))
|
||||
} catch (error) {
|
||||
console.error('Failed to save to localStorage:', error)
|
||||
}
|
||||
|
||||
// Save to backend
|
||||
try {
|
||||
if (layoutId) {
|
||||
// Update existing layout
|
||||
await dashboardLayoutApi.update(layoutId, {
|
||||
config: config,
|
||||
})
|
||||
} else {
|
||||
// Create new layout
|
||||
const response = await dashboardLayoutApi.create({
|
||||
name: 'default',
|
||||
config: config,
|
||||
is_default: true,
|
||||
})
|
||||
setLayoutId(response.data.id)
|
||||
}
|
||||
return true
|
||||
} catch (error) {
|
||||
console.error('Failed to save to backend:', error)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// Only save to localStorage automatically (no backend saves)
|
||||
useEffect(() => {
|
||||
if (!isInitialLoadRef.current) {
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(config))
|
||||
} catch (error) {
|
||||
console.error('Failed to save to localStorage:', 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,
|
||||
saveConfig,
|
||||
isLoading,
|
||||
}
|
||||
}
|
||||
@@ -1,54 +0,0 @@
|
||||
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,
|
||||
}
|
||||
}
|
||||
@@ -1,155 +0,0 @@
|
||||
import { useEffect, useRef, useCallback } from 'react'
|
||||
import { GridStack } from 'gridstack'
|
||||
import 'gridstack/dist/gridstack.min.css'
|
||||
|
||||
// Define the widget type based on gridstack.js structure
|
||||
export type GridStackWidget = {
|
||||
id?: string
|
||||
x?: number
|
||||
y?: number
|
||||
w?: number
|
||||
h?: number
|
||||
minW?: number
|
||||
minH?: number
|
||||
maxW?: number
|
||||
maxH?: number
|
||||
noResize?: boolean
|
||||
noMove?: boolean
|
||||
locked?: boolean
|
||||
[key: string]: any // Allow additional properties
|
||||
}
|
||||
|
||||
interface UseGridstackOptions {
|
||||
columns?: number
|
||||
cellHeight?: number
|
||||
margin?: number
|
||||
minRow?: number
|
||||
onLayoutChange?: (items: GridStackWidget[]) => void
|
||||
disableResize?: boolean
|
||||
disableDrag?: boolean
|
||||
}
|
||||
|
||||
export function useGridstack(options: UseGridstackOptions = {}) {
|
||||
const gridRef = useRef<HTMLDivElement>(null)
|
||||
const gridInstanceRef = useRef<GridStack | null>(null)
|
||||
const {
|
||||
columns = 4,
|
||||
cellHeight = 150,
|
||||
margin = 12,
|
||||
minRow = 1,
|
||||
onLayoutChange,
|
||||
disableResize = false,
|
||||
disableDrag = false,
|
||||
} = options
|
||||
|
||||
// Initialize gridstack
|
||||
useEffect(() => {
|
||||
if (!gridRef.current) return
|
||||
|
||||
// Clean up existing instance
|
||||
if (gridInstanceRef.current) {
|
||||
gridInstanceRef.current.destroy(false)
|
||||
}
|
||||
|
||||
// Create new gridstack instance
|
||||
// Gridstack will automatically detect and manage elements with data-gs-* attributes
|
||||
const grid = GridStack.init(
|
||||
{
|
||||
column: columns,
|
||||
cellHeight,
|
||||
margin,
|
||||
minRow,
|
||||
resizable: {
|
||||
handles: 'e, se, s, sw, w',
|
||||
},
|
||||
disableResize,
|
||||
disableDrag,
|
||||
float: false,
|
||||
animate: true,
|
||||
acceptWidgets: false,
|
||||
// Removed handle option - entire widget is draggable for better UX
|
||||
},
|
||||
gridRef.current
|
||||
)
|
||||
|
||||
// Handle layout change
|
||||
if (onLayoutChange) {
|
||||
grid.on('change', () => {
|
||||
const serialized = grid.save(false) as GridStackWidget[]
|
||||
onLayoutChange(serialized)
|
||||
})
|
||||
}
|
||||
|
||||
gridInstanceRef.current = grid
|
||||
|
||||
return () => {
|
||||
if (gridInstanceRef.current) {
|
||||
gridInstanceRef.current.destroy(false)
|
||||
gridInstanceRef.current = null
|
||||
}
|
||||
}
|
||||
}, [columns, cellHeight, margin, minRow, disableResize, disableDrag, onLayoutChange])
|
||||
|
||||
// Convert existing elements to gridstack widgets
|
||||
const makeWidgets = useCallback(() => {
|
||||
if (gridInstanceRef.current && gridRef.current) {
|
||||
const items = gridRef.current.querySelectorAll('.grid-stack-item:not(.ui-draggable)')
|
||||
items.forEach((item) => {
|
||||
gridInstanceRef.current!.makeWidget(item as HTMLElement)
|
||||
})
|
||||
}
|
||||
}, [])
|
||||
|
||||
// Load items into grid
|
||||
const loadItems = useCallback((items: GridStackWidget[]) => {
|
||||
if (gridInstanceRef.current) {
|
||||
gridInstanceRef.current.load(items)
|
||||
}
|
||||
}, [])
|
||||
|
||||
// Add item to grid
|
||||
const addItem = useCallback((item: GridStackWidget) => {
|
||||
if (gridInstanceRef.current) {
|
||||
gridInstanceRef.current.addWidget(item)
|
||||
}
|
||||
}, [])
|
||||
|
||||
// Remove item from grid
|
||||
const removeItem = useCallback((id: string) => {
|
||||
if (gridInstanceRef.current) {
|
||||
const el = gridInstanceRef.current.el.querySelector(`[gs-id="${id}"]`)
|
||||
if (el) {
|
||||
gridInstanceRef.current.removeWidget(el as HTMLElement, false)
|
||||
}
|
||||
}
|
||||
}, [])
|
||||
|
||||
// Update item
|
||||
const updateItem = useCallback((id: string, updates: Partial<GridStackWidget>) => {
|
||||
if (gridInstanceRef.current) {
|
||||
const el = gridInstanceRef.current.el.querySelector(`[gs-id="${id}"]`)
|
||||
if (el) {
|
||||
gridInstanceRef.current.update(el as HTMLElement, updates)
|
||||
}
|
||||
}
|
||||
}, [])
|
||||
|
||||
// Get current layout
|
||||
const getLayout = useCallback((): GridStackWidget[] => {
|
||||
if (gridInstanceRef.current) {
|
||||
return gridInstanceRef.current.save(false) as GridStackWidget[]
|
||||
}
|
||||
return []
|
||||
}, [])
|
||||
|
||||
return {
|
||||
gridRef,
|
||||
makeWidgets,
|
||||
loadItems,
|
||||
addItem,
|
||||
removeItem,
|
||||
updateItem,
|
||||
getLayout,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,76 +0,0 @@
|
||||
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: enabled ? 60000 : false, // Refresh every 60 seconds (reduced frequency)
|
||||
staleTime: 30000, // Consider data fresh for 30 seconds
|
||||
})
|
||||
|
||||
return {
|
||||
data: data || [],
|
||||
isLoading,
|
||||
isFetching,
|
||||
error: error as Error | null,
|
||||
refetch,
|
||||
}
|
||||
}
|
||||
@@ -1,40 +0,0 @@
|
||||
import { createContext, useContext, useState, type ReactNode } from 'react'
|
||||
|
||||
interface WellnessState {
|
||||
healthDeviceId: string | null
|
||||
city: string
|
||||
setHealthDeviceId: (id: string | null) => void
|
||||
setCity: (city: string) => void
|
||||
}
|
||||
|
||||
const WellnessStateContext = createContext<WellnessState | undefined>(undefined)
|
||||
|
||||
export function WellnessStateProvider({ children }: { children: ReactNode }) {
|
||||
const [healthDeviceId, setHealthDeviceId] = useState<string | null>(null)
|
||||
const [city, setCity] = useState<string>('Skopje')
|
||||
|
||||
return (
|
||||
<WellnessStateContext.Provider
|
||||
value={{
|
||||
healthDeviceId,
|
||||
city,
|
||||
setHealthDeviceId,
|
||||
setCity,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</WellnessStateContext.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
export function useWellnessState() {
|
||||
const context = useContext(WellnessStateContext)
|
||||
if (context === undefined) {
|
||||
throw new Error('useWellnessState must be used within WellnessStateProvider')
|
||||
}
|
||||
return context
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -1,126 +0,0 @@
|
||||
@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;
|
||||
}
|
||||
}
|
||||
@@ -1,58 +0,0 @@
|
||||
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',
|
||||
},
|
||||
});
|
||||
|
||||
// Add token from localStorage on initialization
|
||||
const token = localStorage.getItem('access_token');
|
||||
if (token) {
|
||||
apiClient.defaults.headers.common['Authorization'] = `Bearer ${token}`;
|
||||
}
|
||||
|
||||
// Add response interceptor for token refresh on 401
|
||||
apiClient.interceptors.response.use(
|
||||
(response) => response,
|
||||
async (error) => {
|
||||
const originalRequest = error.config;
|
||||
|
||||
// Handle 401 errors with token refresh
|
||||
if (error.response?.status === 401 && !originalRequest._retry) {
|
||||
originalRequest._retry = true;
|
||||
|
||||
try {
|
||||
const refreshToken = localStorage.getItem('refresh_token');
|
||||
if (!refreshToken) {
|
||||
throw new Error('No refresh token');
|
||||
}
|
||||
|
||||
const response = await axios.post(`${API_BASE_URL}/auth/refresh/`, {
|
||||
refresh: refreshToken
|
||||
});
|
||||
|
||||
const { access } = response.data;
|
||||
localStorage.setItem('access_token', access);
|
||||
apiClient.defaults.headers.common['Authorization'] = `Bearer ${access}`;
|
||||
originalRequest.headers['Authorization'] = `Bearer ${access}`;
|
||||
|
||||
return apiClient(originalRequest);
|
||||
} catch (refreshError) {
|
||||
// Refresh failed - clear tokens and redirect to login
|
||||
localStorage.removeItem('access_token');
|
||||
localStorage.removeItem('refresh_token');
|
||||
window.location.href = '/login';
|
||||
return Promise.reject(refreshError);
|
||||
}
|
||||
}
|
||||
|
||||
return Promise.reject(error);
|
||||
}
|
||||
);
|
||||
|
||||
export default apiClient;
|
||||
@@ -1,29 +0,0 @@
|
||||
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)
|
||||
}
|
||||
@@ -1,10 +0,0 @@
|
||||
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>,
|
||||
)
|
||||
@@ -1,160 +0,0 @@
|
||||
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>
|
||||
)
|
||||
}
|
||||
@@ -1,284 +0,0 @@
|
||||
import { useState, useRef, useEffect, useCallback } 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'
|
||||
|
||||
const GRID_COLUMNS = 5
|
||||
const GRID_MARGIN: [number, number] = [8, 6]
|
||||
const ROW_HEIGHT = 90
|
||||
const HEIGHT_PADDING = 0
|
||||
const ROW_UNIT = ROW_HEIGHT + GRID_MARGIN[1]
|
||||
const MAX_AUTO_ROWS = 6
|
||||
|
||||
export default function Dashboard() {
|
||||
const { config, addWidget, removeWidget, updateWidget, exportConfig, importConfig, saveConfig } = useDashboardConfig()
|
||||
const [isModalOpen, setIsModalOpen] = useState(false)
|
||||
const [editingWidget, setEditingWidget] = useState<string | null>(null)
|
||||
const [isSaving, setIsSaving] = useState(false)
|
||||
const [saveStatus, setSaveStatus] = useState<'idle' | 'success' | 'error'>('idle')
|
||||
const [gridWidth, setGridWidth] = useState(() => {
|
||||
if (typeof window !== 'undefined') {
|
||||
return window.innerWidth
|
||||
}
|
||||
return GRID_COLUMNS * (ROW_HEIGHT + GRID_MARGIN[0])
|
||||
})
|
||||
const gridContainerRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
// Update grid width on resize
|
||||
useEffect(() => {
|
||||
const updateWidth = () => {
|
||||
if (gridContainerRef.current) {
|
||||
const rect = gridContainerRef.current.getBoundingClientRect()
|
||||
setGridWidth(rect.width)
|
||||
} else if (typeof window !== 'undefined') {
|
||||
setGridWidth(window.innerWidth)
|
||||
}
|
||||
}
|
||||
|
||||
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) => {
|
||||
const position = widget.position ?? { x: 0, y: Infinity, w: 1, h: 1 }
|
||||
return {
|
||||
i: widget.id,
|
||||
x: position.x ?? 0,
|
||||
y: position.y ?? Infinity,
|
||||
w: Math.max(position.w ?? 1, 1),
|
||||
h: Math.max(position.h ?? 1, 1),
|
||||
minW: 1,
|
||||
minH: 1,
|
||||
maxW: GRID_COLUMNS,
|
||||
}
|
||||
})
|
||||
|
||||
const handleWidgetHeightChange = useCallback(
|
||||
(widgetId: string, contentHeight: number) => {
|
||||
const widget = config.widgets.find((w) => w.id === widgetId)
|
||||
if (!widget) return
|
||||
|
||||
const position = widget.position ?? { x: 0, y: Infinity, w: 1, h: 1 }
|
||||
const currentRows = Math.max(position.h ?? 1, 1)
|
||||
const desiredPixelHeight = contentHeight + HEIGHT_PADDING
|
||||
const targetRows = Math.min(
|
||||
MAX_AUTO_ROWS,
|
||||
Math.max(1, Math.ceil(desiredPixelHeight / ROW_UNIT))
|
||||
)
|
||||
|
||||
if (Math.abs(targetRows - currentRows) >= 1) {
|
||||
updateWidget(widgetId, {
|
||||
position: {
|
||||
...position,
|
||||
h: targetRows,
|
||||
},
|
||||
})
|
||||
}
|
||||
},
|
||||
[config.widgets, updateWidget]
|
||||
)
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
const handleSaveDashboard = async () => {
|
||||
setIsSaving(true)
|
||||
setSaveStatus('idle')
|
||||
try {
|
||||
const success = await saveConfig()
|
||||
setSaveStatus(success ? 'success' : 'error')
|
||||
} catch (error) {
|
||||
console.error('Failed to save dashboard configuration:', error)
|
||||
setSaveStatus('error')
|
||||
} finally {
|
||||
setIsSaving(false)
|
||||
setTimeout(() => setSaveStatus('idle'), 3000)
|
||||
}
|
||||
}
|
||||
|
||||
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 flex-wrap items-center gap-2">
|
||||
<button
|
||||
className="btn btn-success btn-sm"
|
||||
onClick={handleSaveDashboard}
|
||||
disabled={isSaving}
|
||||
>
|
||||
{isSaving ? (
|
||||
<svg className="h-4 w-4 animate-spin" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
|
||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v4a4 4 0 00-4 4H4z" />
|
||||
</svg>
|
||||
) : (
|
||||
<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="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
)}
|
||||
Save Dashboard
|
||||
</button>
|
||||
{saveStatus === 'success' && (
|
||||
<span className="text-success text-sm">Saved!</span>
|
||||
)}
|
||||
{saveStatus === 'error' && (
|
||||
<span className="text-error text-sm">Save failed</span>
|
||||
)}
|
||||
<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={GRID_COLUMNS}
|
||||
rowHeight={ROW_HEIGHT}
|
||||
width={gridWidth}
|
||||
onLayoutChange={handleLayoutChange}
|
||||
draggableHandle=".drag-handle"
|
||||
compactType="vertical"
|
||||
preventCollision={false}
|
||||
isResizable={true}
|
||||
isDraggable={true}
|
||||
margin={GRID_MARGIN}
|
||||
containerPadding={[0, 0]}
|
||||
>
|
||||
{config.widgets.map((widget) => (
|
||||
<div key={widget.id} className="h-full">
|
||||
<WidgetContainer
|
||||
config={widget}
|
||||
onRemove={() => removeWidget(widget.id)}
|
||||
onEdit={() => setEditingWidget(widget.id)}
|
||||
onHeightChange={(height: number) => handleWidgetHeightChange(widget.id, height)}
|
||||
/>
|
||||
</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>
|
||||
)
|
||||
}
|
||||
@@ -1,187 +0,0 @@
|
||||
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>
|
||||
)
|
||||
}
|
||||
@@ -1,280 +0,0 @@
|
||||
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>
|
||||
)
|
||||
}
|
||||
@@ -1,71 +0,0 @@
|
||||
import { useState } from 'react'
|
||||
import { useNavigate } from 'react-router-dom'
|
||||
import { useAuth } from '../contexts/AuthContext'
|
||||
|
||||
export default function Login() {
|
||||
const [username, setUsername] = useState('')
|
||||
const [password, setPassword] = useState('')
|
||||
const [error, setError] = useState('')
|
||||
const [loading, setLoading] = useState(false)
|
||||
const { login } = useAuth()
|
||||
const navigate = useNavigate()
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
setError('')
|
||||
setLoading(true)
|
||||
try {
|
||||
await login(username, password)
|
||||
navigate('/dashboard')
|
||||
} catch (err: any) {
|
||||
setError(err.response?.data?.detail || 'Invalid credentials')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen flex items-center justify-center bg-base-200">
|
||||
<div className="card w-96 bg-base-100 shadow-xl">
|
||||
<div className="card-body">
|
||||
<h2 className="card-title text-2xl mb-4">IoT Dashboard Login</h2>
|
||||
{error && <div className="alert alert-error text-sm">{error}</div>}
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div className="form-control">
|
||||
<label className="label">
|
||||
<span className="label-text">Username</span>
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
className="input input-bordered"
|
||||
value={username}
|
||||
onChange={(e) => setUsername(e.target.value)}
|
||||
required
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
<div className="form-control">
|
||||
<label className="label">
|
||||
<span className="label-text">Password</span>
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
className="input input-bordered"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<button
|
||||
type="submit"
|
||||
className="btn btn-primary w-full"
|
||||
disabled={loading}
|
||||
>
|
||||
{loading ? <span className="loading loading-spinner" /> : 'Login'}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,66 +0,0 @@
|
||||
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[];
|
||||
}[];
|
||||
}
|
||||
@@ -1,64 +0,0 @@
|
||||
/**
|
||||
* 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
|
||||
}
|
||||
}
|
||||
@@ -1,28 +0,0 @@
|
||||
{
|
||||
"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"]
|
||||
}
|
||||
@@ -1,7 +0,0 @@
|
||||
{
|
||||
"files": [],
|
||||
"references": [
|
||||
{ "path": "./tsconfig.app.json" },
|
||||
{ "path": "./tsconfig.node.json" }
|
||||
]
|
||||
}
|
||||
@@ -1,26 +0,0 @@
|
||||
{
|
||||
"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"]
|
||||
}
|
||||
@@ -1,23 +0,0 @@
|
||||
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
Normal file
42
gpt.py
Normal file
@@ -0,0 +1,42 @@
|
||||
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)
|
||||
5
infrastructure/.gitignore
vendored
5
infrastructure/.gitignore
vendored
@@ -1,5 +0,0 @@
|
||||
mosquitto/certs/
|
||||
mosquitto/data/
|
||||
mosquitto/logs/
|
||||
.env
|
||||
.env*
|
||||
@@ -1,68 +0,0 @@
|
||||
services:
|
||||
|
||||
redis:
|
||||
image: redis:8
|
||||
ports:
|
||||
- "6379:6379"
|
||||
volumes:
|
||||
- redis-data:/data
|
||||
restart: unless-stopped
|
||||
|
||||
mosquitto:
|
||||
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
|
||||
|
||||
db-write:
|
||||
build:
|
||||
context: ../services/db_write
|
||||
env_file:
|
||||
- .env.dbw
|
||||
depends_on:
|
||||
- timescaledb
|
||||
- redis
|
||||
- mqtt-ingestion
|
||||
restart:
|
||||
unless-stopped
|
||||
|
||||
mqtt-ingestion:
|
||||
build:
|
||||
context: ../services/mqtt_ingestion
|
||||
env_file:
|
||||
- .env.mqi
|
||||
depends_on:
|
||||
- mosquitto
|
||||
- redis
|
||||
restart:
|
||||
unless-stopped
|
||||
|
||||
# gpt-service:
|
||||
# build:
|
||||
# context: ./services/gpt_service
|
||||
# ports:
|
||||
# - "8001:8001"
|
||||
# restart: unless-stopped
|
||||
|
||||
|
||||
|
||||
|
||||
volumes:
|
||||
redis-data:
|
||||
timescaledb-data:
|
||||
@@ -1,32 +0,0 @@
|
||||
# Persistence
|
||||
persistence true
|
||||
persistence_location /mosquitto/data/
|
||||
|
||||
# Logging
|
||||
#log_dest file /mosquitto/log/mosquitto.log
|
||||
|
||||
# Standard MQTT listener (for testing without certs)
|
||||
listener 1883
|
||||
allow_anonymous true
|
||||
|
||||
# mTLS listener (requires client certificates)
|
||||
listener 8883
|
||||
allow_anonymous true
|
||||
protocol mqtt
|
||||
|
||||
# Server certificates (mosquitto's identity)
|
||||
certfile /mosquitto/certs/server.crt
|
||||
keyfile /mosquitto/certs/server.key
|
||||
|
||||
# CA certificate to verify client certificates
|
||||
cafile /mosquitto/certs/ca.crt
|
||||
|
||||
# CRL file
|
||||
crlfile /mosquitto/certs/ca.crl
|
||||
|
||||
# Certificate-based authentication
|
||||
require_certificate true
|
||||
use_identity_as_username true
|
||||
|
||||
# TLS version restrictions
|
||||
tls_version tlsv1.2
|
||||
@@ -1,7 +1,6 @@
|
||||
PASSWORD='postgres'
|
||||
SECRET_KEY='django_key'
|
||||
CONNECTION_STRING='postgres://postgres:postgres@localhost:5555/example'
|
||||
|
||||
WEATHER_API_KEY=your_weather_api_key_here
|
||||
DEVICE_MANAGER_URL=http://localhost:8000
|
||||
|
||||
REDIS_HOST="localhost"
|
||||
MQTT_BROKER="localhost"
|
||||
OPENAI_API_KEY="example"
|
||||
@@ -1 +0,0 @@
|
||||
"""REST API for IoT Dashboard."""
|
||||
@@ -1,88 +0,0 @@
|
||||
"""DRF serializers for IoT Dashboard models."""
|
||||
|
||||
from rest_framework import serializers
|
||||
from iotDashboard.models import Device, DeviceCertificate, Telemetry
|
||||
from iotDashboard.dashboard_models import DashboardLayout
|
||||
|
||||
|
||||
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)
|
||||
|
||||
|
||||
# Dashboard Layout Serializers
|
||||
class DashboardLayoutSerializer(serializers.ModelSerializer):
|
||||
"""Serializer for dashboard layouts (single-user system)."""
|
||||
|
||||
class Meta:
|
||||
model = DashboardLayout
|
||||
fields = ['id', 'name', 'config', 'is_default', 'created_at', 'updated_at']
|
||||
read_only_fields = ['id', 'created_at', 'updated_at']
|
||||
@@ -1,21 +0,0 @@
|
||||
"""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,
|
||||
WellnessViewSet, DashboardLayoutViewSet, CalendarViewSet
|
||||
)
|
||||
|
||||
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')
|
||||
router.register(r'wellness', WellnessViewSet, basename='wellness')
|
||||
router.register(r'dashboard-layouts', DashboardLayoutViewSet, basename='dashboard-layout')
|
||||
router.register(r'calendar', CalendarViewSet, basename='calendar')
|
||||
|
||||
urlpatterns = [
|
||||
path('', include(router.urls)),
|
||||
]
|
||||
File diff suppressed because it is too large
Load Diff
@@ -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'
|
||||
|
||||
@@ -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()
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user