mirror of
https://github.com/ferdzo/iotDashboard.git
synced 2026-04-05 17:16:26 +00:00
Compare commits
12 Commits
develop
...
copilot/su
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d1eb102e44 | ||
|
|
8fa4117ce0 | ||
| 4ce795228d | |||
| 6c0dc5f3d8 | |||
| 1a5bef277d | |||
| 3ab81fad8c | |||
| ab72c01999 | |||
| 8c699bd121 | |||
| 7f2aec96dd | |||
| ed105fccd3 | |||
| 7921049f56 | |||
| 8e98f5ad7d |
34
.dockerignore
Normal file
34
.dockerignore
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
.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
|
||||||
2
.github/copilot-instructions.md
vendored
2
.github/copilot-instructions.md
vendored
@@ -3,6 +3,8 @@ The following concise instructions help AI coding agents become productive in th
|
|||||||
Purpose
|
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.
|
- 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
|
Big Picture
|
||||||
- Architecture: Device → MQTT (mTLS) → mqtt_ingestion → Redis → db_write → PostgreSQL/TimescaleDB
|
- Architecture: Device → MQTT (mTLS) → mqtt_ingestion → Redis → db_write → PostgreSQL/TimescaleDB
|
||||||
- Components:
|
- Components:
|
||||||
|
|||||||
40
Dockerfile
Normal file
40
Dockerfile
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
# Django Backend Dockerfile
|
||||||
|
FROM ghcr.io/astral-sh/uv:python3.13-alpine AS builder
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
ENV UV_COMPILE_BYTECODE=1
|
||||||
|
|
||||||
|
COPY pyproject.toml uv.lock ./
|
||||||
|
|
||||||
|
RUN uv sync --frozen --no-dev --no-install-project
|
||||||
|
|
||||||
|
COPY iotDashboard/ ./iotDashboard/
|
||||||
|
COPY manage.py ./
|
||||||
|
COPY create_user.py ./
|
||||||
|
|
||||||
|
RUN uv sync --frozen --no-dev
|
||||||
|
|
||||||
|
|
||||||
|
FROM python:3.13-alpine
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
RUN apk add --no-cache postgresql-client
|
||||||
|
|
||||||
|
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/
|
||||||
|
|
||||||
|
RUN adduser -D -u 1000 appuser && \
|
||||||
|
chown -R appuser:appuser /app
|
||||||
|
|
||||||
|
USER appuser
|
||||||
|
|
||||||
|
ENV PATH="/app/.venv/bin:$PATH"
|
||||||
|
ENV PYTHONUNBUFFERED=1
|
||||||
|
|
||||||
|
EXPOSE 3000
|
||||||
|
|
||||||
|
CMD ["python", "-m", "uvicorn", "iotDashboard.asgi:application", "--host", "0.0.0.0", "--port", "3000"]
|
||||||
107
create_user.py
Executable file
107
create_user.py
Executable file
@@ -0,0 +1,107 @@
|
|||||||
|
#!/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 SECRET_KEY
|
||||||
|
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()
|
||||||
7
db_migrations/.dockerignore
Normal file
7
db_migrations/.dockerignore
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
.env*
|
||||||
|
.md
|
||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
|
*.pyo
|
||||||
|
*.pyd
|
||||||
|
*.log
|
||||||
12
db_migrations/Dockerfile
Normal file
12
db_migrations/Dockerfile
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
FROM ghcr.io/astral-sh/uv:python3.13-alpine
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
COPY pyproject.toml ./
|
||||||
|
|
||||||
|
RUN uv sync --no-cache
|
||||||
|
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
CMD ["uv", "run", "alembic", "upgrade", "head"]
|
||||||
|
|
||||||
@@ -0,0 +1,45 @@
|
|||||||
|
"""Add device onboarding tokens for secure QR code onboarding
|
||||||
|
|
||||||
|
Revision ID: 0f2632e459d3
|
||||||
|
Revises: 4e405f1129b1
|
||||||
|
Create Date: 2025-11-13 22:08:31.765427+00:00
|
||||||
|
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision: str = '0f2632e459d3'
|
||||||
|
down_revision: Union[str, Sequence[str], None] = '4e405f1129b1'
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
"""Upgrade schema."""
|
||||||
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
|
op.create_table('device_onboarding_tokens',
|
||||||
|
sa.Column('token', sa.Text(), nullable=False),
|
||||||
|
sa.Column('device_id', sa.Text(), nullable=False),
|
||||||
|
sa.Column('certificate_id', sa.Text(), nullable=False),
|
||||||
|
sa.Column('created_at', sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.Column('expires_at', sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.Column('used_at', sa.DateTime(timezone=True), nullable=True),
|
||||||
|
sa.ForeignKeyConstraint(['device_id'], ['devices.id'], ondelete='CASCADE'),
|
||||||
|
sa.PrimaryKeyConstraint('token')
|
||||||
|
)
|
||||||
|
op.create_index('idx_onboarding_tokens_device_id', 'device_onboarding_tokens', ['device_id'], unique=False)
|
||||||
|
op.create_index('idx_onboarding_tokens_expires', 'device_onboarding_tokens', ['expires_at'], unique=False)
|
||||||
|
# ### end Alembic commands ###
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
"""Downgrade schema."""
|
||||||
|
# ### commands auto generated by Alembic - please adjust! ###
|
||||||
|
op.drop_index('idx_onboarding_tokens_expires', table_name='device_onboarding_tokens')
|
||||||
|
op.drop_index('idx_onboarding_tokens_device_id', table_name='device_onboarding_tokens')
|
||||||
|
op.drop_table('device_onboarding_tokens')
|
||||||
|
# ### end Alembic commands ###
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
"""add_cascade_delete_to_telemetry
|
||||||
|
|
||||||
|
Revision ID: 4b84a36e13f5
|
||||||
|
Revises: 0f2632e459d3
|
||||||
|
Create Date: 2025-11-13 23:18:36.029045+00:00
|
||||||
|
|
||||||
|
"""
|
||||||
|
from typing import Sequence, Union
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
|
||||||
|
# revision identifiers, used by Alembic.
|
||||||
|
revision: str = '4b84a36e13f5'
|
||||||
|
down_revision: Union[str, Sequence[str], None] = '0f2632e459d3'
|
||||||
|
branch_labels: Union[str, Sequence[str], None] = None
|
||||||
|
depends_on: Union[str, Sequence[str], None] = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
"""Upgrade schema: Add ON DELETE CASCADE to telemetry foreign key."""
|
||||||
|
# Drop existing foreign key constraint
|
||||||
|
op.drop_constraint('telemetry_device_id_fkey', 'telemetry', type_='foreignkey')
|
||||||
|
|
||||||
|
# Re-create foreign key with ON DELETE CASCADE
|
||||||
|
op.create_foreign_key(
|
||||||
|
'telemetry_device_id_fkey',
|
||||||
|
'telemetry',
|
||||||
|
'devices',
|
||||||
|
['device_id'],
|
||||||
|
['id'],
|
||||||
|
ondelete='CASCADE'
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
"""Downgrade schema: Remove CASCADE from telemetry foreign key."""
|
||||||
|
# Drop foreign key with CASCADE
|
||||||
|
op.drop_constraint('telemetry_device_id_fkey', 'telemetry', type_='foreignkey')
|
||||||
|
|
||||||
|
# Re-create foreign key without CASCADE (original state)
|
||||||
|
op.create_foreign_key(
|
||||||
|
'telemetry_device_id_fkey',
|
||||||
|
'telemetry',
|
||||||
|
'devices',
|
||||||
|
['device_id'],
|
||||||
|
['id']
|
||||||
|
)
|
||||||
@@ -78,7 +78,30 @@ class DeviceCredential(Base):
|
|||||||
return f"<DeviceCredential(id={self.id}, device_id={self.device_id}, type={self.credential_type})>"
|
return f"<DeviceCredential(id={self.id}, device_id={self.device_id}, type={self.credential_type})>"
|
||||||
|
|
||||||
|
|
||||||
class Telemetry(Base):
|
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.
|
Time-series telemetry data from devices.
|
||||||
"""
|
"""
|
||||||
@@ -86,7 +109,7 @@ class Telemetry(Base):
|
|||||||
__tablename__ = "telemetry"
|
__tablename__ = "telemetry"
|
||||||
|
|
||||||
time = Column(DateTime(timezone=True), primary_key=True, nullable=False)
|
time = Column(DateTime(timezone=True), primary_key=True, nullable=False)
|
||||||
device_id = Column(Text, ForeignKey("devices.id"), 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)
|
metric = Column(Text, primary_key=True, nullable=False)
|
||||||
value = Column(Float, nullable=False)
|
value = Column(Float, nullable=False)
|
||||||
unit = Column(Text)
|
unit = Column(Text)
|
||||||
@@ -95,3 +118,24 @@ class Telemetry(Base):
|
|||||||
|
|
||||||
def __repr__(self):
|
def __repr__(self):
|
||||||
return f"<Telemetry(device={self.device_id}, metric={self.metric}, value={self.value})>"
|
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})>"
|
||||||
|
|||||||
@@ -6,5 +6,7 @@ readme = "README.md"
|
|||||||
requires-python = ">=3.13"
|
requires-python = ">=3.13"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"alembic>=1.17.0",
|
"alembic>=1.17.0",
|
||||||
|
"dotenv>=0.9.9",
|
||||||
"sqlalchemy>=2.0.44",
|
"sqlalchemy>=2.0.44",
|
||||||
|
"psycopg2-binary>=2.9.10",
|
||||||
]
|
]
|
||||||
|
|||||||
13
frontend/.dockerignore
Normal file
13
frontend/.dockerignore
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
.env
|
||||||
|
.env.local
|
||||||
|
.env.*.local
|
||||||
|
npm-debug.log*
|
||||||
|
.DS_Store
|
||||||
|
.vscode
|
||||||
|
.idea
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
*.log
|
||||||
|
coverage
|
||||||
8
frontend/.env.sample
Normal file
8
frontend/.env.sample
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
# API Configuration
|
||||||
|
VITE_API_URL=/api
|
||||||
|
# Django proxy URL (used for QR code API endpoint)
|
||||||
|
VITE_DEVICE_MANAGER_URL=http://10.10.1.114:3000/api
|
||||||
|
|
||||||
|
# MQTT Configuration
|
||||||
|
VITE_MQTT_BROKER=10.10.1.114
|
||||||
|
VITE_MQTT_PORT=8883
|
||||||
25
frontend/.gitignore
vendored
Normal file
25
frontend/.gitignore
vendored
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
# Logs
|
||||||
|
logs
|
||||||
|
*.log
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
pnpm-debug.log*
|
||||||
|
lerna-debug.log*
|
||||||
|
|
||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
dist-ssr
|
||||||
|
*.local
|
||||||
|
.env
|
||||||
|
|
||||||
|
# Editor directories and files
|
||||||
|
.vscode/*
|
||||||
|
!.vscode/extensions.json
|
||||||
|
.idea
|
||||||
|
.DS_Store
|
||||||
|
*.suo
|
||||||
|
*.ntvs*
|
||||||
|
*.njsproj
|
||||||
|
*.sln
|
||||||
|
*.sw?
|
||||||
32
frontend/Dockerfile
Normal file
32
frontend/Dockerfile
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
FROM node:22-alpine AS builder
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
COPY package*.json ./
|
||||||
|
|
||||||
|
RUN npm ci
|
||||||
|
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
ARG VITE_API_URL=/api
|
||||||
|
ARG VITE_DEVICE_MANAGER_URL
|
||||||
|
ARG VITE_MQTT_BROKER
|
||||||
|
ARG VITE_MQTT_PORT=8883
|
||||||
|
|
||||||
|
ENV VITE_API_URL=$VITE_API_URL
|
||||||
|
ENV VITE_DEVICE_MANAGER_URL=$VITE_DEVICE_MANAGER_URL
|
||||||
|
ENV VITE_MQTT_BROKER=$VITE_MQTT_BROKER
|
||||||
|
ENV VITE_MQTT_PORT=$VITE_MQTT_PORT
|
||||||
|
|
||||||
|
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;"]
|
||||||
111
frontend/README.md
Normal file
111
frontend/README.md
Normal file
@@ -0,0 +1,111 @@
|
|||||||
|
# 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),
|
||||||
|
});
|
||||||
|
```
|
||||||
23
frontend/eslint.config.js
Normal file
23
frontend/eslint.config.js
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
import js from '@eslint/js'
|
||||||
|
import globals from 'globals'
|
||||||
|
import reactHooks from 'eslint-plugin-react-hooks'
|
||||||
|
import reactRefresh from 'eslint-plugin-react-refresh'
|
||||||
|
import tseslint from 'typescript-eslint'
|
||||||
|
import { defineConfig, globalIgnores } from 'eslint/config'
|
||||||
|
|
||||||
|
export default defineConfig([
|
||||||
|
globalIgnores(['dist']),
|
||||||
|
{
|
||||||
|
files: ['**/*.{ts,tsx}'],
|
||||||
|
extends: [
|
||||||
|
js.configs.recommended,
|
||||||
|
tseslint.configs.recommended,
|
||||||
|
reactHooks.configs['recommended-latest'],
|
||||||
|
reactRefresh.configs.vite,
|
||||||
|
],
|
||||||
|
languageOptions: {
|
||||||
|
ecmaVersion: 2020,
|
||||||
|
globals: globals.browser,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
])
|
||||||
13
frontend/index.html
Normal file
13
frontend/index.html
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<!-- <link rel="icon" type="image/svg+xml" href="/vite.svg" /> -->
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>Lyncis</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
23
frontend/nginx.conf
Normal file
23
frontend/nginx.conf
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
# 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
Normal file
5396
frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
46
frontend/package.json
Normal file
46
frontend/package.json
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
{
|
||||||
|
"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"
|
||||||
|
}
|
||||||
|
}
|
||||||
165
frontend/src/App.css
Normal file
165
frontend/src/App.css
Normal file
@@ -0,0 +1,165 @@
|
|||||||
|
/* Custom styles for the IoT Dashboard */
|
||||||
|
|
||||||
|
#root {
|
||||||
|
width: 100%;
|
||||||
|
min-height: 100vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Custom scrollbar for the drawer */
|
||||||
|
.drawer-side::-webkit-scrollbar {
|
||||||
|
width: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.drawer-side::-webkit-scrollbar-track {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.drawer-side::-webkit-scrollbar-thumb {
|
||||||
|
background: hsl(var(--bc) / 0.2);
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.drawer-side::-webkit-scrollbar-thumb:hover {
|
||||||
|
background: hsl(var(--bc) / 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Smooth transitions for interactive elements */
|
||||||
|
.btn,
|
||||||
|
.card {
|
||||||
|
transition: all 0.2s ease-in-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card:hover {
|
||||||
|
transform: translateY(-2px);
|
||||||
|
box-shadow: 0 8px 16px rgba(0, 0, 0, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Badge animations */
|
||||||
|
.badge {
|
||||||
|
transition: all 0.2s ease-in-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Stats animation on load */
|
||||||
|
@keyframes fadeInUp {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(20px);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.stats {
|
||||||
|
animation: fadeInUp 0.5s ease-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Responsive table scrolling */
|
||||||
|
.overflow-x-auto {
|
||||||
|
scrollbar-width: thin;
|
||||||
|
scrollbar-color: hsl(var(--bc) / 0.2) transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.overflow-x-auto::-webkit-scrollbar {
|
||||||
|
height: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.overflow-x-auto::-webkit-scrollbar-track {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.overflow-x-auto::-webkit-scrollbar-thumb {
|
||||||
|
background: hsl(var(--bc) / 0.2);
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Loading spinner custom styles */
|
||||||
|
.loading {
|
||||||
|
animation: spin 1s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes spin {
|
||||||
|
from {
|
||||||
|
transform: rotate(0deg);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
transform: rotate(360deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* React Grid Layout Overrides */
|
||||||
|
.react-grid-layout {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.react-grid-item {
|
||||||
|
transition: all 200ms ease;
|
||||||
|
transition-property: left, top, width, height;
|
||||||
|
}
|
||||||
|
|
||||||
|
.react-grid-item img {
|
||||||
|
pointer-events: none;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.react-grid-item > .react-resizable-handle {
|
||||||
|
position: absolute;
|
||||||
|
width: 20px;
|
||||||
|
height: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.react-grid-item > .react-resizable-handle::after {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
right: 3px;
|
||||||
|
bottom: 3px;
|
||||||
|
width: 5px;
|
||||||
|
height: 5px;
|
||||||
|
border-right: 2px solid hsl(var(--bc) / 0.4);
|
||||||
|
border-bottom: 2px solid hsl(var(--bc) / 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
.react-resizable-hide > .react-resizable-handle {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.react-grid-item > .react-resizable-handle.react-resizable-handle-se {
|
||||||
|
bottom: 0;
|
||||||
|
right: 0;
|
||||||
|
cursor: se-resize;
|
||||||
|
}
|
||||||
|
|
||||||
|
.react-grid-item.resizing {
|
||||||
|
transition: none;
|
||||||
|
z-index: 100;
|
||||||
|
will-change: width, height;
|
||||||
|
}
|
||||||
|
|
||||||
|
.react-grid-item.react-draggable-dragging {
|
||||||
|
transition: none;
|
||||||
|
z-index: 100;
|
||||||
|
will-change: transform;
|
||||||
|
}
|
||||||
|
|
||||||
|
.react-grid-item.dropping {
|
||||||
|
visibility: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.react-grid-item.react-grid-placeholder {
|
||||||
|
background: hsl(var(--p) / 0.2);
|
||||||
|
opacity: 0.2;
|
||||||
|
transition-duration: 100ms;
|
||||||
|
z-index: 2;
|
||||||
|
-webkit-user-select: none;
|
||||||
|
-moz-user-select: none;
|
||||||
|
-ms-user-select: none;
|
||||||
|
-o-user-select: none;
|
||||||
|
user-select: none;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.react-grid-item > .react-resizable-handle::after {
|
||||||
|
border-right: 2px solid hsl(var(--p));
|
||||||
|
border-bottom: 2px solid hsl(var(--p));
|
||||||
|
}
|
||||||
152
frontend/src/App.tsx
Normal file
152
frontend/src/App.tsx
Normal file
@@ -0,0 +1,152 @@
|
|||||||
|
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 { useState } from 'react'
|
||||||
|
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()
|
||||||
|
const [sidebarCollapsed, setSidebarCollapsed] = useState(false)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="drawer lg:drawer-open min-h-screen">
|
||||||
|
<input id="main-drawer" type="checkbox" className="drawer-toggle" />
|
||||||
|
<div className="drawer-content flex flex-col min-h-screen bg-base-200">
|
||||||
|
{/* 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 min-h-full">
|
||||||
|
{children}
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Sidebar */}
|
||||||
|
<div className="drawer-side">
|
||||||
|
<label htmlFor="main-drawer" className="drawer-overlay"></label>
|
||||||
|
<aside className={`bg-base-100 min-h-screen flex flex-col transition-all duration-300 ${sidebarCollapsed ? 'w-16' : 'w-56'}`}>
|
||||||
|
{/* Toggle button (desktop only) */}
|
||||||
|
<div className="hidden lg:flex justify-end p-2">
|
||||||
|
<button
|
||||||
|
onClick={() => setSidebarCollapsed(!sidebarCollapsed)}
|
||||||
|
className="btn btn-ghost btn-xs btn-square"
|
||||||
|
title={sidebarCollapsed ? 'Expand sidebar' : 'Collapse sidebar'}
|
||||||
|
>
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
{sidebarCollapsed ? (
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 5l7 7-7 7M5 5l7 7-7 7" />
|
||||||
|
) : (
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M11 19l-7-7 7-7m8 14l-7-7 7-7" />
|
||||||
|
)}
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className={`${sidebarCollapsed ? 'px-2' : 'p-4'}`}>
|
||||||
|
<Link to="/" className={`flex items-center gap-2 font-bold ${sidebarCollapsed ? 'justify-center' : 'text-2xl'}`}>
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" className={`${sidebarCollapsed ? 'h-6 w-6' : '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>
|
||||||
|
{!sidebarCollapsed && <span>Lyncis</span>}
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ul className={`menu space-y-2 ${sidebarCollapsed ? 'p-2' : 'p-4'}`}>
|
||||||
|
<li>
|
||||||
|
<NavLink
|
||||||
|
to="/"
|
||||||
|
className={({ isActive }) => `${isActive ? 'active' : ''} ${sidebarCollapsed ? 'tooltip tooltip-right' : ''}`}
|
||||||
|
data-tip="Dashboard"
|
||||||
|
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>
|
||||||
|
{!sidebarCollapsed && 'Dashboard'}
|
||||||
|
</NavLink>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<NavLink
|
||||||
|
to="/devices"
|
||||||
|
className={({ isActive }) => `${isActive ? 'active' : ''} ${sidebarCollapsed ? 'tooltip tooltip-right' : ''}`}
|
||||||
|
data-tip="Devices"
|
||||||
|
>
|
||||||
|
<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>
|
||||||
|
{!sidebarCollapsed && 'Devices'}
|
||||||
|
</NavLink>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<div className={`mt-auto ${sidebarCollapsed ? 'p-2' : 'p-4'}`}>
|
||||||
|
<button
|
||||||
|
onClick={logout}
|
||||||
|
className={`btn btn-ghost btn-sm w-full ${sidebarCollapsed ? 'tooltip tooltip-right' : ''}`}
|
||||||
|
data-tip="Logout"
|
||||||
|
>
|
||||||
|
<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>
|
||||||
|
{!sidebarCollapsed && '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
|
||||||
402
frontend/src/App.tsx.bak
Normal file
402
frontend/src/App.tsx.bak
Normal file
@@ -0,0 +1,402 @@
|
|||||||
|
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||||
|
import { BrowserRouter, Routes, Route, Link, NavLink } from 'react-router-dom'
|
||||||
|
import { Toaster } from 'react-hot-toast'
|
||||||
|
import Dashboard from './pages/Dashboard'
|
||||||
|
import DeviceList from './pages/DeviceList'
|
||||||
|
import DeviceDetail from './pages/DeviceDetail'
|
||||||
|
import AddDevice from './pages/AddDevice'
|
||||||
|
import './App.css'
|
||||||
|
|
||||||
|
const queryClient = new QueryClient()
|
||||||
|
|
||||||
|
function AppLayout({ children }: { children: React.ReactNode }) {
|
||||||
|
return (
|
||||||
|
<div className="drawer lg:drawer-open">
|
||||||
|
<input id="main-drawer" type="checkbox" className="drawer-toggle" />
|
||||||
|
<div className="drawer-content flex flex-col">
|
||||||
|
{/* Navbar */}
|
||||||
|
<div className="navbar bg-base-300 lg:hidden">
|
||||||
|
<div className="flex-none">
|
||||||
|
<label htmlFor="main-drawer" className="btn btn-square btn-ghost">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" className="inline-block w-5 h-5 stroke-current">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M4 6h16M4 12h16M4 18h16"></path>
|
||||||
|
</svg>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div className="flex-1">
|
||||||
|
<span className="text-xl font-bold">IoT Dashboard</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Page content */}
|
||||||
|
<main className="flex-1 bg-base-200">
|
||||||
|
{children}
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Sidebar */}
|
||||||
|
<div className="drawer-side">
|
||||||
|
<label htmlFor="main-drawer" className="drawer-overlay"></label>
|
||||||
|
<aside className="bg-base-100 w-64 min-h-full">
|
||||||
|
<div className="p-4">
|
||||||
|
<Link to="/" className="flex items-center gap-2 text-2xl font-bold">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" className="h-8 w-8" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 3v2m6-2v2M9 19v2m6-2v2M5 9H3m2 6H3m18-6h-2m2 6h-2M7 19h10a2 2 0 002-2V7a2 2 0 00-2-2H7a2 2 0 00-2 2v10a2 2 0 002 2zM9 9h6v6H9V9z" />
|
||||||
|
</svg>
|
||||||
|
<span>IoT Dashboard</span>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ul className="menu p-4 space-y-2">
|
||||||
|
<li>
|
||||||
|
<NavLink
|
||||||
|
to="/"
|
||||||
|
className={({ isActive }) => isActive ? 'active' : ''}
|
||||||
|
end
|
||||||
|
>
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6" />
|
||||||
|
</svg>
|
||||||
|
Dashboard
|
||||||
|
</NavLink>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<NavLink
|
||||||
|
to="/devices"
|
||||||
|
className={({ isActive }) => isActive ? 'active' : ''}
|
||||||
|
>
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 3v2m6-2v2M9 19v2m6-2v2M5 9H3m2 6H3m18-6h-2m2 6h-2M7 19h10a2 2 0 002-2V7a2 2 0 00-2-2H7a2 2 0 00-2 2v10a2 2 0 002 2zM9 9h6v6H9V9z" />
|
||||||
|
</svg>
|
||||||
|
Devices
|
||||||
|
</NavLink>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</aside>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function App() {
|
||||||
|
queryKey: ['dashboard'],
|
||||||
|
queryFn: async () => {
|
||||||
|
const response = await dashboardApi.getOverview()
|
||||||
|
return response.data
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const { data: devices, isLoading: devicesLoading } = useQuery({
|
||||||
|
queryKey: ['devices'],
|
||||||
|
queryFn: async () => {
|
||||||
|
const response = await devicesApi.getAll()
|
||||||
|
return response.data
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
if (overviewLoading || devicesLoading) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center min-h-screen">
|
||||||
|
<span className="loading loading-spinner loading-lg text-primary"></span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="drawer lg:drawer-open">
|
||||||
|
<input id="drawer" type="checkbox" className="drawer-toggle" />
|
||||||
|
|
||||||
|
<div className="drawer-content flex flex-col">
|
||||||
|
{/* Navbar */}
|
||||||
|
<div className="navbar bg-base-100 shadow-lg">
|
||||||
|
<div className="flex-none lg:hidden">
|
||||||
|
<label htmlFor="drawer" className="btn btn-square btn-ghost">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" className="inline-block w-6 h-6 stroke-current">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M4 6h16M4 12h16M4 18h16"></path>
|
||||||
|
</svg>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div className="flex-1">
|
||||||
|
<h1 className="text-2xl font-bold ml-2">IoT Dashboard</h1>
|
||||||
|
</div>
|
||||||
|
<div className="flex-none gap-2">
|
||||||
|
<div className="dropdown dropdown-end">
|
||||||
|
<div tabIndex={0} role="button" className="btn btn-ghost btn-circle avatar">
|
||||||
|
<div className="w-10 rounded-full bg-primary text-primary-content flex items-center justify-center">
|
||||||
|
<span className="text-xl">U</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Main Content */}
|
||||||
|
<div className="p-4 md:p-8">
|
||||||
|
{/* Breadcrumbs */}
|
||||||
|
<div className="text-sm breadcrumbs mb-4">
|
||||||
|
<ul>
|
||||||
|
<li><a>Home</a></li>
|
||||||
|
<li>Dashboard</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Page Header */}
|
||||||
|
<div className="mb-6">
|
||||||
|
<h2 className="text-3xl font-bold">Dashboard Overview</h2>
|
||||||
|
<p className="text-base-content/70 mt-1">
|
||||||
|
Office Environment Intelligence Platform
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Stats Cards */}
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4 mb-8">
|
||||||
|
<div className="stats shadow">
|
||||||
|
<div className="stat">
|
||||||
|
<div className="stat-figure text-primary">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" className="inline-block w-8 h-8 stroke-current">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div className="stat-title">Total Devices</div>
|
||||||
|
<div className="stat-value text-primary">{overview?.total_devices || 0}</div>
|
||||||
|
<div className="stat-desc">Registered in system</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="stats shadow">
|
||||||
|
<div className="stat">
|
||||||
|
<div className="stat-figure text-success">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" className="inline-block w-8 h-8 stroke-current">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div className="stat-title">Active Devices</div>
|
||||||
|
<div className="stat-value text-success">{overview?.active_devices || 0}</div>
|
||||||
|
<div className="stat-desc">Currently online</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="stats shadow">
|
||||||
|
<div className="stat">
|
||||||
|
<div className="stat-figure text-secondary">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" className="inline-block w-8 h-8 stroke-current">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M8.111 16.404a5.5 5.5 0 017.778 0M12 20h.01m-7.08-7.071c3.904-3.905 10.236-3.905 14.141 0M1.394 9.393c5.857-5.857 15.355-5.857 21.213 0" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div className="stat-title">MQTT Devices</div>
|
||||||
|
<div className="stat-value text-secondary">{overview?.mqtt_devices || 0}</div>
|
||||||
|
<div className="stat-desc">Using mTLS</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="stats shadow">
|
||||||
|
<div className="stat">
|
||||||
|
<div className="stat-figure text-warning">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" className="inline-block w-8 h-8 stroke-current">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div className="stat-title">Expiring Soon</div>
|
||||||
|
<div className="stat-value text-warning">{overview?.certificates_expiring_soon || 0}</div>
|
||||||
|
<div className="stat-desc">Certificates need renewal</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Devices Section */}
|
||||||
|
<div className="card bg-base-100 shadow-xl">
|
||||||
|
<div className="card-body">
|
||||||
|
<div className="flex justify-between items-center mb-4">
|
||||||
|
<h2 className="card-title text-2xl">Devices</h2>
|
||||||
|
<button className="btn btn-primary btn-sm">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M12 4v16m8-8H4" />
|
||||||
|
</svg>
|
||||||
|
Add Device
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{devices && devices.length > 0 ? (
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||||
|
{devices.map((device: Device) => (
|
||||||
|
<div key={device.id} className="card bg-base-200 shadow-md hover:shadow-xl transition-shadow">
|
||||||
|
<div className="card-body">
|
||||||
|
<div className="flex justify-between items-start">
|
||||||
|
<h3 className="card-title text-lg">{device.name}</h3>
|
||||||
|
<div className={`badge ${device.is_active ? 'badge-success' : 'badge-ghost'}`}>
|
||||||
|
{device.is_active ? 'Active' : 'Inactive'}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2 text-sm">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="font-semibold">ID:</span>
|
||||||
|
<code className="bg-base-300 px-2 py-1 rounded">{device.id}</code>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="font-semibold">Protocol:</span>
|
||||||
|
<div className="badge badge-outline">{device.protocol.toUpperCase()}</div>
|
||||||
|
</div>
|
||||||
|
{device.location && (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="font-semibold">Location:</span>
|
||||||
|
<span>{device.location}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{device.certificate_status && (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="font-semibold">Certificate:</span>
|
||||||
|
<div className={`badge ${
|
||||||
|
device.certificate_status === 'Valid' ? 'badge-success' :
|
||||||
|
device.certificate_status === 'Expiring Soon' ? 'badge-warning' :
|
||||||
|
'badge-error'
|
||||||
|
}`}>
|
||||||
|
{device.certificate_status}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="card-actions justify-end mt-4">
|
||||||
|
<button className="btn btn-sm btn-ghost">View</button>
|
||||||
|
<button className="btn btn-sm btn-primary">Manage</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="alert">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" className="stroke-info shrink-0 w-6 h-6">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"></path>
|
||||||
|
</svg>
|
||||||
|
<span>No devices registered yet. Add your first device to get started!</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Recent Telemetry */}
|
||||||
|
{overview && overview.recent_telemetry.length > 0 && (
|
||||||
|
<div className="card bg-base-100 shadow-xl mt-8">
|
||||||
|
<div className="card-body">
|
||||||
|
<h2 className="card-title text-2xl mb-4">Recent Telemetry</h2>
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="table table-zebra">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Device</th>
|
||||||
|
<th>Metric</th>
|
||||||
|
<th>Value</th>
|
||||||
|
<th>Time</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{overview.recent_telemetry.map((t: { device_name: string; device_id: string; metric: string; value: number; unit?: string; time: string }, idx: number) => (
|
||||||
|
<tr key={idx} className="hover">
|
||||||
|
<td>
|
||||||
|
<div className="font-bold">{t.device_name}</div>
|
||||||
|
<div className="text-sm opacity-50">{t.device_id}</div>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<div className="badge badge-ghost">{t.metric}</div>
|
||||||
|
</td>
|
||||||
|
<td className="font-mono font-semibold">
|
||||||
|
{t.value} {t.unit || ''}
|
||||||
|
</td>
|
||||||
|
<td className="text-sm opacity-70">
|
||||||
|
{new Date(t.time).toLocaleString()}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Drawer Sidebar */}
|
||||||
|
<div className="drawer-side z-10">
|
||||||
|
<label htmlFor="drawer" className="drawer-overlay"></label>
|
||||||
|
<aside className="bg-base-200 w-64 min-h-screen">
|
||||||
|
<div className="p-4">
|
||||||
|
<h2 className="text-xl font-bold mb-4">IoT Dashboard</h2>
|
||||||
|
</div>
|
||||||
|
<ul className="menu p-4 text-base-content">
|
||||||
|
<li>
|
||||||
|
<a className="active">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6" />
|
||||||
|
</svg>
|
||||||
|
Dashboard
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a>
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z" />
|
||||||
|
</svg>
|
||||||
|
Devices
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a>
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z" />
|
||||||
|
</svg>
|
||||||
|
Analytics
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a>
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M8 10h.01M12 10h.01M16 10h.01M9 16H5a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v8a2 2 0 01-2 2h-5l-5 5v-5z" />
|
||||||
|
</svg>
|
||||||
|
AI Assistant
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li className="menu-title">
|
||||||
|
<span>Management</span>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a>
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
|
||||||
|
</svg>
|
||||||
|
Reports
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<a>
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.065 2.572c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.572 1.065c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.065-2.572c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.572-1.065z" />
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||||
|
</svg>
|
||||||
|
Settings
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</aside>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function App() {
|
||||||
|
return (
|
||||||
|
<QueryClientProvider client={queryClient}>
|
||||||
|
<BrowserRouter>
|
||||||
|
<Toaster position="top-right" />
|
||||||
|
<Routes>
|
||||||
|
<Route path="/" element={<AppLayout><Dashboard /></AppLayout>} />
|
||||||
|
<Route path="/devices" element={<AppLayout><DeviceList /></AppLayout>} />
|
||||||
|
<Route path="/devices/add" element={<AppLayout><AddDevice /></AppLayout>} />
|
||||||
|
<Route path="/devices/:id" element={<AppLayout><DeviceDetail /></AppLayout>} />
|
||||||
|
</Routes>
|
||||||
|
</BrowserRouter>
|
||||||
|
</QueryClientProvider>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default App
|
||||||
296
frontend/src/api/index.ts
Normal file
296
frontend/src/api/index.ts
Normal file
@@ -0,0 +1,296 @@
|
|||||||
|
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;
|
||||||
|
metrics?: string[]; // Support multiple metrics
|
||||||
|
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
frontend/src/assets/react.svg
Normal file
1
frontend/src/assets/react.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>
|
||||||
|
After Width: | Height: | Size: 4.0 KiB |
832
frontend/src/components/AddWidgetModal.tsx
Normal file
832
frontend/src/components/AddWidgetModal.tsx
Normal file
@@ -0,0 +1,832 @@
|
|||||||
|
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>
|
||||||
|
)
|
||||||
|
}
|
||||||
273
frontend/src/components/CredentialsViewer.tsx
Normal file
273
frontend/src/components/CredentialsViewer.tsx
Normal file
@@ -0,0 +1,273 @@
|
|||||||
|
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>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* MQTT Connection Information */}
|
||||||
|
<div className="rounded-lg bg-info/10 border border-info/30 p-4">
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" className="h-6 w-6 text-info 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 className="flex-1">
|
||||||
|
<h3 className="font-semibold mb-2">Connection Configuration</h3>
|
||||||
|
<div className="space-y-2 text-sm">
|
||||||
|
<div className="flex justify-between items-center">
|
||||||
|
<span className="font-medium">MQTT Broker:</span>
|
||||||
|
<code className="bg-base-100 px-2 py-1 rounded text-xs">{mqttBroker}</code>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between items-center">
|
||||||
|
<span className="font-medium">Port (TLS):</span>
|
||||||
|
<code className="bg-base-100 px-2 py-1 rounded text-xs">{mqttPort}</code>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between items-center">
|
||||||
|
<span className="font-medium">Device ID:</span>
|
||||||
|
<code className="bg-base-100 px-2 py-1 rounded text-xs">{resolvedDeviceId}</code>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Publishing Guide */}
|
||||||
|
<div className="rounded-lg bg-primary/10 border border-primary/30 p-4">
|
||||||
|
<div className="flex items-start gap-3">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" className="h-6 w-6 text-primary shrink-0" 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>
|
||||||
|
<div className="flex-1">
|
||||||
|
<h3 className="font-semibold mb-2">How to Publish Data</h3>
|
||||||
|
<div className="space-y-3 text-sm">
|
||||||
|
<div>
|
||||||
|
<span className="font-medium block mb-1">Topic Format:</span>
|
||||||
|
<code className="block bg-base-100 px-3 py-2 rounded text-xs break-all">
|
||||||
|
devices/{resolvedDeviceId}/{'<metric>'}
|
||||||
|
</code>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<span className="font-medium block mb-1">Payload:</span>
|
||||||
|
<p className="opacity-80 text-xs mb-1">Send numeric value as plain text (no JSON)</p>
|
||||||
|
<code className="block bg-base-100 px-3 py-2 rounded text-xs">23.5</code>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<span className="font-medium block mb-1">Supported Metrics:</span>
|
||||||
|
<div className="flex flex-wrap gap-1 mt-1">
|
||||||
|
{['temperature', 'humidity', 'co2', 'pm25', 'pm10', 'pressure', 'light', 'noise'].map(metric => (
|
||||||
|
<span key={metric} className="badge badge-sm badge-outline">{metric}</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="divider my-2"></div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<span className="font-medium block mb-1">Example (mosquitto_pub):</span>
|
||||||
|
<code className="block bg-base-100 px-3 py-2 rounded text-xs overflow-x-auto">
|
||||||
|
mosquitto_pub --cafile ca.crt --cert {resolvedDeviceId}.crt --key {resolvedDeviceId}.key \<br/>
|
||||||
|
-h {mqttBroker} -p {mqttPort} \<br/>
|
||||||
|
-t "devices/{resolvedDeviceId}/temperature" -m "23.5"
|
||||||
|
</code>
|
||||||
|
<button
|
||||||
|
className="btn btn-xs btn-outline mt-2"
|
||||||
|
onClick={() => copyToClipboard(
|
||||||
|
`mosquitto_pub --cafile ca.crt --cert ${resolvedDeviceId}.crt --key ${resolvedDeviceId}.key -h ${mqttBroker} -p ${mqttPort} -t "devices/${resolvedDeviceId}/temperature" -m "23.5"`,
|
||||||
|
'Example command'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
Copy Command
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{(credentials.certificate_id || expiresAt) && (
|
||||||
|
<div className="rounded-lg bg-base-200 p-4 text-sm">
|
||||||
|
<div className="flex flex-col gap-2">
|
||||||
|
{credentials.certificate_id && (
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="font-semibold">Certificate ID</span>
|
||||||
|
<code className="bg-base-100 px-2 py-1 rounded">
|
||||||
|
{credentials.certificate_id}
|
||||||
|
</code>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{expiresAt && (
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="font-semibold">Expires At</span>
|
||||||
|
<span>{expiresAt}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{credentials.ca_certificate_pem && (
|
||||||
|
<div>
|
||||||
|
<label className="label">
|
||||||
|
<span className="label-text font-semibold">CA Certificate</span>
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
className="textarea textarea-bordered w-full font-mono text-xs h-32"
|
||||||
|
value={credentials.ca_certificate_pem}
|
||||||
|
readOnly
|
||||||
|
/>
|
||||||
|
<div className="flex gap-2 mt-2">
|
||||||
|
<button
|
||||||
|
className="btn btn-sm btn-outline"
|
||||||
|
onClick={() => copyToClipboard(credentials.ca_certificate_pem!, 'CA certificate')}
|
||||||
|
>
|
||||||
|
Copy
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="btn btn-sm btn-outline"
|
||||||
|
onClick={() => downloadFile(credentials.ca_certificate_pem!, 'ca.crt')}
|
||||||
|
>
|
||||||
|
Download
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{credentials.certificate_pem && (
|
||||||
|
<div>
|
||||||
|
<label className="label">
|
||||||
|
<span className="label-text font-semibold">Device Certificate</span>
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
className="textarea textarea-bordered w-full font-mono text-xs h-32"
|
||||||
|
value={credentials.certificate_pem}
|
||||||
|
readOnly
|
||||||
|
/>
|
||||||
|
<div className="flex gap-2 mt-2">
|
||||||
|
<button
|
||||||
|
className="btn btn-sm btn-outline"
|
||||||
|
onClick={() => copyToClipboard(credentials.certificate_pem!, 'Device certificate')}
|
||||||
|
>
|
||||||
|
Copy
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="btn btn-sm btn-outline"
|
||||||
|
onClick={() => downloadFile(credentials.certificate_pem!, `${resolvedDeviceId}.crt`)}
|
||||||
|
>
|
||||||
|
Download
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{credentials.private_key_pem && (
|
||||||
|
<div>
|
||||||
|
<label className="label">
|
||||||
|
<span className="label-text font-semibold">Private Key</span>
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
className="textarea textarea-bordered w-full font-mono text-xs h-32"
|
||||||
|
value={credentials.private_key_pem}
|
||||||
|
readOnly
|
||||||
|
/>
|
||||||
|
<div className="flex gap-2 mt-2">
|
||||||
|
<button
|
||||||
|
className="btn btn-sm btn-outline"
|
||||||
|
onClick={() => copyToClipboard(credentials.private_key_pem!, 'Private key')}
|
||||||
|
>
|
||||||
|
Copy
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="btn btn-sm btn-outline"
|
||||||
|
onClick={() => downloadFile(credentials.private_key_pem!, `${resolvedDeviceId}.key`)}
|
||||||
|
>
|
||||||
|
Download
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
76
frontend/src/components/DeleteDeviceDialog.tsx
Normal file
76
frontend/src/components/DeleteDeviceDialog.tsx
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
import * as AlertDialog from '@radix-ui/react-alert-dialog'
|
||||||
|
import { useMutation, useQueryClient } from '@tanstack/react-query'
|
||||||
|
import { devicesApi } from '../api'
|
||||||
|
import toast from 'react-hot-toast'
|
||||||
|
import type { AxiosError } from 'axios'
|
||||||
|
import type { Device } from '../types/api'
|
||||||
|
|
||||||
|
interface DeleteDeviceDialogProps {
|
||||||
|
device: Device
|
||||||
|
open: boolean
|
||||||
|
onOpenChange: (open: boolean) => void
|
||||||
|
onDeleted?: () => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function DeleteDeviceDialog({ device, open, onOpenChange, onDeleted }: DeleteDeviceDialogProps) {
|
||||||
|
const queryClient = useQueryClient()
|
||||||
|
|
||||||
|
const deleteMutation = useMutation({
|
||||||
|
mutationFn: () => devicesApi.delete(device.id),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['devices'] })
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['device', device.id] })
|
||||||
|
toast.success(`Device "${device.name}" deleted successfully`)
|
||||||
|
onDeleted?.()
|
||||||
|
onOpenChange(false)
|
||||||
|
},
|
||||||
|
onError: (error) => {
|
||||||
|
const axiosError = error as AxiosError<{ detail?: string }>
|
||||||
|
const message = axiosError.response?.data?.detail || axiosError.message
|
||||||
|
toast.error(`Failed to delete device: ${message}`)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AlertDialog.Root open={open} onOpenChange={onOpenChange}>
|
||||||
|
<AlertDialog.Portal>
|
||||||
|
<AlertDialog.Overlay className="fixed inset-0 bg-black/50 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0" />
|
||||||
|
<AlertDialog.Content className="fixed left-[50%] top-[50%] z-50 max-h-[85vh] w-[90vw] max-w-[500px] translate-x-[-50%] translate-y-[-50%] rounded-lg bg-base-100 p-6 shadow-xl">
|
||||||
|
<AlertDialog.Title className="text-2xl font-bold mb-2">
|
||||||
|
Delete Device
|
||||||
|
</AlertDialog.Title>
|
||||||
|
<AlertDialog.Description className="text-base-content/70 mb-6">
|
||||||
|
Are you sure you want to delete <strong>{device.name}</strong>? This action cannot be undone.
|
||||||
|
All associated telemetry data and certificates will be permanently removed.
|
||||||
|
</AlertDialog.Description>
|
||||||
|
<div className="flex justify-end gap-3">
|
||||||
|
<AlertDialog.Cancel asChild>
|
||||||
|
<button className="btn btn-ghost" disabled={deleteMutation.isPending}>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
</AlertDialog.Cancel>
|
||||||
|
<AlertDialog.Action asChild>
|
||||||
|
<button
|
||||||
|
className="btn btn-error"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.preventDefault()
|
||||||
|
deleteMutation.mutate()
|
||||||
|
}}
|
||||||
|
disabled={deleteMutation.isPending}
|
||||||
|
>
|
||||||
|
{deleteMutation.isPending ? (
|
||||||
|
<>
|
||||||
|
<span className="loading loading-spinner loading-sm"></span>
|
||||||
|
Deleting...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
'Delete Device'
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</AlertDialog.Action>
|
||||||
|
</div>
|
||||||
|
</AlertDialog.Content>
|
||||||
|
</AlertDialog.Portal>
|
||||||
|
</AlertDialog.Root>
|
||||||
|
)
|
||||||
|
}
|
||||||
40
frontend/src/components/DeviceCredentialsDialog.tsx
Normal file
40
frontend/src/components/DeviceCredentialsDialog.tsx
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
import * as Dialog from '@radix-ui/react-dialog'
|
||||||
|
import CredentialsViewer from './CredentialsViewer'
|
||||||
|
import type { DeviceRegistrationResponse } from '../types/api'
|
||||||
|
|
||||||
|
interface DeviceCredentialsDialogProps {
|
||||||
|
open: boolean
|
||||||
|
onOpenChange: (open: boolean) => void
|
||||||
|
credentials: DeviceRegistrationResponse | null
|
||||||
|
deviceName?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function DeviceCredentialsDialog({ open, onOpenChange, credentials, deviceName }: DeviceCredentialsDialogProps) {
|
||||||
|
if (!credentials) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog.Root open={open} onOpenChange={onOpenChange}>
|
||||||
|
<Dialog.Portal>
|
||||||
|
<Dialog.Overlay className="fixed inset-0 bg-black/50 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0" />
|
||||||
|
<Dialog.Content className="fixed left-1/2 top-1/2 z-50 max-h-[85vh] w-[90vw] max-w-[600px] -translate-x-1/2 -translate-y-1/2 rounded-lg bg-base-100 p-6 shadow-xl overflow-y-auto">
|
||||||
|
<Dialog.Title className="text-2xl font-bold mb-4">
|
||||||
|
{deviceName ? `${deviceName} Credentials` : 'Device Credentials'}
|
||||||
|
</Dialog.Title>
|
||||||
|
<Dialog.Description className="text-base-content/70 mb-4">
|
||||||
|
Store these credentials securely. They are only shown once after issuing the certificate.
|
||||||
|
</Dialog.Description>
|
||||||
|
|
||||||
|
<CredentialsViewer credentials={credentials} deviceId={credentials.device_id} />
|
||||||
|
|
||||||
|
<div className="flex justify-end mt-6">
|
||||||
|
<Dialog.Close asChild>
|
||||||
|
<button className="btn btn-primary">Done</button>
|
||||||
|
</Dialog.Close>
|
||||||
|
</div>
|
||||||
|
</Dialog.Content>
|
||||||
|
</Dialog.Portal>
|
||||||
|
</Dialog.Root>
|
||||||
|
)
|
||||||
|
}
|
||||||
496
frontend/src/components/EditWidgetModal.tsx
Normal file
496
frontend/src/components/EditWidgetModal.tsx
Normal file
@@ -0,0 +1,496 @@
|
|||||||
|
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>
|
||||||
|
)
|
||||||
|
}
|
||||||
101
frontend/src/components/RenewDialog.tsx
Normal file
101
frontend/src/components/RenewDialog.tsx
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
import * as Dialog from '@radix-ui/react-dialog'
|
||||||
|
import { useMutation, useQueryClient } from '@tanstack/react-query'
|
||||||
|
import { devicesApi } from '../api'
|
||||||
|
import toast from 'react-hot-toast'
|
||||||
|
import { useState } from 'react'
|
||||||
|
import CredentialsViewer from './CredentialsViewer'
|
||||||
|
import type { AxiosError } from 'axios'
|
||||||
|
import type { Device, DeviceRegistrationResponse } from '../types/api'
|
||||||
|
|
||||||
|
interface RenewDialogProps {
|
||||||
|
device: Device
|
||||||
|
open: boolean
|
||||||
|
onOpenChange: (open: boolean) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function RenewDialog({ device, open, onOpenChange }: RenewDialogProps) {
|
||||||
|
const queryClient = useQueryClient()
|
||||||
|
const [credentials, setCredentials] = useState<DeviceRegistrationResponse | null>(null)
|
||||||
|
|
||||||
|
const renewMutation = useMutation({
|
||||||
|
mutationFn: () => devicesApi.renew(device.id),
|
||||||
|
onSuccess: (response) => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['devices'] })
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['device', device.id] })
|
||||||
|
setCredentials(response.data)
|
||||||
|
toast.success(`Certificate for "${device.name}" renewed successfully`)
|
||||||
|
},
|
||||||
|
onError: (error) => {
|
||||||
|
const axiosError = error as AxiosError<{ detail?: string }>
|
||||||
|
const message = axiosError.response?.data?.detail || axiosError.message
|
||||||
|
toast.error(`Failed to renew certificate: ${message}`)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const handleOpenChange = (nextOpen: boolean) => {
|
||||||
|
if (!nextOpen) {
|
||||||
|
setCredentials(null)
|
||||||
|
onOpenChange(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog.Root open={open} onOpenChange={handleOpenChange}>
|
||||||
|
<Dialog.Portal>
|
||||||
|
<Dialog.Overlay className="fixed inset-0 bg-black/50 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0" />
|
||||||
|
<Dialog.Content className="fixed left-[50%] top-[50%] z-50 max-h-[85vh] w-[90vw] max-w-[600px] translate-x-[-50%] translate-y-[-50%] rounded-lg bg-base-100 p-6 shadow-xl overflow-y-auto">
|
||||||
|
<Dialog.Title className="text-2xl font-bold mb-4">
|
||||||
|
{credentials ? 'Certificate Renewed' : 'Renew Certificate'}
|
||||||
|
</Dialog.Title>
|
||||||
|
|
||||||
|
{!credentials ? (
|
||||||
|
<>
|
||||||
|
<Dialog.Description className="text-base-content/70 mb-6">
|
||||||
|
This will generate a new certificate for <strong>{device.name}</strong>.
|
||||||
|
You will need to update the device with the new credentials.
|
||||||
|
</Dialog.Description>
|
||||||
|
<div className="flex justify-end gap-3">
|
||||||
|
<Dialog.Close asChild>
|
||||||
|
<button className="btn btn-ghost" disabled={renewMutation.isPending}>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
</Dialog.Close>
|
||||||
|
<button
|
||||||
|
className="btn btn-warning"
|
||||||
|
onClick={() => renewMutation.mutate()}
|
||||||
|
disabled={renewMutation.isPending}
|
||||||
|
>
|
||||||
|
{renewMutation.isPending ? (
|
||||||
|
<>
|
||||||
|
<span className="loading loading-spinner loading-sm"></span>
|
||||||
|
Renewing...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
'Renew Certificate'
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div className="alert alert-warning mb-4">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" className="stroke-current shrink-0 h-6 w-6" fill="none" viewBox="0 0 24 24">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
|
||||||
|
</svg>
|
||||||
|
<span>Save these credentials now! They will not be shown again.</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<CredentialsViewer credentials={credentials} deviceId={device.id} />
|
||||||
|
|
||||||
|
<div className="flex justify-end mt-6">
|
||||||
|
<Dialog.Close asChild>
|
||||||
|
<button className="btn btn-primary">Done</button>
|
||||||
|
</Dialog.Close>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Dialog.Content>
|
||||||
|
</Dialog.Portal>
|
||||||
|
</Dialog.Root>
|
||||||
|
)
|
||||||
|
}
|
||||||
74
frontend/src/components/RevokeDialog.tsx
Normal file
74
frontend/src/components/RevokeDialog.tsx
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
import * as AlertDialog from '@radix-ui/react-alert-dialog'
|
||||||
|
import { useMutation, useQueryClient } from '@tanstack/react-query'
|
||||||
|
import { devicesApi } from '../api'
|
||||||
|
import toast from 'react-hot-toast'
|
||||||
|
import type { AxiosError } from 'axios'
|
||||||
|
import type { Device } from '../types/api'
|
||||||
|
|
||||||
|
interface RevokeDialogProps {
|
||||||
|
device: Device
|
||||||
|
open: boolean
|
||||||
|
onOpenChange: (open: boolean) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function RevokeDialog({ device, open, onOpenChange }: RevokeDialogProps) {
|
||||||
|
const queryClient = useQueryClient()
|
||||||
|
|
||||||
|
const revokeMutation = useMutation({
|
||||||
|
mutationFn: () => devicesApi.revoke(device.id),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['devices'] })
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['device', device.id] })
|
||||||
|
toast.success(`Certificate for "${device.name}" revoked successfully`)
|
||||||
|
onOpenChange(false)
|
||||||
|
},
|
||||||
|
onError: (error) => {
|
||||||
|
const axiosError = error as AxiosError<{ detail?: string }>
|
||||||
|
const message = axiosError.response?.data?.detail || axiosError.message
|
||||||
|
toast.error(`Failed to revoke certificate: ${message}`)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AlertDialog.Root open={open} onOpenChange={onOpenChange}>
|
||||||
|
<AlertDialog.Portal>
|
||||||
|
<AlertDialog.Overlay className="fixed inset-0 bg-black/50 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0" />
|
||||||
|
<AlertDialog.Content className="fixed left-[50%] top-[50%] z-50 max-h-[85vh] w-[90vw] max-w-[500px] translate-x-[-50%] translate-y-[-50%] rounded-lg bg-base-100 p-6 shadow-xl">
|
||||||
|
<AlertDialog.Title className="text-2xl font-bold mb-2">
|
||||||
|
Revoke Certificate
|
||||||
|
</AlertDialog.Title>
|
||||||
|
<AlertDialog.Description className="text-base-content/70 mb-6">
|
||||||
|
Are you sure you want to revoke the certificate for <strong>{device.name}</strong>?
|
||||||
|
The device will no longer be able to connect until you renew its certificate.
|
||||||
|
</AlertDialog.Description>
|
||||||
|
<div className="flex justify-end gap-3">
|
||||||
|
<AlertDialog.Cancel asChild>
|
||||||
|
<button className="btn btn-ghost" disabled={revokeMutation.isPending}>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
</AlertDialog.Cancel>
|
||||||
|
<AlertDialog.Action asChild>
|
||||||
|
<button
|
||||||
|
className="btn btn-warning"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.preventDefault()
|
||||||
|
revokeMutation.mutate()
|
||||||
|
}}
|
||||||
|
disabled={revokeMutation.isPending}
|
||||||
|
>
|
||||||
|
{revokeMutation.isPending ? (
|
||||||
|
<>
|
||||||
|
<span className="loading loading-spinner loading-sm"></span>
|
||||||
|
Revoking...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
'Revoke Certificate'
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</AlertDialog.Action>
|
||||||
|
</div>
|
||||||
|
</AlertDialog.Content>
|
||||||
|
</AlertDialog.Portal>
|
||||||
|
</AlertDialog.Root>
|
||||||
|
)
|
||||||
|
}
|
||||||
84
frontend/src/components/dashboard/TelemetryTrendCard.tsx
Normal file
84
frontend/src/components/dashboard/TelemetryTrendCard.tsx
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
import { ResponsiveContainer, LineChart, Line, Tooltip, XAxis, YAxis, CartesianGrid } from 'recharts'
|
||||||
|
|
||||||
|
interface TelemetryTrendCardProps {
|
||||||
|
title: string
|
||||||
|
data: Array<{ time: string; value: number }>
|
||||||
|
unit?: string
|
||||||
|
accentColor?: string
|
||||||
|
subtitle?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatTimeLabel(timestamp: string) {
|
||||||
|
const date = new Date(timestamp)
|
||||||
|
return date.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatValue(value: number, unit?: string) {
|
||||||
|
const rounded = Number.isInteger(value) ? value : value.toFixed(1)
|
||||||
|
return unit ? `${rounded} ${unit}` : String(rounded)
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function TelemetryTrendCard({ title, data, unit, accentColor = '#2563eb', subtitle }: TelemetryTrendCardProps) {
|
||||||
|
const latest = data.at(-1)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="card bg-base-100 shadow-xl">
|
||||||
|
<div className="card-body gap-4">
|
||||||
|
<div className="flex items-start justify-between">
|
||||||
|
<div>
|
||||||
|
<h3 className="text-lg font-semibold">{title}</h3>
|
||||||
|
{subtitle && <p className="text-sm text-base-content/60">{subtitle}</p>}
|
||||||
|
</div>
|
||||||
|
{latest ? (
|
||||||
|
<div className="text-right">
|
||||||
|
<div className="text-3xl font-bold text-primary">
|
||||||
|
{formatValue(latest.value, unit)}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-base-content/60">as of {formatTimeLabel(latest.time)}</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="text-sm text-base-content/60">No data</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="h-48">
|
||||||
|
{data.length > 1 ? (
|
||||||
|
<ResponsiveContainer width="100%" height="100%">
|
||||||
|
<LineChart data={data} margin={{ top: 10, right: 20, left: 0, bottom: 0 }}>
|
||||||
|
<CartesianGrid strokeDasharray="3 3" stroke="hsl(var(--bc) / 0.1)" />
|
||||||
|
<XAxis
|
||||||
|
dataKey="time"
|
||||||
|
tickFormatter={formatTimeLabel}
|
||||||
|
tick={{ fontSize: 12, fill: '#ffffff' }}
|
||||||
|
stroke="rgba(255, 255, 255, 0.3)"
|
||||||
|
/>
|
||||||
|
<YAxis
|
||||||
|
tickFormatter={(val) => formatValue(val, unit)}
|
||||||
|
width={48}
|
||||||
|
tick={{ fontSize: 12, fill: '#ffffff' }}
|
||||||
|
stroke="rgba(255, 255, 255, 0.3)"
|
||||||
|
/>
|
||||||
|
<Tooltip
|
||||||
|
formatter={(value: number) => formatValue(value, unit)}
|
||||||
|
labelFormatter={(label) => formatTimeLabel(String(label))}
|
||||||
|
/>
|
||||||
|
<Line
|
||||||
|
type="monotone"
|
||||||
|
dataKey="value"
|
||||||
|
stroke={accentColor}
|
||||||
|
strokeWidth={2}
|
||||||
|
dot={false}
|
||||||
|
activeDot={{ r: 4 }}
|
||||||
|
/>
|
||||||
|
</LineChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
) : (
|
||||||
|
<div className="flex h-full items-center justify-center text-sm text-base-content/60">
|
||||||
|
Not enough telemetry to chart yet
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
441
frontend/src/components/widgets/AiInsightWidget.tsx
Normal file
441
frontend/src/components/widgets/AiInsightWidget.tsx
Normal file
@@ -0,0 +1,441 @@
|
|||||||
|
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 metrics = metricIds // Use ALL selected metrics
|
||||||
|
|
||||||
|
const {
|
||||||
|
data: analysis,
|
||||||
|
isLoading,
|
||||||
|
refetch,
|
||||||
|
error,
|
||||||
|
} = useQuery({
|
||||||
|
queryKey: ['ai-insight', deviceId, metrics, promptType, timeframe],
|
||||||
|
queryFn: async () => {
|
||||||
|
const response = await telemetryApi.analyze({
|
||||||
|
device_id: deviceId,
|
||||||
|
metrics: metrics, // Send all metrics
|
||||||
|
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>
|
||||||
|
)
|
||||||
|
}
|
||||||
141
frontend/src/components/widgets/AirQualityWidget.tsx
Normal file
141
frontend/src/components/widgets/AirQualityWidget.tsx
Normal file
@@ -0,0 +1,141 @@
|
|||||||
|
import { useQuery } from '@tanstack/react-query'
|
||||||
|
import type { WidgetConfig } from '../../hooks'
|
||||||
|
import { weatherApi } from '../../api'
|
||||||
|
import './widget-styles.css'
|
||||||
|
|
||||||
|
interface AirQualityWidgetProps {
|
||||||
|
config: WidgetConfig
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function AirQualityWidget({ config }: AirQualityWidgetProps) {
|
||||||
|
// Get city from config or use default (Pulse.eco city)
|
||||||
|
const city = (config.visualization as Record<string, unknown>)?.city as string || 'skopje'
|
||||||
|
|
||||||
|
const { data: airQuality, isLoading, error } = useQuery({
|
||||||
|
queryKey: ['air-quality', city],
|
||||||
|
queryFn: async () => {
|
||||||
|
const response = await weatherApi.getAirQuality(city)
|
||||||
|
return response.data
|
||||||
|
},
|
||||||
|
refetchInterval: 5000, // Refresh every 5 seconds
|
||||||
|
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>
|
||||||
|
)
|
||||||
|
}
|
||||||
308
frontend/src/components/widgets/CalendarWidget.tsx
Normal file
308
frontend/src/components/widgets/CalendarWidget.tsx
Normal file
@@ -0,0 +1,308 @@
|
|||||||
|
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>
|
||||||
|
)
|
||||||
|
}
|
||||||
209
frontend/src/components/widgets/ComfortIndexWidget.tsx
Normal file
209
frontend/src/components/widgets/ComfortIndexWidget.tsx
Normal file
@@ -0,0 +1,209 @@
|
|||||||
|
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: 5000, // Refresh every 5 seconds
|
||||||
|
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>
|
||||||
|
)
|
||||||
|
}
|
||||||
266
frontend/src/components/widgets/DailyBriefingWidget.tsx
Normal file
266
frontend/src/components/widgets/DailyBriefingWidget.tsx
Normal file
@@ -0,0 +1,266 @@
|
|||||||
|
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>
|
||||||
|
)
|
||||||
|
}
|
||||||
103
frontend/src/components/widgets/GaugeWidget.tsx
Normal file
103
frontend/src/components/widgets/GaugeWidget.tsx
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
import { useTelemetrySeries } from '../../hooks'
|
||||||
|
import type { WidgetConfig } from '../../hooks'
|
||||||
|
|
||||||
|
interface GaugeWidgetProps {
|
||||||
|
config: WidgetConfig
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function GaugeWidget({ config }: GaugeWidgetProps) {
|
||||||
|
const { deviceIds, metricIds, timeframe, title } = config
|
||||||
|
|
||||||
|
const deviceId = deviceIds[0]
|
||||||
|
const metric = metricIds[0]
|
||||||
|
|
||||||
|
const { data, isLoading, error } = useTelemetrySeries({
|
||||||
|
deviceId,
|
||||||
|
metric,
|
||||||
|
hours: timeframe.hours,
|
||||||
|
startTime: timeframe.startTime,
|
||||||
|
endTime: timeframe.endTime,
|
||||||
|
limit: 1,
|
||||||
|
})
|
||||||
|
|
||||||
|
const latest = data[0]
|
||||||
|
const value = latest?.value || 0
|
||||||
|
|
||||||
|
// Simple gauge ranges (could be configured per metric)
|
||||||
|
const ranges = {
|
||||||
|
low: 33,
|
||||||
|
medium: 66,
|
||||||
|
high: 100,
|
||||||
|
}
|
||||||
|
|
||||||
|
const percentage = Math.min(100, Math.max(0, value))
|
||||||
|
let color = 'text-success'
|
||||||
|
if (percentage > ranges.medium) color = 'text-error'
|
||||||
|
else if (percentage > ranges.low) color = 'text-warning'
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<div className="card bg-base-200 animate-pulse">
|
||||||
|
<div className="card-body h-48"></div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<div className="card bg-error/10">
|
||||||
|
<div className="card-body">
|
||||||
|
<p className="text-error text-sm">Error: {error.message}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="card bg-base-100 shadow">
|
||||||
|
<div className="card-body items-center text-center">
|
||||||
|
<h3 className="card-title text-base">{title || metric}</h3>
|
||||||
|
<div className="relative w-32 h-32 mt-4">
|
||||||
|
<svg className="w-full h-full transform -rotate-90">
|
||||||
|
<circle
|
||||||
|
cx="64"
|
||||||
|
cy="64"
|
||||||
|
r="56"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="8"
|
||||||
|
fill="none"
|
||||||
|
className="text-base-300"
|
||||||
|
/>
|
||||||
|
<circle
|
||||||
|
cx="64"
|
||||||
|
cy="64"
|
||||||
|
r="56"
|
||||||
|
stroke="currentColor"
|
||||||
|
strokeWidth="8"
|
||||||
|
fill="none"
|
||||||
|
strokeDasharray={`${2 * Math.PI * 56}`}
|
||||||
|
strokeDashoffset={`${2 * Math.PI * 56 * (1 - percentage / 100)}`}
|
||||||
|
className={color}
|
||||||
|
strokeLinecap="round"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
<div className="absolute inset-0 flex items-center justify-center">
|
||||||
|
<div>
|
||||||
|
<div className={`text-3xl font-bold ${color}`}>
|
||||||
|
{value.toFixed(1)}
|
||||||
|
</div>
|
||||||
|
{latest?.unit && (
|
||||||
|
<div className="text-sm text-base-content/60">{latest.unit}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{latest && (
|
||||||
|
<div className="text-xs text-base-content/50 mt-4">
|
||||||
|
Updated {new Date(latest.time).toLocaleTimeString()}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
178
frontend/src/components/widgets/HealthStatsWidget.tsx
Normal file
178
frontend/src/components/widgets/HealthStatsWidget.tsx
Normal file
@@ -0,0 +1,178 @@
|
|||||||
|
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: 5000, // Refresh every 5 seconds
|
||||||
|
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>
|
||||||
|
)
|
||||||
|
}
|
||||||
284
frontend/src/components/widgets/LineChartWidget.tsx
Normal file
284
frontend/src/components/widgets/LineChartWidget.tsx
Normal file
@@ -0,0 +1,284 @@
|
|||||||
|
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)
|
||||||
162
frontend/src/components/widgets/RunSuitabilityWidget.tsx
Normal file
162
frontend/src/components/widgets/RunSuitabilityWidget.tsx
Normal file
@@ -0,0 +1,162 @@
|
|||||||
|
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: 5000, // Refresh every 5 seconds
|
||||||
|
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>
|
||||||
|
)
|
||||||
|
}
|
||||||
78
frontend/src/components/widgets/StatWidget.tsx
Normal file
78
frontend/src/components/widgets/StatWidget.tsx
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
import { useTelemetrySeries } from '../../hooks'
|
||||||
|
import type { WidgetConfig } from '../../hooks'
|
||||||
|
import { formatMetricName } from '../../utils/formatters'
|
||||||
|
|
||||||
|
interface StatWidgetProps {
|
||||||
|
config: WidgetConfig
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function StatWidgetProps({ config }: StatWidgetProps) {
|
||||||
|
const { deviceIds, metricIds, timeframe, title } = config
|
||||||
|
|
||||||
|
const deviceId = deviceIds[0]
|
||||||
|
const metric = metricIds[0]
|
||||||
|
|
||||||
|
const { data, isLoading, error } = useTelemetrySeries({
|
||||||
|
deviceId,
|
||||||
|
metric,
|
||||||
|
hours: timeframe.hours,
|
||||||
|
startTime: timeframe.startTime,
|
||||||
|
endTime: timeframe.endTime,
|
||||||
|
limit: 100,
|
||||||
|
})
|
||||||
|
|
||||||
|
const latest = data[0]
|
||||||
|
const values = data.map((d) => d.value)
|
||||||
|
const average = values.length > 0 ? values.reduce((a, b) => a + b, 0) / values.length : 0
|
||||||
|
const min = values.length > 0 ? Math.min(...values) : 0
|
||||||
|
const max = values.length > 0 ? Math.max(...values) : 0
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<div className="card bg-base-200 animate-pulse">
|
||||||
|
<div className="card-body h-32"></div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<div className="card bg-error/10">
|
||||||
|
<div className="card-body">
|
||||||
|
<p className="text-error text-sm">Error: {error.message}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="card bg-base-100 shadow">
|
||||||
|
<div className="card-body">
|
||||||
|
<div className="text-sm uppercase tracking-wide text-base-content/60">
|
||||||
|
{title || formatMetricName(metric)}
|
||||||
|
</div>
|
||||||
|
<div className="text-4xl font-bold text-primary">
|
||||||
|
{latest ? latest.value.toFixed(1) : '—'}
|
||||||
|
{latest?.unit && <span className="text-xl ml-2">{latest.unit}</span>}
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-3 gap-2 mt-4 text-sm">
|
||||||
|
<div>
|
||||||
|
<div className="text-base-content/60">Min</div>
|
||||||
|
<div className="font-semibold">{min.toFixed(1)}</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="text-base-content/60">Avg</div>
|
||||||
|
<div className="font-semibold">{average.toFixed(1)}</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="text-base-content/60">Max</div>
|
||||||
|
<div className="font-semibold">{max.toFixed(1)}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-base-content/50 mt-2">
|
||||||
|
{data.length} readings in last {timeframe.hours || 24}h
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
222
frontend/src/components/widgets/WeatherWidget.tsx
Normal file
222
frontend/src/components/widgets/WeatherWidget.tsx
Normal file
@@ -0,0 +1,222 @@
|
|||||||
|
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: 5000, // Refresh every 5 seconds
|
||||||
|
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>
|
||||||
|
)
|
||||||
|
}
|
||||||
132
frontend/src/components/widgets/WidgetContainer.tsx
Normal file
132
frontend/src/components/widgets/WidgetContainer.tsx
Normal file
@@ -0,0 +1,132 @@
|
|||||||
|
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)
|
||||||
7
frontend/src/components/widgets/index.ts
Normal file
7
frontend/src/components/widgets/index.ts
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
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'
|
||||||
33
frontend/src/components/widgets/registry.ts
Normal file
33
frontend/src/components/widgets/registry.ts
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
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,
|
||||||
|
}
|
||||||
58
frontend/src/components/widgets/widget-styles.css
Normal file
58
frontend/src/components/widgets/widget-styles.css
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
/* 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;
|
||||||
|
}
|
||||||
50
frontend/src/contexts/AuthContext.tsx
Normal file
50
frontend/src/contexts/AuthContext.tsx
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
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
|
||||||
|
}
|
||||||
4
frontend/src/hooks/index.ts
Normal file
4
frontend/src/hooks/index.ts
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
export { useTelemetrySeries } from './useTelemetrySeries'
|
||||||
|
export { useDeviceMetrics, useAllMetrics } from './useDeviceMetrics'
|
||||||
|
export { useDashboardConfig } from './useDashboardConfig'
|
||||||
|
export type { WidgetConfig, DashboardConfig, WidgetType } from './useDashboardConfig'
|
||||||
245
frontend/src/hooks/useDashboardConfig.ts
Normal file
245
frontend/src/hooks/useDashboardConfig.ts
Normal file
@@ -0,0 +1,245 @@
|
|||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Auto-save to localStorage and debounced backend save
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isInitialLoadRef.current) {
|
||||||
|
// Save to localStorage immediately
|
||||||
|
try {
|
||||||
|
localStorage.setItem(STORAGE_KEY, JSON.stringify(config))
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to save to localStorage:', error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Auto-save to backend with debounce (2 seconds after last change)
|
||||||
|
const timeoutId = setTimeout(async () => {
|
||||||
|
try {
|
||||||
|
if (layoutId) {
|
||||||
|
await dashboardLayoutApi.update(layoutId, {
|
||||||
|
config: config,
|
||||||
|
})
|
||||||
|
console.log('Dashboard auto-saved to backend')
|
||||||
|
} else {
|
||||||
|
const response = await dashboardLayoutApi.create({
|
||||||
|
name: 'default',
|
||||||
|
config: config,
|
||||||
|
is_default: true,
|
||||||
|
})
|
||||||
|
setLayoutId(response.data.id)
|
||||||
|
console.log('Dashboard created and auto-saved to backend')
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to auto-save to backend:', error)
|
||||||
|
}
|
||||||
|
}, 2000)
|
||||||
|
|
||||||
|
return () => clearTimeout(timeoutId)
|
||||||
|
}
|
||||||
|
}, [config, layoutId])
|
||||||
|
|
||||||
|
|
||||||
|
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,
|
||||||
|
}
|
||||||
|
}
|
||||||
54
frontend/src/hooks/useDeviceMetrics.ts
Normal file
54
frontend/src/hooks/useDeviceMetrics.ts
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
import { useQuery } from '@tanstack/react-query'
|
||||||
|
import { devicesApi, telemetryApi } from '../api'
|
||||||
|
|
||||||
|
interface DeviceMetricsResult {
|
||||||
|
deviceId: string
|
||||||
|
deviceName: string
|
||||||
|
metrics: string[]
|
||||||
|
isLoading: boolean
|
||||||
|
error: Error | null
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook to fetch available metrics for a specific device
|
||||||
|
*/
|
||||||
|
export function useDeviceMetrics(deviceId: string | undefined): DeviceMetricsResult {
|
||||||
|
const { data, isLoading, error } = useQuery({
|
||||||
|
queryKey: ['device', deviceId, 'metrics'],
|
||||||
|
queryFn: async () => {
|
||||||
|
if (!deviceId) return null
|
||||||
|
const response = await devicesApi.getMetrics(deviceId)
|
||||||
|
return response.data
|
||||||
|
},
|
||||||
|
enabled: !!deviceId,
|
||||||
|
staleTime: 60000, // Cache for 1 minute
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
deviceId: data?.device_id || '',
|
||||||
|
deviceName: data?.device_name || '',
|
||||||
|
metrics: data?.metrics || [],
|
||||||
|
isLoading,
|
||||||
|
error: error as Error | null,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Hook to fetch all available metrics across all devices
|
||||||
|
*/
|
||||||
|
export function useAllMetrics() {
|
||||||
|
const { data, isLoading, error } = useQuery({
|
||||||
|
queryKey: ['telemetry', 'metrics'],
|
||||||
|
queryFn: async () => {
|
||||||
|
const response = await telemetryApi.getMetrics()
|
||||||
|
return response.data.metrics
|
||||||
|
},
|
||||||
|
staleTime: 60000,
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
metrics: data || [],
|
||||||
|
isLoading,
|
||||||
|
error: error as Error | null,
|
||||||
|
}
|
||||||
|
}
|
||||||
155
frontend/src/hooks/useGridstack.ts
Normal file
155
frontend/src/hooks/useGridstack.ts
Normal file
@@ -0,0 +1,155 @@
|
|||||||
|
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,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
76
frontend/src/hooks/useTelemetrySeries.ts
Normal file
76
frontend/src/hooks/useTelemetrySeries.ts
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
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 ? 5000 : false, // Refresh every 5 seconds
|
||||||
|
staleTime: 3000, // Consider data fresh for 3 seconds
|
||||||
|
})
|
||||||
|
|
||||||
|
return {
|
||||||
|
data: data || [],
|
||||||
|
isLoading,
|
||||||
|
isFetching,
|
||||||
|
error: error as Error | null,
|
||||||
|
refetch,
|
||||||
|
}
|
||||||
|
}
|
||||||
40
frontend/src/hooks/useWellnessState.tsx
Normal file
40
frontend/src/hooks/useWellnessState.tsx
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
134
frontend/src/index.css
Normal file
134
frontend/src/index.css
Normal file
@@ -0,0 +1,134 @@
|
|||||||
|
@import "tailwindcss";
|
||||||
|
@plugin "daisyui";
|
||||||
|
|
||||||
|
/* DaisyUI theme configuration */
|
||||||
|
@theme {
|
||||||
|
--dui-themes: light, dark, cupcake, corporate;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Ensure full viewport height */
|
||||||
|
html,
|
||||||
|
body,
|
||||||
|
#root {
|
||||||
|
height: 100%;
|
||||||
|
min-height: 100vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
58
frontend/src/lib/api-client.ts
Normal file
58
frontend/src/lib/api-client.ts
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
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;
|
||||||
29
frontend/src/lib/utils.ts
Normal file
29
frontend/src/lib/utils.ts
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
import { type ClassValue, clsx } from "clsx"
|
||||||
|
import { twMerge } from "tailwind-merge"
|
||||||
|
|
||||||
|
export function cn(...inputs: ClassValue[]) {
|
||||||
|
return twMerge(clsx(inputs))
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatDate(date: Date | string): string {
|
||||||
|
const d = typeof date === 'string' ? new Date(date) : date
|
||||||
|
return d.toLocaleDateString() + ' ' + d.toLocaleTimeString()
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatRelativeTime(date: Date | string): string {
|
||||||
|
const d = typeof date === 'string' ? new Date(date) : date
|
||||||
|
const now = new Date()
|
||||||
|
const diff = now.getTime() - d.getTime()
|
||||||
|
|
||||||
|
const seconds = Math.floor(diff / 1000)
|
||||||
|
const minutes = Math.floor(seconds / 60)
|
||||||
|
const hours = Math.floor(minutes / 60)
|
||||||
|
const days = Math.floor(hours / 24)
|
||||||
|
|
||||||
|
if (seconds < 60) return `${seconds}s ago`
|
||||||
|
if (minutes < 60) return `${minutes}m ago`
|
||||||
|
if (hours < 24) return `${hours}h ago`
|
||||||
|
if (days < 30) return `${days}d ago`
|
||||||
|
|
||||||
|
return formatDate(d)
|
||||||
|
}
|
||||||
10
frontend/src/main.tsx
Normal file
10
frontend/src/main.tsx
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
import { StrictMode } from 'react'
|
||||||
|
import { createRoot } from 'react-dom/client'
|
||||||
|
import './index.css'
|
||||||
|
import App from './App.tsx'
|
||||||
|
|
||||||
|
createRoot(document.getElementById('root')!).render(
|
||||||
|
<StrictMode>
|
||||||
|
<App />
|
||||||
|
</StrictMode>,
|
||||||
|
)
|
||||||
160
frontend/src/pages/AddDevice.tsx
Normal file
160
frontend/src/pages/AddDevice.tsx
Normal file
@@ -0,0 +1,160 @@
|
|||||||
|
import { Link } from 'react-router-dom'
|
||||||
|
import { useState } from 'react'
|
||||||
|
import { useForm } from 'react-hook-form'
|
||||||
|
import { useMutation, useQueryClient } from '@tanstack/react-query'
|
||||||
|
import toast from 'react-hot-toast'
|
||||||
|
import type { AxiosError } from 'axios'
|
||||||
|
import { devicesApi } from '../api'
|
||||||
|
import DeviceCredentialsDialog from '../components/DeviceCredentialsDialog'
|
||||||
|
import type { DeviceRegistrationRequest, DeviceRegistrationResponse } from '../types/api'
|
||||||
|
|
||||||
|
type DeviceRegistrationForm = DeviceRegistrationRequest
|
||||||
|
|
||||||
|
export default function AddDevice() {
|
||||||
|
const queryClient = useQueryClient()
|
||||||
|
const {
|
||||||
|
register,
|
||||||
|
handleSubmit,
|
||||||
|
formState: { errors },
|
||||||
|
reset,
|
||||||
|
} = useForm<DeviceRegistrationForm>({
|
||||||
|
defaultValues: {
|
||||||
|
protocol: 'mqtt',
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const [credentials, setCredentials] = useState<DeviceRegistrationResponse | null>(null)
|
||||||
|
const [credentialsOpen, setCredentialsOpen] = useState(false)
|
||||||
|
|
||||||
|
const registerMutation = useMutation({
|
||||||
|
mutationFn: (payload: DeviceRegistrationRequest) => devicesApi.create(payload),
|
||||||
|
onSuccess: (response) => {
|
||||||
|
setCredentials(response.data)
|
||||||
|
setCredentialsOpen(true)
|
||||||
|
toast.success('Device registered successfully')
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['devices'] })
|
||||||
|
reset({ name: '', location: '', protocol: 'mqtt' })
|
||||||
|
},
|
||||||
|
onError: (error) => {
|
||||||
|
const axiosError = error as AxiosError<{ detail?: string }>
|
||||||
|
const message = axiosError.response?.data?.detail || axiosError.message
|
||||||
|
toast.error(`Failed to register device: ${message}`)
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const onSubmit = (data: DeviceRegistrationForm) => {
|
||||||
|
if (data.protocol !== 'mqtt') {
|
||||||
|
toast.error('Only MQTT devices are supported right now')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
registerMutation.mutate({
|
||||||
|
name: data.name.trim(),
|
||||||
|
location: data.location?.trim() || undefined,
|
||||||
|
protocol: 'mqtt',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="p-6">
|
||||||
|
<div className="mb-6">
|
||||||
|
<Link to="/devices" className="btn btn-ghost btn-sm mb-4">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5 mr-2" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 19l-7-7m0 0l7-7m-7 7h18" />
|
||||||
|
</svg>
|
||||||
|
Back to Devices
|
||||||
|
</Link>
|
||||||
|
<h1 className="text-3xl font-bold">Add New Device</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="card bg-base-100 shadow-xl max-w-2xl">
|
||||||
|
<div className="card-body">
|
||||||
|
<h2 className="card-title">Device Registration</h2>
|
||||||
|
<p className="text-sm opacity-70 mb-4">
|
||||||
|
Register a new IoT device. For MQTT devices, a certificate will be automatically generated.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<form className="space-y-4" onSubmit={handleSubmit(onSubmit)}>
|
||||||
|
<div className="form-control">
|
||||||
|
<label className="label">
|
||||||
|
<span className="label-text font-semibold">Device Name *</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="e.g., Office Temperature Sensor"
|
||||||
|
className={`input input-bordered w-full ${errors.name ? 'input-error' : ''}`}
|
||||||
|
{...register('name', { required: 'Device name is required' })}
|
||||||
|
/>
|
||||||
|
{errors.name && (
|
||||||
|
<label className="label">
|
||||||
|
<span className="label-text-alt text-error">{errors.name.message}</span>
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="form-control">
|
||||||
|
<label className="label">
|
||||||
|
<span className="label-text font-semibold">Location</span>
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="e.g., Office Room 101"
|
||||||
|
className="input input-bordered w-full"
|
||||||
|
{...register('location')}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="form-control">
|
||||||
|
<label className="label">
|
||||||
|
<span className="label-text font-semibold">Protocol *</span>
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
className="select select-bordered w-full"
|
||||||
|
{...register('protocol')}
|
||||||
|
>
|
||||||
|
<option value="mqtt">MQTT (with mTLS)</option>
|
||||||
|
<option value="http" disabled>
|
||||||
|
HTTP (coming soon)
|
||||||
|
</option>
|
||||||
|
<option value="webhook" disabled>
|
||||||
|
Webhook (coming soon)
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
<label className="label">
|
||||||
|
<span className="label-text-alt">MQTT devices will receive a certificate for secure communication</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="card-actions justify-end mt-6">
|
||||||
|
<Link to="/devices" className="btn btn-ghost">
|
||||||
|
Cancel
|
||||||
|
</Link>
|
||||||
|
<button type="submit" className="btn btn-primary" disabled={registerMutation.isPending}>
|
||||||
|
{registerMutation.isPending ? (
|
||||||
|
<>
|
||||||
|
<span className="loading loading-spinner loading-sm" />
|
||||||
|
Registering...
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
'Register Device'
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DeviceCredentialsDialog
|
||||||
|
open={credentialsOpen}
|
||||||
|
credentials={credentials}
|
||||||
|
deviceName={credentials?.device_id}
|
||||||
|
onOpenChange={(open) => {
|
||||||
|
setCredentialsOpen(open)
|
||||||
|
if (!open) {
|
||||||
|
setCredentials(null)
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
290
frontend/src/pages/Dashboard.tsx
Normal file
290
frontend/src/pages/Dashboard.tsx
Normal file
@@ -0,0 +1,290 @@
|
|||||||
|
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 min-h-screen">
|
||||||
|
<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">
|
||||||
|
<div className="text-sm text-base-content/60">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" className="h-4 w-4 inline-block" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
|
||||||
|
</svg>
|
||||||
|
Auto-save enabled
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
className="btn btn-outline 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 Now
|
||||||
|
</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>
|
||||||
|
)
|
||||||
|
}
|
||||||
187
frontend/src/pages/DeviceDetail.tsx
Normal file
187
frontend/src/pages/DeviceDetail.tsx
Normal file
@@ -0,0 +1,187 @@
|
|||||||
|
import { useParams, Link, useNavigate } from 'react-router-dom'
|
||||||
|
import { useQuery } from '@tanstack/react-query'
|
||||||
|
import { useState } from 'react'
|
||||||
|
import { devicesApi } from '../api'
|
||||||
|
import DeleteDeviceDialog from '../components/DeleteDeviceDialog'
|
||||||
|
import RenewDialog from '../components/RenewDialog'
|
||||||
|
import RevokeDialog from '../components/RevokeDialog'
|
||||||
|
|
||||||
|
export default function DeviceDetail() {
|
||||||
|
const { id } = useParams<{ id: string }>()
|
||||||
|
const navigate = useNavigate()
|
||||||
|
const [deleteOpen, setDeleteOpen] = useState(false)
|
||||||
|
const [renewOpen, setRenewOpen] = useState(false)
|
||||||
|
const [revokeOpen, setRevokeOpen] = useState(false)
|
||||||
|
|
||||||
|
const { data: device, isLoading } = useQuery({
|
||||||
|
queryKey: ['device', id],
|
||||||
|
queryFn: async () => {
|
||||||
|
const response = await devicesApi.getOne(id!)
|
||||||
|
return response.data
|
||||||
|
},
|
||||||
|
enabled: !!id,
|
||||||
|
})
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center min-h-screen">
|
||||||
|
<span className="loading loading-spinner loading-lg"></span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!device) {
|
||||||
|
return (
|
||||||
|
<div className="p-6">
|
||||||
|
<div className="alert alert-error">
|
||||||
|
<span>Device not found</span>
|
||||||
|
</div>
|
||||||
|
<Link to="/devices" className="btn btn-ghost mt-4">
|
||||||
|
Back to Device List
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="p-6">
|
||||||
|
<div className="mb-6">
|
||||||
|
<Link to="/devices" className="btn btn-ghost btn-sm mb-4">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5 mr-2" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 19l-7-7m0 0l7-7m-7 7h18" />
|
||||||
|
</svg>
|
||||||
|
Back to Devices
|
||||||
|
</Link>
|
||||||
|
<h1 className="text-3xl font-bold">Device Details</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="card bg-base-100 shadow-xl">
|
||||||
|
<div className="card-body">
|
||||||
|
<h2 className="card-title text-2xl mb-4">{device.name}</h2>
|
||||||
|
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="table">
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<th className="w-1/3">Device ID:</th>
|
||||||
|
<td><code className="bg-base-200 px-3 py-1 rounded">{device.id}</code></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<th>Location:</th>
|
||||||
|
<td>{device.location || '—'}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<th>Protocol:</th>
|
||||||
|
<td>
|
||||||
|
<div className="badge badge-info">{device.protocol.toUpperCase()}</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<th>Status:</th>
|
||||||
|
<td>
|
||||||
|
<div className={`badge ${device.is_active ? 'badge-success' : 'badge-ghost'}`}>
|
||||||
|
{device.is_active ? 'Active' : 'Inactive'}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<th>Created:</th>
|
||||||
|
<td>{new Date(device.created_at).toLocaleString()}</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Certificate Information for MQTT devices */}
|
||||||
|
{device.protocol === 'mqtt' && device.active_certificate && (
|
||||||
|
<div className="mt-6">
|
||||||
|
<h3 className="text-xl font-bold mb-4">Certificate Information</h3>
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="table">
|
||||||
|
<tbody>
|
||||||
|
<tr>
|
||||||
|
<th className="w-1/3">Certificate ID:</th>
|
||||||
|
<td><code className="bg-base-200 px-3 py-1 rounded">{device.active_certificate.id}</code></td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<th>Issued At:</th>
|
||||||
|
<td>{new Date(device.active_certificate.issued_at).toLocaleString()}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<th>Expires At:</th>
|
||||||
|
<td>{new Date(device.active_certificate.expires_at).toLocaleString()}</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<th>Days Until Expiry:</th>
|
||||||
|
<td>
|
||||||
|
<span className={`font-semibold ${
|
||||||
|
device.active_certificate.days_until_expiry < 30 ? 'text-warning' :
|
||||||
|
device.active_certificate.days_until_expiry < 7 ? 'text-error' :
|
||||||
|
'text-success'
|
||||||
|
}`}>
|
||||||
|
{device.active_certificate.days_until_expiry} days
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
<tr>
|
||||||
|
<th>Status:</th>
|
||||||
|
<td>
|
||||||
|
{device.active_certificate.revoked_at ? (
|
||||||
|
<div className="badge badge-error">Revoked</div>
|
||||||
|
) : device.active_certificate.is_expired ? (
|
||||||
|
<div className="badge badge-error">Expired</div>
|
||||||
|
) : device.active_certificate.is_expiring_soon ? (
|
||||||
|
<div className="badge badge-warning">Expiring Soon</div>
|
||||||
|
) : (
|
||||||
|
<div className="badge badge-success">Active</div>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Action Buttons */}
|
||||||
|
<div className="card-actions justify-end mt-6">
|
||||||
|
{device.protocol === 'mqtt' && (
|
||||||
|
<>
|
||||||
|
<button className="btn btn-outline btn-warning" onClick={() => setRenewOpen(true)}>
|
||||||
|
Renew Certificate
|
||||||
|
</button>
|
||||||
|
<button className="btn btn-outline btn-error" onClick={() => setRevokeOpen(true)}>
|
||||||
|
Revoke Certificate
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<button className="btn btn-error" onClick={() => setDeleteOpen(true)}>
|
||||||
|
Delete Device
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DeleteDeviceDialog
|
||||||
|
device={device}
|
||||||
|
open={deleteOpen}
|
||||||
|
onOpenChange={(open) => setDeleteOpen(open)}
|
||||||
|
onDeleted={() => navigate('/devices')}
|
||||||
|
/>
|
||||||
|
{device.protocol === 'mqtt' && (
|
||||||
|
<>
|
||||||
|
<RenewDialog
|
||||||
|
device={device}
|
||||||
|
open={renewOpen}
|
||||||
|
onOpenChange={(open) => setRenewOpen(open)}
|
||||||
|
/>
|
||||||
|
<RevokeDialog
|
||||||
|
device={device}
|
||||||
|
open={revokeOpen}
|
||||||
|
onOpenChange={(open) => setRevokeOpen(open)}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
280
frontend/src/pages/DeviceList.tsx
Normal file
280
frontend/src/pages/DeviceList.tsx
Normal file
@@ -0,0 +1,280 @@
|
|||||||
|
import { Link } from 'react-router-dom'
|
||||||
|
import { useQuery } from '@tanstack/react-query'
|
||||||
|
import { useState } from 'react'
|
||||||
|
import { dashboardApi, devicesApi } from '../api'
|
||||||
|
import type { DashboardOverview, Device } from '../types/api'
|
||||||
|
import DeleteDeviceDialog from '../components/DeleteDeviceDialog'
|
||||||
|
import RevokeDialog from '../components/RevokeDialog'
|
||||||
|
import RenewDialog from '../components/RenewDialog'
|
||||||
|
|
||||||
|
export default function DeviceList() {
|
||||||
|
const [deleteDevice, setDeleteDevice] = useState<Device | null>(null)
|
||||||
|
const [revokeDevice, setRevokeDevice] = useState<Device | null>(null)
|
||||||
|
const [renewDevice, setRenewDevice] = useState<Device | null>(null)
|
||||||
|
const { data: devicesData, isLoading: devicesLoading } = useQuery({
|
||||||
|
queryKey: ['devices'],
|
||||||
|
queryFn: async () => {
|
||||||
|
const response = await devicesApi.getAll()
|
||||||
|
return response.data
|
||||||
|
},
|
||||||
|
})
|
||||||
|
|
||||||
|
const { data: overview } = useQuery({
|
||||||
|
queryKey: ['dashboard', 'overview'],
|
||||||
|
queryFn: async (): Promise<DashboardOverview> => {
|
||||||
|
const response = await dashboardApi.getOverview()
|
||||||
|
return response.data
|
||||||
|
},
|
||||||
|
staleTime: 5000,
|
||||||
|
})
|
||||||
|
|
||||||
|
const devices = devicesData?.results || []
|
||||||
|
|
||||||
|
if (devicesLoading) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center min-h-screen">
|
||||||
|
<span className="loading loading-spinner loading-lg"></span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="p-6">
|
||||||
|
<div className="flex justify-between items-center mb-6">
|
||||||
|
<h1 className="text-3xl font-bold">Manage Devices</h1>
|
||||||
|
<Link to="/devices/add" className="btn btn-primary">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5 mr-2" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
|
||||||
|
</svg>
|
||||||
|
Add Device
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<section className="mb-8 space-y-4">
|
||||||
|
<h2 className="text-xl font-semibold">System Health</h2>
|
||||||
|
{overview ? (
|
||||||
|
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-4">
|
||||||
|
<div className="stats shadow">
|
||||||
|
<div className="stat">
|
||||||
|
<div className="stat-figure text-primary">
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
className="inline-block w-8 h-8 stroke-current"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth="2"
|
||||||
|
d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div className="stat-title">Total Devices</div>
|
||||||
|
<div className="stat-value text-primary">{overview.total_devices}</div>
|
||||||
|
<div className="stat-desc">Registered in system</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="stats shadow">
|
||||||
|
<div className="stat">
|
||||||
|
<div className="stat-figure text-success">
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
className="inline-block w-8 h-8 stroke-current"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth="2"
|
||||||
|
d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div className="stat-title">Active Devices</div>
|
||||||
|
<div className="stat-value text-success">{overview.active_devices}</div>
|
||||||
|
<div className="stat-desc">Currently online</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="stats shadow">
|
||||||
|
<div className="stat">
|
||||||
|
<div className="stat-figure text-secondary">
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
className="inline-block w-8 h-8 stroke-current"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth="2"
|
||||||
|
d="M8.111 16.404a5.5 5.5 0 017.778 0M12 20h.01m-7.08-7.071c3.904-3.905 10.236-3.905 14.141 0M1.394 9.393c5.857-5.857 15.355-15.857 21.213 0"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div className="stat-title">MQTT Devices</div>
|
||||||
|
<div className="stat-value text-secondary">{overview.mqtt_devices}</div>
|
||||||
|
<div className="stat-desc">Using mTLS</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="stats shadow">
|
||||||
|
<div className="stat">
|
||||||
|
<div className="stat-figure text-warning">
|
||||||
|
<svg
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
fill="none"
|
||||||
|
viewBox="0 0 24 24"
|
||||||
|
className="inline-block w-8 h-8 stroke-current"
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeLinejoin="round"
|
||||||
|
strokeWidth="2"
|
||||||
|
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
<div className="stat-title">Expiring Soon</div>
|
||||||
|
<div className="stat-value text-warning">
|
||||||
|
{overview.certificates_expiring_soon}
|
||||||
|
</div>
|
||||||
|
<div className="stat-desc">Certificates need renewal</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-4">
|
||||||
|
{[1, 2, 3, 4].map((key) => (
|
||||||
|
<div key={key} className="stats shadow animate-pulse">
|
||||||
|
<div className="stat">
|
||||||
|
<div className="stat-figure w-8 h-8 rounded-full bg-base-200"></div>
|
||||||
|
<div className="stat-title bg-base-200 h-4 w-24 rounded"></div>
|
||||||
|
<div className="stat-value bg-base-200 h-6 w-20 rounded mt-2"></div>
|
||||||
|
<div className="stat-desc bg-base-200 h-4 w-28 rounded mt-2"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="table table-zebra w-full">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Name</th>
|
||||||
|
<th>Location</th>
|
||||||
|
<th>Protocol</th>
|
||||||
|
<th>Certificate Status</th>
|
||||||
|
<th>Certificate Expiry</th>
|
||||||
|
<th>Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{devices && devices.length > 0 ? (
|
||||||
|
devices.map((device: Device) => {
|
||||||
|
const expiresAt = device.active_certificate?.expires_at ?? device.certificate_expires_at
|
||||||
|
|
||||||
|
return (
|
||||||
|
<tr key={device.id} className="hover">
|
||||||
|
<td className="font-semibold">{device.name}</td>
|
||||||
|
<td>{device.location || '—'}</td>
|
||||||
|
<td>
|
||||||
|
<div className="badge badge-info">{device.protocol.toUpperCase()}</div>
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
{device.protocol === 'mqtt' ? (
|
||||||
|
<div className={`badge ${
|
||||||
|
device.certificate_status === 'Valid' ? 'badge-success' :
|
||||||
|
device.certificate_status === 'Expiring Soon' ? 'badge-warning' :
|
||||||
|
'badge-error'
|
||||||
|
}`}>
|
||||||
|
{device.certificate_status || 'Unknown'}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<span className="badge badge-ghost">N/A</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td>{expiresAt ? new Date(expiresAt).toLocaleString() : '—'}</td>
|
||||||
|
<td>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Link to={`/devices/${device.id}`} className="btn btn-outline btn-info btn-xs">
|
||||||
|
View
|
||||||
|
</Link>
|
||||||
|
<button
|
||||||
|
className="btn btn-error btn-xs"
|
||||||
|
onClick={() => setDeleteDevice(device)}
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</button>
|
||||||
|
{device.protocol === 'mqtt' && (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
className="btn btn-outline btn-warning btn-xs"
|
||||||
|
onClick={() => setRenewDevice(device)}
|
||||||
|
>
|
||||||
|
Renew
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="btn btn-outline btn-error btn-xs"
|
||||||
|
onClick={() => setRevokeDevice(device)}
|
||||||
|
>
|
||||||
|
Revoke
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
) : (
|
||||||
|
<tr>
|
||||||
|
<td colSpan={6} className="text-center py-8">
|
||||||
|
<div className="flex flex-col items-center gap-4">
|
||||||
|
<svg xmlns="http://www.w3.org/2000/svg" className="h-16 w-16 opacity-30" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
|
||||||
|
</svg>
|
||||||
|
<p className="text-lg opacity-60">No devices found.</p>
|
||||||
|
<Link to="/devices/add" className="btn btn-primary btn-sm">
|
||||||
|
Add Your First Device
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Dialogs */}
|
||||||
|
{deleteDevice && (
|
||||||
|
<DeleteDeviceDialog
|
||||||
|
device={deleteDevice}
|
||||||
|
open={!!deleteDevice}
|
||||||
|
onOpenChange={(open) => !open && setDeleteDevice(null)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{revokeDevice && (
|
||||||
|
<RevokeDialog
|
||||||
|
device={revokeDevice}
|
||||||
|
open={!!revokeDevice}
|
||||||
|
onOpenChange={(open) => !open && setRevokeDevice(null)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{renewDevice && (
|
||||||
|
<RenewDialog
|
||||||
|
device={renewDevice}
|
||||||
|
open={!!renewDevice}
|
||||||
|
onOpenChange={(open) => !open && setRenewDevice(null)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
71
frontend/src/pages/Login.tsx
Normal file
71
frontend/src/pages/Login.tsx
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
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>
|
||||||
|
)
|
||||||
|
}
|
||||||
66
frontend/src/types/api.ts
Normal file
66
frontend/src/types/api.ts
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
export interface Device {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
location?: string;
|
||||||
|
protocol: 'mqtt' | 'http' | 'webhook';
|
||||||
|
connection_config?: Record<string, any>;
|
||||||
|
is_active: boolean;
|
||||||
|
created_at: string;
|
||||||
|
certificate_status?: string;
|
||||||
|
certificate_expires_at?: string;
|
||||||
|
active_certificate?: DeviceCertificate;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DeviceCertificate {
|
||||||
|
id: string;
|
||||||
|
device_id: string;
|
||||||
|
issued_at: string;
|
||||||
|
expires_at: string;
|
||||||
|
revoked_at?: string;
|
||||||
|
is_revoked: boolean;
|
||||||
|
is_expired: boolean;
|
||||||
|
is_expiring_soon: boolean;
|
||||||
|
is_valid: boolean;
|
||||||
|
days_until_expiry: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Telemetry {
|
||||||
|
time: string;
|
||||||
|
device_id: string;
|
||||||
|
device_name: string;
|
||||||
|
metric: string;
|
||||||
|
value: number;
|
||||||
|
unit?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DeviceRegistrationRequest {
|
||||||
|
name: string;
|
||||||
|
location?: string;
|
||||||
|
protocol?: 'mqtt' | 'http' | 'webhook';
|
||||||
|
connection_config?: Record<string, any>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DeviceRegistrationResponse {
|
||||||
|
device_id: string;
|
||||||
|
protocol: string;
|
||||||
|
certificate_id?: string;
|
||||||
|
ca_certificate_pem?: string;
|
||||||
|
certificate_pem?: string;
|
||||||
|
private_key_pem?: string;
|
||||||
|
expires_at?: string;
|
||||||
|
onboarding_token?: string; // One-time token for secure onboarding (valid 15 min)
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DashboardOverview {
|
||||||
|
total_devices: number;
|
||||||
|
active_devices: number;
|
||||||
|
mqtt_devices: number;
|
||||||
|
http_devices: number;
|
||||||
|
certificates_expiring_soon: number;
|
||||||
|
recent_telemetry: Telemetry[];
|
||||||
|
devices_with_metrics: {
|
||||||
|
device_id: string;
|
||||||
|
device_name: string;
|
||||||
|
metrics: string[];
|
||||||
|
}[];
|
||||||
|
}
|
||||||
64
frontend/src/utils/formatters.ts
Normal file
64
frontend/src/utils/formatters.ts
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
/**
|
||||||
|
* Format metric names for display
|
||||||
|
*/
|
||||||
|
export function formatMetricName(metric: string): string {
|
||||||
|
// Known metric mappings
|
||||||
|
const metricNames: Record<string, string> = {
|
||||||
|
'temperature': 'Temperature',
|
||||||
|
'humidity': 'Humidity',
|
||||||
|
'co2': 'CO₂',
|
||||||
|
'pressure': 'Pressure',
|
||||||
|
'light': 'Light Level',
|
||||||
|
'noise': 'Noise Level',
|
||||||
|
'pm25': 'PM2.5',
|
||||||
|
'voc': 'VOC',
|
||||||
|
}
|
||||||
|
|
||||||
|
// Return mapped name or capitalize the metric
|
||||||
|
return metricNames[metric.toLowerCase()] ||
|
||||||
|
metric.replace(/_/g, ' ')
|
||||||
|
.split(' ')
|
||||||
|
.map(word => word.charAt(0).toUpperCase() + word.slice(1))
|
||||||
|
.join(' ')
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format device name for display
|
||||||
|
*/
|
||||||
|
export function formatDeviceName(deviceName: string): string {
|
||||||
|
return deviceName
|
||||||
|
.replace(/_/g, ' ')
|
||||||
|
.replace(/-/g, ' ')
|
||||||
|
.split(' ')
|
||||||
|
.map(word => word.charAt(0).toUpperCase() + word.slice(1))
|
||||||
|
.join(' ')
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a default widget title based on type, device, and metrics
|
||||||
|
*/
|
||||||
|
export function createDefaultWidgetTitle(
|
||||||
|
type: string,
|
||||||
|
deviceName: string | undefined,
|
||||||
|
metrics: string[]
|
||||||
|
): string {
|
||||||
|
const formattedMetrics = metrics.map(formatMetricName).join(' & ')
|
||||||
|
|
||||||
|
switch (type) {
|
||||||
|
case 'line-chart':
|
||||||
|
if (metrics.length > 1) {
|
||||||
|
return `${formattedMetrics}`
|
||||||
|
}
|
||||||
|
return `${formattedMetrics}`
|
||||||
|
case 'stat':
|
||||||
|
return `${formattedMetrics}`
|
||||||
|
case 'gauge':
|
||||||
|
return `${formattedMetrics}`
|
||||||
|
case 'ai-insight':
|
||||||
|
return deviceName
|
||||||
|
? `AI Insights - ${formatDeviceName(deviceName)}`
|
||||||
|
: 'AI Insights'
|
||||||
|
default:
|
||||||
|
return formattedMetrics
|
||||||
|
}
|
||||||
|
}
|
||||||
28
frontend/tsconfig.app.json
Normal file
28
frontend/tsconfig.app.json
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
|
||||||
|
"target": "ES2022",
|
||||||
|
"useDefineForClassFields": true,
|
||||||
|
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||||
|
"module": "ESNext",
|
||||||
|
"types": ["vite/client"],
|
||||||
|
"skipLibCheck": true,
|
||||||
|
|
||||||
|
/* Bundler mode */
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"verbatimModuleSyntax": true,
|
||||||
|
"moduleDetection": "force",
|
||||||
|
"noEmit": true,
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
|
||||||
|
/* Linting */
|
||||||
|
"strict": true,
|
||||||
|
"noUnusedLocals": true,
|
||||||
|
"noUnusedParameters": true,
|
||||||
|
"erasableSyntaxOnly": true,
|
||||||
|
"noFallthroughCasesInSwitch": true,
|
||||||
|
"noUncheckedSideEffectImports": true
|
||||||
|
},
|
||||||
|
"include": ["src"]
|
||||||
|
}
|
||||||
7
frontend/tsconfig.json
Normal file
7
frontend/tsconfig.json
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"files": [],
|
||||||
|
"references": [
|
||||||
|
{ "path": "./tsconfig.app.json" },
|
||||||
|
{ "path": "./tsconfig.node.json" }
|
||||||
|
]
|
||||||
|
}
|
||||||
26
frontend/tsconfig.node.json
Normal file
26
frontend/tsconfig.node.json
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
|
||||||
|
"target": "ES2023",
|
||||||
|
"lib": ["ES2023"],
|
||||||
|
"module": "ESNext",
|
||||||
|
"types": ["node"],
|
||||||
|
"skipLibCheck": true,
|
||||||
|
|
||||||
|
/* Bundler mode */
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"verbatimModuleSyntax": true,
|
||||||
|
"moduleDetection": "force",
|
||||||
|
"noEmit": true,
|
||||||
|
|
||||||
|
/* Linting */
|
||||||
|
"strict": true,
|
||||||
|
"noUnusedLocals": true,
|
||||||
|
"noUnusedParameters": true,
|
||||||
|
"erasableSyntaxOnly": true,
|
||||||
|
"noFallthroughCasesInSwitch": true,
|
||||||
|
"noUncheckedSideEffectImports": true
|
||||||
|
},
|
||||||
|
"include": ["vite.config.ts"]
|
||||||
|
}
|
||||||
23
frontend/vite.config.ts
Normal file
23
frontend/vite.config.ts
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
import { defineConfig } from 'vite'
|
||||||
|
import react from '@vitejs/plugin-react'
|
||||||
|
import tailwindcss from '@tailwindcss/vite'
|
||||||
|
import path from 'path'
|
||||||
|
|
||||||
|
// https://vite.dev/config/
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [react(), tailwindcss()],
|
||||||
|
resolve: {
|
||||||
|
alias: {
|
||||||
|
'@': path.resolve(__dirname, './src'),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
server: {
|
||||||
|
port: 5173,
|
||||||
|
proxy: {
|
||||||
|
'/api': {
|
||||||
|
target: 'http://localhost:3000',
|
||||||
|
changeOrigin: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
44
gpt.py
44
gpt.py
@@ -1,44 +0,0 @@
|
|||||||
import json
|
|
||||||
import os
|
|
||||||
import redis
|
|
||||||
from dotenv import load_dotenv
|
|
||||||
from openai import OpenAI
|
|
||||||
|
|
||||||
load_dotenv()
|
|
||||||
client = OpenAI()
|
|
||||||
|
|
||||||
REDIS_HOST = os.getenv("REDIS_HOST")
|
|
||||||
redis_client = redis.StrictRedis(host=REDIS_HOST, port=6379, db=0)
|
|
||||||
|
|
||||||
data = redis_client.get("last5").decode("utf-8")
|
|
||||||
|
|
||||||
|
|
||||||
def analysis(environment_data):
|
|
||||||
completion = client.chat.completions.create(
|
|
||||||
model="gpt-4o-mini",
|
|
||||||
messages=[
|
|
||||||
{
|
|
||||||
"role": "system",
|
|
||||||
"content": "You are an assistant that analyzes environmental data for an office working space and provides "
|
|
||||||
"concise numerical insights.",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"role": "user",
|
|
||||||
"content": f"Analyze the following environmental data. The goal is maintaining optimal working "
|
|
||||||
f"conditions in the office and peak working brain. Focus on any outliers or necessary adjustments. The data is as following: {environment_data}."
|
|
||||||
f"The output should be only the recommendations in numerical form with postitive and negative "
|
|
||||||
f"numbers and also provide small summary in a sentence or two of the current conditions and "
|
|
||||||
f"easily computable in json format. Be consistent with the + and - signs and the summary",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
response_format={"type": "json_object"},
|
|
||||||
)
|
|
||||||
output = completion.choices[0].message.content
|
|
||||||
|
|
||||||
return output
|
|
||||||
|
|
||||||
|
|
||||||
output = analysis(data)
|
|
||||||
redis_client.set("gpt", json.dumps(output))
|
|
||||||
|
|
||||||
print(output)
|
|
||||||
4
infrastructure/.gitignore
vendored
4
infrastructure/.gitignore
vendored
@@ -1,3 +1,5 @@
|
|||||||
mosquitto/certs/
|
mosquitto/certs/
|
||||||
mosquitto/data/
|
mosquitto/data/
|
||||||
mosquitto/logs/
|
mosquitto/logs/
|
||||||
|
.env
|
||||||
|
.env*
|
||||||
129
infrastructure/compose.prod.yml
Normal file
129
infrastructure/compose.prod.yml
Normal file
@@ -0,0 +1,129 @@
|
|||||||
|
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
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-postgres}"]
|
||||||
|
interval: 10s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 5
|
||||||
|
|
||||||
|
device-manager:
|
||||||
|
image: git.ferdzo.xyz/ferdzo/lyncis/device-manager:latest
|
||||||
|
ports:
|
||||||
|
- "8000:8000"
|
||||||
|
environment:
|
||||||
|
- DATABASE_URL=postgresql://${POSTGRES_USER:-postgres}:${POSTGRES_PASSWORD:-example}@timescaledb:5432/${POSTGRES_DB:-iot_data}
|
||||||
|
- CA_CERT_PATH=/app/certs/ca.crt
|
||||||
|
- CA_KEY_PATH=/app/certs/ca.key
|
||||||
|
- CRL_PATH=/app/certs/ca.crl
|
||||||
|
volumes:
|
||||||
|
- ./mosquitto/certs:/app/certs:z
|
||||||
|
depends_on:
|
||||||
|
timescaledb:
|
||||||
|
condition: service_healthy
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
mqtt-ingestion:
|
||||||
|
image: git.ferdzo.xyz/ferdzo/lyncis/mqtt-ingestion:latest
|
||||||
|
environment:
|
||||||
|
- REDIS_HOST=redis
|
||||||
|
- REDIS_PORT=6379
|
||||||
|
- REDIS_DB=0
|
||||||
|
- MQTT_BROKER=mosquitto
|
||||||
|
- MQTT_PORT=1883
|
||||||
|
- MQTT_USER=${MQTT_USER:-}
|
||||||
|
- MQTT_PASS=${MQTT_PASS:-}
|
||||||
|
depends_on:
|
||||||
|
- mosquitto
|
||||||
|
- redis
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
db-write:
|
||||||
|
image: git.ferdzo.xyz/ferdzo/lyncis/db-write:latest
|
||||||
|
environment:
|
||||||
|
- REDIS_HOST=redis
|
||||||
|
- REDIS_PORT=6379
|
||||||
|
- REDIS_DB=0
|
||||||
|
- DATABASE_URL=postgresql://${POSTGRES_USER:-postgres}:${POSTGRES_PASSWORD:-example}@timescaledb:5432/${POSTGRES_DB:-iot_data}
|
||||||
|
- CONSUMER_GROUP_NAME=db_writer
|
||||||
|
- CONSUMER_NAME=worker-01
|
||||||
|
- BATCH_SIZE=100
|
||||||
|
- BATCH_TIMEOUT_SEC=5
|
||||||
|
- STREAM_PATTERN=mqtt_stream:*
|
||||||
|
depends_on:
|
||||||
|
timescaledb:
|
||||||
|
condition: service_healthy
|
||||||
|
redis:
|
||||||
|
condition: service_started
|
||||||
|
mqtt-ingestion:
|
||||||
|
condition: service_started
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
backend:
|
||||||
|
image: git.ferdzo.xyz/ferdzo/lyncis/backend:latest
|
||||||
|
ports:
|
||||||
|
- "3000:3000"
|
||||||
|
environment:
|
||||||
|
- POSTGRES_HOST=timescaledb
|
||||||
|
- POSTGRES_PORT=5432
|
||||||
|
- POSTGRES_USER=${POSTGRES_USER:-postgres}
|
||||||
|
- POSTGRES_PASSWORD=${POSTGRES_PASSWORD:-example}
|
||||||
|
- POSTGRES_DB=${POSTGRES_DB:-iot_data}
|
||||||
|
- DEVICE_MANAGER_URL=http://device-manager:8000
|
||||||
|
- SECRET_KEY=${SECRET_KEY:-change-me-in-production}
|
||||||
|
- DEBUG=False
|
||||||
|
- ALLOWED_HOSTS=*
|
||||||
|
depends_on:
|
||||||
|
timescaledb:
|
||||||
|
condition: service_healthy
|
||||||
|
device-manager:
|
||||||
|
condition: service_started
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
frontend:
|
||||||
|
image: git.ferdzo.xyz/ferdzo/lyncis/frontend:latest
|
||||||
|
ports:
|
||||||
|
- "80:80"
|
||||||
|
depends_on:
|
||||||
|
- backend
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
gpt-service:
|
||||||
|
image: git.ferdzo.xyz/ferdzo/lyncis-gpt-service:latest
|
||||||
|
environment:
|
||||||
|
- OPENAI_API_KEY=${OPENAI_API_KEY}
|
||||||
|
ports:
|
||||||
|
- "8001:8001"
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
redis-data:
|
||||||
|
timescaledb-data:
|
||||||
@@ -8,14 +8,14 @@ services:
|
|||||||
- redis-data:/data
|
- redis-data:/data
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|
||||||
mqtt:
|
mosquitto:
|
||||||
image: eclipse-mosquitto:2.0
|
image: eclipse-mosquitto:2.0
|
||||||
ports:
|
ports:
|
||||||
- "1883:1883"
|
- "1883:1883"
|
||||||
- "9001:9001"
|
- "9001:9001"
|
||||||
- "8883:8883"
|
- "8883:8883"
|
||||||
volumes:
|
volumes:
|
||||||
- ./mosquitto/:/mosquitto/:Z
|
- ./mosquitto/:/mosquitto/
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
|
||||||
timescaledb:
|
timescaledb:
|
||||||
@@ -29,6 +29,94 @@ services:
|
|||||||
volumes:
|
volumes:
|
||||||
- timescaledb-data:/var/lib/postgresql/data
|
- timescaledb-data:/var/lib/postgresql/data
|
||||||
restart: unless-stopped
|
restart: unless-stopped
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-postgres}"]
|
||||||
|
interval: 10s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 5
|
||||||
|
|
||||||
|
device-manager:
|
||||||
|
build:
|
||||||
|
context: ../services/device_manager
|
||||||
|
ports:
|
||||||
|
- "8000:8000"
|
||||||
|
env_file:
|
||||||
|
- ../services/device_manager/.env
|
||||||
|
volumes:
|
||||||
|
- ./mosquitto/certs:/app/certs:z
|
||||||
|
depends_on:
|
||||||
|
timescaledb:
|
||||||
|
condition: service_healthy
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
mqtt-ingestion:
|
||||||
|
build:
|
||||||
|
context: ../services/mqtt_ingestion
|
||||||
|
env_file:
|
||||||
|
- .env.mqi
|
||||||
|
depends_on:
|
||||||
|
- mosquitto
|
||||||
|
- redis
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
db-write:
|
||||||
|
build:
|
||||||
|
context: ../services/db_write
|
||||||
|
env_file:
|
||||||
|
- .env.dbw
|
||||||
|
depends_on:
|
||||||
|
timescaledb:
|
||||||
|
condition: service_healthy
|
||||||
|
redis:
|
||||||
|
condition: service_started
|
||||||
|
mqtt-ingestion:
|
||||||
|
condition: service_started
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
backend:
|
||||||
|
image: git.ferdzo.xyz/ferdzo/lyncis/backend:latest
|
||||||
|
ports:
|
||||||
|
- "3000:3000"
|
||||||
|
environment:
|
||||||
|
- POSTGRES_HOST=timescaledb
|
||||||
|
- POSTGRES_PORT=5432
|
||||||
|
- POSTGRES_USER=${POSTGRES_USER:-postgres}
|
||||||
|
- POSTGRES_PASSWORD=${POSTGRES_PASSWORD:-example}
|
||||||
|
- POSTGRES_DB=${POSTGRES_DB:-iot_data}
|
||||||
|
- DEVICE_MANAGER_URL=http://device-manager:8000
|
||||||
|
- SECRET_KEY=${SECRET_KEY:-change-me-in-production}6
|
||||||
|
- DEBUG=False
|
||||||
|
- ALLOWED_HOSTS=*
|
||||||
|
depends_on:
|
||||||
|
timescaledb:
|
||||||
|
condition: service_healthy
|
||||||
|
device-manager:
|
||||||
|
condition: service_started
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
frontend:
|
||||||
|
build:
|
||||||
|
context: ../frontend
|
||||||
|
dockerfile: Dockerfile
|
||||||
|
args:
|
||||||
|
VITE_API_URL: /api
|
||||||
|
VITE_DEVICE_MANAGER_URL: ${VITE_DEVICE_MANAGER_URL:-http://localhost/api}
|
||||||
|
VITE_MQTT_BROKER: ${VITE_MQTT_BROKER:-localhost}
|
||||||
|
VITE_MQTT_PORT: ${VITE_MQTT_PORT:-8883}
|
||||||
|
ports:
|
||||||
|
- "80:80"
|
||||||
|
depends_on:
|
||||||
|
- django
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
gpt-service:
|
||||||
|
image: git.ferdzo.xyz/ferdzo/lyncis-gpt-service:latest
|
||||||
|
environment:
|
||||||
|
- OPENAI_API_KEY=${OPENAI_API_KEY}
|
||||||
|
ports:
|
||||||
|
- "8001:8001"
|
||||||
|
restart: unless-stopped
|
||||||
|
|
||||||
|
|
||||||
volumes:
|
volumes:
|
||||||
redis-data:
|
redis-data:
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
PASSWORD='postgres'
|
PASSWORD='postgres'
|
||||||
SECRET_KEY='django_key'
|
SECRET_KEY='django_key'
|
||||||
CONNECTION_STRING='postgres://postgres:postgres@localhost:5555/example'
|
CONNECTION_STRING='postgres://postgres:postgres@localhost:5555/example'
|
||||||
REDIS_HOST="localhost"
|
|
||||||
MQTT_BROKER="localhost"
|
WEATHER_API_KEY=your_weather_api_key_here
|
||||||
OPENAI_API_KEY="example"
|
DEVICE_MANAGER_URL=http://localhost:8000
|
||||||
|
|
||||||
|
|||||||
1
iotDashboard/api/__init__.py
Normal file
1
iotDashboard/api/__init__.py
Normal file
@@ -0,0 +1 @@
|
|||||||
|
"""REST API for IoT Dashboard."""
|
||||||
88
iotDashboard/api/serializers.py
Normal file
88
iotDashboard/api/serializers.py
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
"""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']
|
||||||
21
iotDashboard/api/urls.py
Normal file
21
iotDashboard/api/urls.py
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
"""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)),
|
||||||
|
]
|
||||||
1158
iotDashboard/api/views.py
Normal file
1158
iotDashboard/api/views.py
Normal file
File diff suppressed because it is too large
Load Diff
48
iotDashboard/auth_backend.py
Normal file
48
iotDashboard/auth_backend.py
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
"""
|
||||||
|
Custom authentication backend for IoT Dashboard.
|
||||||
|
Uses the custom users table instead of Django's auth_user table.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from django.contrib.auth.hashers import check_password
|
||||||
|
from django.contrib.auth.backends import BaseBackend
|
||||||
|
from iotDashboard.models import User
|
||||||
|
|
||||||
|
|
||||||
|
class CustomUserBackend(BaseBackend):
|
||||||
|
"""
|
||||||
|
Custom authentication backend that uses our users table.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def authenticate(self, request, username=None, password=None, **kwargs):
|
||||||
|
"""
|
||||||
|
Authenticate user against our custom users table.
|
||||||
|
"""
|
||||||
|
if username is None or password is None:
|
||||||
|
return None
|
||||||
|
|
||||||
|
try:
|
||||||
|
user = User.objects.get(username=username, is_active=True)
|
||||||
|
except User.DoesNotExist:
|
||||||
|
return None
|
||||||
|
|
||||||
|
# Verify password
|
||||||
|
if check_password(password, user.password_hash):
|
||||||
|
# Create a minimal user-like object that JWT can use
|
||||||
|
# We need to add required attributes for JWT
|
||||||
|
user.is_authenticated = True
|
||||||
|
user.pk = user.id # JWT expects pk attribute
|
||||||
|
return user
|
||||||
|
|
||||||
|
return None
|
||||||
|
|
||||||
|
def get_user(self, user_id):
|
||||||
|
"""
|
||||||
|
Get user by ID for session/JWT validation.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
user = User.objects.get(pk=user_id, is_active=True)
|
||||||
|
user.is_authenticated = True
|
||||||
|
user.pk = user.id
|
||||||
|
return user
|
||||||
|
except User.DoesNotExist:
|
||||||
|
return None
|
||||||
300
iotDashboard/comfort_index.py
Normal file
300
iotDashboard/comfort_index.py
Normal file
@@ -0,0 +1,300 @@
|
|||||||
|
"""
|
||||||
|
Comfort Index Calculation Module
|
||||||
|
|
||||||
|
Combines temperature, humidity, CO₂, noise, and air quality into a weighted comfort score.
|
||||||
|
Score range: 0-100 (100 = optimal comfort)
|
||||||
|
"""
|
||||||
|
|
||||||
|
from typing import Dict, List, Optional, Tuple
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class ComfortMetrics:
|
||||||
|
"""Raw environmental metrics for comfort calculation."""
|
||||||
|
temperature: Optional[float] = None
|
||||||
|
humidity: Optional[float] = None
|
||||||
|
co2: Optional[float] = None
|
||||||
|
noise: Optional[float] = None
|
||||||
|
pm25: Optional[float] = None
|
||||||
|
pm10: Optional[float] = None
|
||||||
|
light: Optional[float] = None
|
||||||
|
timestamp: Optional[datetime] = None
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class ComfortScore:
|
||||||
|
"""Comfort index results with component scores."""
|
||||||
|
overall_score: float # 0-100
|
||||||
|
temperature_score: float
|
||||||
|
humidity_score: float
|
||||||
|
air_quality_score: float
|
||||||
|
acoustic_score: float
|
||||||
|
light_score: float
|
||||||
|
rating: str # "Excellent", "Good", "Fair", "Poor", "Very Poor"
|
||||||
|
suggestions: List[str]
|
||||||
|
|
||||||
|
class ComfortIndexCalculator:
|
||||||
|
"""Calculate multi-factor comfort index from environmental sensors."""
|
||||||
|
|
||||||
|
# Optimal ranges (comfort score = 100 at these values)
|
||||||
|
OPTIMAL_TEMP_RANGE = (20.0, 24.0) # °C
|
||||||
|
OPTIMAL_HUMIDITY_RANGE = (40.0, 60.0) # %
|
||||||
|
OPTIMAL_CO2_MAX = 800 # ppm (< 1000 is good)
|
||||||
|
OPTIMAL_NOISE_MAX = 40 # dB (office environment)
|
||||||
|
OPTIMAL_PM25_MAX = 12 # μg/m³ (WHO guideline)
|
||||||
|
OPTIMAL_PM10_MAX = 20 # μg/m³
|
||||||
|
OPTIMAL_LIGHT_RANGE = (300, 500) # Lux (office work)
|
||||||
|
|
||||||
|
# Weights for overall score (must sum to 1.0)
|
||||||
|
WEIGHTS = {
|
||||||
|
'temperature': 0.25,
|
||||||
|
'humidity': 0.15,
|
||||||
|
'air_quality': 0.30,
|
||||||
|
'acoustic': 0.15,
|
||||||
|
'light': 0.15,
|
||||||
|
}
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def calculate_temperature_score(temp: float) -> Tuple[float, List[str]]:
|
||||||
|
"""Score temperature comfort (0-100)."""
|
||||||
|
suggestions = []
|
||||||
|
optimal_min, optimal_max = ComfortIndexCalculator.OPTIMAL_TEMP_RANGE
|
||||||
|
|
||||||
|
if optimal_min <= temp <= optimal_max:
|
||||||
|
score = 100.0
|
||||||
|
elif temp < optimal_min:
|
||||||
|
deviation = optimal_min - temp
|
||||||
|
score = max(0, 100 - (deviation * 10)) # -10 points per degree below
|
||||||
|
if deviation > 2:
|
||||||
|
suggestions.append(f"Temperature too cold ({temp:.1f}°C). Increase heating to {optimal_min}-{optimal_max}°C")
|
||||||
|
else:
|
||||||
|
deviation = temp - optimal_max
|
||||||
|
score = max(0, 100 - (deviation * 10)) # -10 points per degree above
|
||||||
|
if deviation > 2:
|
||||||
|
suggestions.append(f"Temperature too warm ({temp:.1f}°C). Increase cooling to {optimal_min}-{optimal_max}°C")
|
||||||
|
|
||||||
|
return score, suggestions
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def calculate_humidity_score(humidity: float) -> Tuple[float, List[str]]:
|
||||||
|
"""Score humidity comfort (0-100)."""
|
||||||
|
suggestions = []
|
||||||
|
optimal_min, optimal_max = ComfortIndexCalculator.OPTIMAL_HUMIDITY_RANGE
|
||||||
|
|
||||||
|
if optimal_min <= humidity <= optimal_max:
|
||||||
|
score = 100.0
|
||||||
|
elif humidity < optimal_min:
|
||||||
|
# Too dry
|
||||||
|
deviation = optimal_min - humidity
|
||||||
|
score = max(0, 100 - (deviation * 2)) # -2 points per % below
|
||||||
|
if deviation > 10:
|
||||||
|
suggestions.append(f"Air too dry ({humidity:.1f}%). Use humidifier to reach {optimal_min}-{optimal_max}%")
|
||||||
|
else:
|
||||||
|
# Too humid
|
||||||
|
deviation = humidity - optimal_max
|
||||||
|
score = max(0, 100 - (deviation * 2)) # -2 points per % above
|
||||||
|
if deviation > 10:
|
||||||
|
suggestions.append(f"Air too humid ({humidity:.1f}%). Use dehumidifier to reach {optimal_min}-{optimal_max}%")
|
||||||
|
|
||||||
|
return score, suggestions
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def calculate_air_quality_score(co2: Optional[float], pm25: Optional[float], pm10: Optional[float]) -> Tuple[float, List[str]]:
|
||||||
|
"""Score air quality based on CO₂ and particulate matter (0-100)."""
|
||||||
|
suggestions = []
|
||||||
|
scores = []
|
||||||
|
|
||||||
|
# CO₂ score
|
||||||
|
if co2 is not None:
|
||||||
|
if co2 <= ComfortIndexCalculator.OPTIMAL_CO2_MAX:
|
||||||
|
co2_score = 100.0
|
||||||
|
elif co2 <= 1000:
|
||||||
|
# Acceptable range (800-1000 ppm)
|
||||||
|
co2_score = 100 - ((co2 - 800) / 2) # -0.5 points per ppm
|
||||||
|
else:
|
||||||
|
# Poor air quality
|
||||||
|
co2_score = max(0, 50 - ((co2 - 1000) / 20)) # Drops to 0 at 2000 ppm
|
||||||
|
if co2 > 1500:
|
||||||
|
suggestions.append(f"CO₂ level very high ({co2:.0f} ppm). Increase ventilation immediately")
|
||||||
|
else:
|
||||||
|
suggestions.append(f"CO₂ level elevated ({co2:.0f} ppm). Improve ventilation")
|
||||||
|
scores.append(co2_score)
|
||||||
|
|
||||||
|
# PM2.5 score
|
||||||
|
if pm25 is not None:
|
||||||
|
if pm25 <= ComfortIndexCalculator.OPTIMAL_PM25_MAX:
|
||||||
|
pm25_score = 100.0
|
||||||
|
elif pm25 <= 35.4:
|
||||||
|
pm25_score = 100 - ((pm25 - 12) / 0.234)
|
||||||
|
else:
|
||||||
|
pm25_score = 0
|
||||||
|
suggestions.append(f"PM2.5 unhealthy ({pm25:.1f} μg/m³). Use air purifier and avoid outdoor air")
|
||||||
|
scores.append(pm25_score)
|
||||||
|
|
||||||
|
# PM10 score
|
||||||
|
if pm10 is not None:
|
||||||
|
if pm10 <= ComfortIndexCalculator.OPTIMAL_PM10_MAX:
|
||||||
|
pm10_score = 100.0
|
||||||
|
elif pm10 <= 50: # Acceptable
|
||||||
|
pm10_score = 100 - ((pm10 - 20) / 0.3)
|
||||||
|
else:
|
||||||
|
pm10_score = max(0, 50 - (pm10 - 50))
|
||||||
|
if pm10 > 100:
|
||||||
|
suggestions.append(f"PM10 very high ({pm10:.1f} μg/m³). Close windows and use filtration")
|
||||||
|
scores.append(pm10_score)
|
||||||
|
|
||||||
|
score = sum(scores) / len(scores) if scores else 50.0
|
||||||
|
return score, suggestions
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def calculate_acoustic_score(noise: float) -> Tuple[float, List[str]]:
|
||||||
|
"""Score acoustic comfort based on noise level (0-100)."""
|
||||||
|
suggestions = []
|
||||||
|
|
||||||
|
if noise <= ComfortIndexCalculator.OPTIMAL_NOISE_MAX:
|
||||||
|
score = 100.0
|
||||||
|
elif noise <= 55:
|
||||||
|
score = 100 - ((noise - 40) * 3)
|
||||||
|
elif noise <= 70: # Noisy
|
||||||
|
score = max(0, 55 - ((noise - 55) * 2))
|
||||||
|
suggestions.append(f"Noise level high ({noise:.1f} dB). Consider noise-canceling or quieter environment")
|
||||||
|
else: # Very noisy
|
||||||
|
score = 0
|
||||||
|
suggestions.append(f"Noise level very high ({noise:.1f} dB). Immediate action needed for hearing protection")
|
||||||
|
|
||||||
|
return score, suggestions
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def calculate_light_score(light: float) -> Tuple[float, List[str]]:
|
||||||
|
"""Score lighting comfort (0-100)."""
|
||||||
|
suggestions = []
|
||||||
|
optimal_min, optimal_max = ComfortIndexCalculator.OPTIMAL_LIGHT_RANGE
|
||||||
|
|
||||||
|
if optimal_min <= light <= optimal_max:
|
||||||
|
score = 100.0
|
||||||
|
elif light < optimal_min:
|
||||||
|
# Too dark
|
||||||
|
if light < 100:
|
||||||
|
score = 0
|
||||||
|
suggestions.append(f"Lighting very dim ({light:.0f} lux). Increase to {optimal_min}-{optimal_max} lux")
|
||||||
|
else:
|
||||||
|
score = (light / optimal_min) * 100
|
||||||
|
suggestions.append(f"Lighting insufficient ({light:.0f} lux). Increase to {optimal_min}-{optimal_max} lux")
|
||||||
|
else:
|
||||||
|
# Too bright
|
||||||
|
if light > 1000:
|
||||||
|
score = max(0, 100 - ((light - 1000) / 10))
|
||||||
|
suggestions.append(f"Lighting very bright ({light:.0f} lux). May cause glare. Reduce to {optimal_min}-{optimal_max} lux")
|
||||||
|
else:
|
||||||
|
score = 100 - ((light - optimal_max) / 5)
|
||||||
|
|
||||||
|
return score, suggestions
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def get_rating(score: float) -> str:
|
||||||
|
"""Convert numeric score to rating."""
|
||||||
|
if score >= 90:
|
||||||
|
return "Excellent"
|
||||||
|
elif score >= 75:
|
||||||
|
return "Good"
|
||||||
|
elif score >= 60:
|
||||||
|
return "Fair"
|
||||||
|
elif score >= 40:
|
||||||
|
return "Poor"
|
||||||
|
else:
|
||||||
|
return "Very Poor"
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def calculate(cls, metrics: ComfortMetrics) -> ComfortScore:
|
||||||
|
"""
|
||||||
|
Calculate overall comfort index from environmental metrics.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
metrics: ComfortMetrics with sensor readings
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
ComfortScore with overall score and component breakdowns
|
||||||
|
"""
|
||||||
|
all_suggestions = []
|
||||||
|
component_scores = {}
|
||||||
|
|
||||||
|
# Temperature
|
||||||
|
if metrics.temperature is not None:
|
||||||
|
temp_score, temp_suggestions = cls.calculate_temperature_score(metrics.temperature)
|
||||||
|
component_scores['temperature'] = temp_score
|
||||||
|
all_suggestions.extend(temp_suggestions)
|
||||||
|
else:
|
||||||
|
component_scores['temperature'] = 50.0 #
|
||||||
|
|
||||||
|
# Humidity
|
||||||
|
if metrics.humidity is not None:
|
||||||
|
humidity_score, humidity_suggestions = cls.calculate_humidity_score(metrics.humidity)
|
||||||
|
component_scores['humidity'] = humidity_score
|
||||||
|
all_suggestions.extend(humidity_suggestions)
|
||||||
|
else:
|
||||||
|
component_scores['humidity'] = 50.0
|
||||||
|
|
||||||
|
# Air Quality (CO₂ + PM)
|
||||||
|
air_score, air_suggestions = cls.calculate_air_quality_score(
|
||||||
|
metrics.co2, metrics.pm25, metrics.pm10
|
||||||
|
)
|
||||||
|
component_scores['air_quality'] = air_score
|
||||||
|
all_suggestions.extend(air_suggestions)
|
||||||
|
|
||||||
|
# Acoustic
|
||||||
|
if metrics.noise is not None:
|
||||||
|
acoustic_score, acoustic_suggestions = cls.calculate_acoustic_score(metrics.noise)
|
||||||
|
component_scores['acoustic'] = acoustic_score
|
||||||
|
all_suggestions.extend(acoustic_suggestions)
|
||||||
|
else:
|
||||||
|
component_scores['acoustic'] = 50.0
|
||||||
|
|
||||||
|
# Light
|
||||||
|
if metrics.light is not None:
|
||||||
|
light_score, light_suggestions = cls.calculate_light_score(metrics.light)
|
||||||
|
component_scores['light'] = light_score
|
||||||
|
all_suggestions.extend(light_suggestions)
|
||||||
|
else:
|
||||||
|
component_scores['light'] = 50.0
|
||||||
|
|
||||||
|
# Calculate weighted overall score
|
||||||
|
overall = sum(
|
||||||
|
component_scores[key] * cls.WEIGHTS[key]
|
||||||
|
for key in cls.WEIGHTS.keys()
|
||||||
|
)
|
||||||
|
|
||||||
|
return ComfortScore(
|
||||||
|
overall_score=round(overall, 1),
|
||||||
|
temperature_score=round(component_scores['temperature'], 1),
|
||||||
|
humidity_score=round(component_scores['humidity'], 1),
|
||||||
|
air_quality_score=round(component_scores['air_quality'], 1),
|
||||||
|
acoustic_score=round(component_scores['acoustic'], 1),
|
||||||
|
light_score=round(component_scores['light'], 1),
|
||||||
|
rating=cls.get_rating(overall),
|
||||||
|
suggestions=all_suggestions,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def calculate_comfort_index_from_telemetry(telemetry_data: Dict[str, float]) -> ComfortScore:
|
||||||
|
"""
|
||||||
|
Convenience function to calculate comfort index from telemetry dictionary.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
telemetry_data: Dict with metric names as keys, e.g. {"temperature": 22.5, "humidity": 45}
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
ComfortScore
|
||||||
|
"""
|
||||||
|
metrics = ComfortMetrics(
|
||||||
|
temperature=telemetry_data.get('temperature'),
|
||||||
|
humidity=telemetry_data.get('humidity'),
|
||||||
|
co2=telemetry_data.get('co2') or telemetry_data.get('CO2'),
|
||||||
|
noise=telemetry_data.get('noise') or telemetry_data.get('sound'),
|
||||||
|
pm25=telemetry_data.get('pm2.5') or telemetry_data.get('PM2.5'),
|
||||||
|
pm10=telemetry_data.get('pm10') or telemetry_data.get('PM10'),
|
||||||
|
light=telemetry_data.get('light') or telemetry_data.get('lux'),
|
||||||
|
)
|
||||||
|
|
||||||
|
return ComfortIndexCalculator.calculate(metrics)
|
||||||
64
iotDashboard/dashboard_models.py
Normal file
64
iotDashboard/dashboard_models.py
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
"""
|
||||||
|
Dashboard layout models for persistent storage in PostgreSQL.
|
||||||
|
|
||||||
|
Single-user system: Stores dashboard configuration for the default user.
|
||||||
|
PostgreSQL is chosen over Redis because:
|
||||||
|
- Dashboard layouts are persistent configuration data (not cache)
|
||||||
|
- Need ACID guarantees for data integrity
|
||||||
|
- Low read/write frequency (performance difference negligible)
|
||||||
|
- Already using PostgreSQL for Django
|
||||||
|
"""
|
||||||
|
|
||||||
|
from django.db import models
|
||||||
|
|
||||||
|
|
||||||
|
class DashboardLayout(models.Model):
|
||||||
|
"""Stores dashboard configuration in PostgreSQL.
|
||||||
|
|
||||||
|
Single-user system: Only one default layout is stored.
|
||||||
|
This is persistent configuration data that should survive server restarts.
|
||||||
|
PostgreSQL provides:
|
||||||
|
- ACID guarantees
|
||||||
|
- Complex queries
|
||||||
|
- Backup/restore capabilities
|
||||||
|
"""
|
||||||
|
|
||||||
|
name = models.CharField(
|
||||||
|
max_length=255,
|
||||||
|
default='default',
|
||||||
|
unique=True,
|
||||||
|
help_text="Layout name (e.g., 'default', 'mobile', 'work')"
|
||||||
|
)
|
||||||
|
config = models.JSONField(
|
||||||
|
help_text="Full dashboard configuration JSON"
|
||||||
|
)
|
||||||
|
is_default = models.BooleanField(
|
||||||
|
default=True,
|
||||||
|
help_text="Whether this is the default layout"
|
||||||
|
)
|
||||||
|
created_at = models.DateTimeField(auto_now_add=True)
|
||||||
|
updated_at = models.DateTimeField(auto_now=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
db_table = "dashboard_layouts"
|
||||||
|
indexes = [
|
||||||
|
models.Index(fields=["is_default"]),
|
||||||
|
models.Index(fields=["name"]),
|
||||||
|
]
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return f"{self.name} (default: {self.is_default})"
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def get_default(cls):
|
||||||
|
"""Get the default layout, or create one if it doesn't exist."""
|
||||||
|
layout = cls.objects.filter(is_default=True).first()
|
||||||
|
if layout:
|
||||||
|
return layout
|
||||||
|
# Create default if none exists
|
||||||
|
return cls.objects.create(
|
||||||
|
name='default',
|
||||||
|
config={'widgets': [], 'layout': 'grid', 'refreshInterval': 30000},
|
||||||
|
is_default=True
|
||||||
|
)
|
||||||
|
|
||||||
@@ -16,6 +16,7 @@ class DeviceRegistrationResponse:
|
|||||||
certificate_pem: Optional[str] = None
|
certificate_pem: Optional[str] = None
|
||||||
private_key_pem: Optional[str] = None
|
private_key_pem: Optional[str] = None
|
||||||
expires_at: Optional[datetime] = None
|
expires_at: Optional[datetime] = None
|
||||||
|
onboarding_token: Optional[str] = None # One-time token for secure onboarding
|
||||||
credential_id: Optional[str] = None
|
credential_id: Optional[str] = None
|
||||||
api_key: Optional[str] = None
|
api_key: Optional[str] = None
|
||||||
webhook_secret: Optional[str] = None
|
webhook_secret: Optional[str] = None
|
||||||
@@ -92,6 +93,7 @@ class DeviceManagerClient:
|
|||||||
certificate_pem=data.get("certificate_pem"),
|
certificate_pem=data.get("certificate_pem"),
|
||||||
private_key_pem=data.get("private_key_pem"),
|
private_key_pem=data.get("private_key_pem"),
|
||||||
expires_at=datetime.fromisoformat(data["expires_at"].replace("Z", "+00:00")) if data.get("expires_at") else None,
|
expires_at=datetime.fromisoformat(data["expires_at"].replace("Z", "+00:00")) if data.get("expires_at") else None,
|
||||||
|
onboarding_token=data.get("onboarding_token"),
|
||||||
credential_id=data.get("credential_id"),
|
credential_id=data.get("credential_id"),
|
||||||
api_key=data.get("api_key"),
|
api_key=data.get("api_key"),
|
||||||
webhook_secret=data.get("webhook_secret"),
|
webhook_secret=data.get("webhook_secret"),
|
||||||
@@ -149,6 +151,11 @@ class DeviceManagerClient:
|
|||||||
webhook_secret=data.get("webhook_secret"),
|
webhook_secret=data.get("webhook_secret"),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def delete_device(self, device_id: str) -> Dict[str, Any]:
|
||||||
|
"""Delete a device and its associated certificates."""
|
||||||
|
response = self._request("POST", f"/devices/{device_id}/delete")
|
||||||
|
return response.json()
|
||||||
|
|
||||||
def get_ca_certificate(self) -> str:
|
def get_ca_certificate(self) -> str:
|
||||||
response = self._request("GET", "/ca_certificate")
|
response = self._request("GET", "/ca_certificate")
|
||||||
return response.text
|
return response.text
|
||||||
@@ -157,6 +164,26 @@ class DeviceManagerClient:
|
|||||||
response = self._request("GET", "/crl")
|
response = self._request("GET", "/crl")
|
||||||
return response.text
|
return response.text
|
||||||
|
|
||||||
|
def get_device_credentials(self, device_id: str, token: str) -> DeviceRegistrationResponse:
|
||||||
|
"""Fetch device credentials using one-time onboarding token."""
|
||||||
|
response = self._request("GET", f"/devices/{device_id}/credentials", params={"token": token})
|
||||||
|
data = response.json()
|
||||||
|
|
||||||
|
# DeviceCertificateResponse from FastAPI doesn't include protocol
|
||||||
|
# We'll use "mqtt" as default since credentials endpoint is only for MQTT devices
|
||||||
|
return DeviceRegistrationResponse(
|
||||||
|
device_id=data["device_id"],
|
||||||
|
protocol="mqtt", # Credentials endpoint is only for MQTT devices
|
||||||
|
certificate_id=data.get("certificate_id"),
|
||||||
|
ca_certificate_pem=data.get("ca_certificate_pem"),
|
||||||
|
certificate_pem=data.get("certificate_pem"),
|
||||||
|
private_key_pem=data.get("private_key_pem"),
|
||||||
|
expires_at=datetime.fromisoformat(data["expires_at"].replace("Z", "+00:00")) if data.get("expires_at") else None,
|
||||||
|
credential_id=None,
|
||||||
|
api_key=None,
|
||||||
|
webhook_secret=None,
|
||||||
|
)
|
||||||
|
|
||||||
def health_check(self) -> bool:
|
def health_check(self) -> bool:
|
||||||
try:
|
try:
|
||||||
response = self.session.get(f"{self.base_url}/docs", timeout=2)
|
response = self.session.get(f"{self.base_url}/docs", timeout=2)
|
||||||
@@ -191,3 +218,7 @@ def revoke_certificate(device_id: str) -> Dict[str, Any]:
|
|||||||
|
|
||||||
def renew_certificate(device_id: str) -> Dict[str, Any]:
|
def renew_certificate(device_id: str) -> Dict[str, Any]:
|
||||||
return default_client.renew_certificate(device_id)
|
return default_client.renew_certificate(device_id)
|
||||||
|
|
||||||
|
|
||||||
|
def delete_device(device_id: str) -> Dict[str, Any]:
|
||||||
|
return default_client.delete_device(device_id)
|
||||||
|
|||||||
336
iotDashboard/gpt_service_client.py
Normal file
336
iotDashboard/gpt_service_client.py
Normal file
@@ -0,0 +1,336 @@
|
|||||||
|
"""
|
||||||
|
Client for GPT Service microservice.
|
||||||
|
|
||||||
|
Handles communication between Django and the GPT analysis service.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import httpx
|
||||||
|
import logging
|
||||||
|
from typing import List, Dict, Any, Optional, Literal
|
||||||
|
from django.conf import settings
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class GPTServiceError(Exception):
|
||||||
|
"""Exception raised when GPT service request fails."""
|
||||||
|
|
||||||
|
def __init__(self, message: str, status_code: Optional[int] = None, details: Optional[Dict] = None):
|
||||||
|
self.message = message
|
||||||
|
self.status_code = status_code
|
||||||
|
self.details = details or {}
|
||||||
|
super().__init__(self.message)
|
||||||
|
|
||||||
|
|
||||||
|
class GPTServiceClient:
|
||||||
|
"""Client for interacting with the GPT analysis microservice."""
|
||||||
|
|
||||||
|
def __init__(self, base_url: Optional[str] = None, timeout: float = 30.0):
|
||||||
|
"""
|
||||||
|
Initialize GPT service client.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
base_url: Base URL of GPT service (default: from settings or http://localhost:8001)
|
||||||
|
timeout: Request timeout in seconds
|
||||||
|
"""
|
||||||
|
self.base_url = base_url or getattr(settings, 'GPT_SERVICE_URL', 'http://localhost:8001')
|
||||||
|
self.timeout = timeout
|
||||||
|
self.client = httpx.AsyncClient(base_url=self.base_url, timeout=timeout)
|
||||||
|
logger.info(f"Initialized GPTServiceClient with base_url={self.base_url}")
|
||||||
|
|
||||||
|
async def health_check(self) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Check if GPT service is healthy.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dict with status information
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
httpx.HTTPError: If service is unreachable
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
response = await self.client.get("/health")
|
||||||
|
response.raise_for_status()
|
||||||
|
return response.json()
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"GPT service health check failed: {e}")
|
||||||
|
raise
|
||||||
|
|
||||||
|
async def generate_daily_briefing(
|
||||||
|
self,
|
||||||
|
briefing_type: Literal["schedule", "environment", "full"],
|
||||||
|
current_time: str,
|
||||||
|
indoor_data: Optional[Dict[str, Any]] = None,
|
||||||
|
outdoor_data: Optional[Dict[str, Any]] = None,
|
||||||
|
health_data: Optional[Dict[str, Any]] = None,
|
||||||
|
calendar_events: Optional[List[Dict[str, Any]]] = None,
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Generate a daily briefing for office workers.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
briefing_type: 'schedule', 'environment', or 'full'
|
||||||
|
current_time: Current time in ISO format
|
||||||
|
indoor_data: Indoor environment readings
|
||||||
|
outdoor_data: Weather and air quality data
|
||||||
|
health_data: Health/fitness metrics
|
||||||
|
calendar_events: List of upcoming calendar events
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dict with status_emoji, status_line, insights, recommendations
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
GPTServiceError: If request fails
|
||||||
|
"""
|
||||||
|
payload = {
|
||||||
|
"briefing_type": briefing_type,
|
||||||
|
"current_time": current_time,
|
||||||
|
}
|
||||||
|
|
||||||
|
if indoor_data:
|
||||||
|
payload["indoor_data"] = indoor_data
|
||||||
|
if outdoor_data:
|
||||||
|
payload["outdoor_data"] = outdoor_data
|
||||||
|
if health_data:
|
||||||
|
payload["health_data"] = health_data
|
||||||
|
if calendar_events:
|
||||||
|
payload["calendar_events"] = calendar_events
|
||||||
|
|
||||||
|
try:
|
||||||
|
logger.info(f"Requesting {briefing_type} daily briefing")
|
||||||
|
response = await self.client.post("/daily-briefing", json=payload)
|
||||||
|
response.raise_for_status()
|
||||||
|
result = response.json()
|
||||||
|
logger.info(f"Daily briefing generated successfully")
|
||||||
|
return result
|
||||||
|
except httpx.HTTPStatusError as e:
|
||||||
|
error_detail = e.response.text
|
||||||
|
logger.error(f"GPT service returned error {e.response.status_code}: {error_detail}")
|
||||||
|
raise GPTServiceError(
|
||||||
|
message=f"GPT service error: {error_detail}",
|
||||||
|
status_code=e.response.status_code,
|
||||||
|
details={"response": error_detail}
|
||||||
|
)
|
||||||
|
except httpx.RequestError as e:
|
||||||
|
logger.error(f"Failed to connect to GPT service: {e}")
|
||||||
|
raise GPTServiceError(
|
||||||
|
message=f"GPT service unavailable: {str(e)}",
|
||||||
|
status_code=503,
|
||||||
|
details={"error": str(e)}
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to generate daily briefing: {e}")
|
||||||
|
raise GPTServiceError(
|
||||||
|
message=f"Briefing generation failed: {str(e)}",
|
||||||
|
details={"error": str(e)}
|
||||||
|
)
|
||||||
|
|
||||||
|
async def analyze_telemetry(
|
||||||
|
self,
|
||||||
|
telemetry_data: List[Dict[str, Any]],
|
||||||
|
device_info: Optional[Dict[str, Any]] = None,
|
||||||
|
prompt_type: Literal["anomaly_detection", "trend_summary", "custom"] = "trend_summary",
|
||||||
|
custom_prompt: Optional[str] = None
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Analyze telemetry data using GPT service.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
telemetry_data: List of telemetry data points with keys:
|
||||||
|
- device_id: str
|
||||||
|
- metric: str
|
||||||
|
- value: float
|
||||||
|
- timestamp: str (ISO format)
|
||||||
|
device_info: Optional device metadata (name, location, etc.)
|
||||||
|
prompt_type: Type of analysis to perform
|
||||||
|
custom_prompt: Custom prompt for 'custom' type
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dict containing:
|
||||||
|
- analysis: str (AI-generated analysis)
|
||||||
|
- prompt_type: str
|
||||||
|
- data_points_analyzed: int
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ValueError: If telemetry_data is empty
|
||||||
|
httpx.HTTPError: If service request fails
|
||||||
|
"""
|
||||||
|
if not telemetry_data:
|
||||||
|
raise ValueError("telemetry_data cannot be empty")
|
||||||
|
|
||||||
|
payload = {
|
||||||
|
"telemetry_data": telemetry_data,
|
||||||
|
"device_info": device_info or {},
|
||||||
|
"prompt_type": prompt_type,
|
||||||
|
}
|
||||||
|
|
||||||
|
if custom_prompt:
|
||||||
|
payload["custom_prompt"] = custom_prompt
|
||||||
|
|
||||||
|
try:
|
||||||
|
logger.info(
|
||||||
|
f"Requesting {prompt_type} analysis for {len(telemetry_data)} data points"
|
||||||
|
)
|
||||||
|
response = await self.client.post("/analyze", json=payload)
|
||||||
|
response.raise_for_status()
|
||||||
|
result = response.json()
|
||||||
|
logger.info(
|
||||||
|
f"Analysis completed: {result.get('data_points_analyzed')} points analyzed"
|
||||||
|
)
|
||||||
|
return result
|
||||||
|
except httpx.HTTPStatusError as e:
|
||||||
|
error_detail = e.response.text
|
||||||
|
logger.error(
|
||||||
|
f"GPT service returned error {e.response.status_code}: {error_detail}"
|
||||||
|
)
|
||||||
|
raise GPTServiceError(
|
||||||
|
message=f"GPT service error: {error_detail}",
|
||||||
|
status_code=e.response.status_code,
|
||||||
|
details={"response": error_detail}
|
||||||
|
)
|
||||||
|
except httpx.RequestError as e:
|
||||||
|
logger.error(f"Failed to connect to GPT service: {e}")
|
||||||
|
raise GPTServiceError(
|
||||||
|
message=f"GPT service unavailable: {str(e)}",
|
||||||
|
status_code=503,
|
||||||
|
details={"error": str(e)}
|
||||||
|
)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to analyze telemetry: {e}")
|
||||||
|
raise GPTServiceError(
|
||||||
|
message=f"Analysis failed: {str(e)}",
|
||||||
|
details={"error": str(e)}
|
||||||
|
)
|
||||||
|
|
||||||
|
async def detect_anomalies(
|
||||||
|
self,
|
||||||
|
telemetry_data: List[Dict[str, Any]],
|
||||||
|
device_info: Optional[Dict[str, Any]] = None
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Detect anomalies in telemetry data.
|
||||||
|
|
||||||
|
Convenience method for anomaly_detection analysis.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dict with analysis containing:
|
||||||
|
- status: normal|warning|critical
|
||||||
|
- summary: Brief overview
|
||||||
|
- anomalies: List of detected anomalies
|
||||||
|
- impacts: List of potential impacts
|
||||||
|
- actions: List of recommended actions
|
||||||
|
"""
|
||||||
|
result = await self.analyze_telemetry(
|
||||||
|
telemetry_data=telemetry_data,
|
||||||
|
device_info=device_info,
|
||||||
|
prompt_type="anomaly_detection"
|
||||||
|
)
|
||||||
|
return result
|
||||||
|
|
||||||
|
async def summarize_trends(
|
||||||
|
self,
|
||||||
|
telemetry_data: List[Dict[str, Any]],
|
||||||
|
device_info: Optional[Dict[str, Any]] = None
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Summarize trends in telemetry data.
|
||||||
|
|
||||||
|
Convenience method for trend_summary analysis.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dict with analysis containing:
|
||||||
|
- status: excellent|good|fair|poor
|
||||||
|
- summary: Brief overview
|
||||||
|
- trends: List of metric trends
|
||||||
|
- comfort_score: Overall comfort rating
|
||||||
|
- patterns: Identified patterns
|
||||||
|
- recommendations: Suggested actions
|
||||||
|
"""
|
||||||
|
result = await self.analyze_telemetry(
|
||||||
|
telemetry_data=telemetry_data,
|
||||||
|
device_info=device_info,
|
||||||
|
prompt_type="trend_summary"
|
||||||
|
)
|
||||||
|
return result
|
||||||
|
|
||||||
|
async def close(self):
|
||||||
|
"""Close the HTTP client connection."""
|
||||||
|
await self.client.aclose()
|
||||||
|
|
||||||
|
async def __aenter__(self):
|
||||||
|
"""Async context manager entry."""
|
||||||
|
return self
|
||||||
|
|
||||||
|
async def __aexit__(self, exc_type, exc_val, exc_tb):
|
||||||
|
"""Async context manager exit."""
|
||||||
|
await self.close()
|
||||||
|
|
||||||
|
|
||||||
|
def format_telemetry_for_gpt(queryset, device_id: Optional[str] = None) -> List[Dict[str, Any]]:
|
||||||
|
"""
|
||||||
|
Format Django Telemetry queryset for GPT service.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
queryset: Django queryset of Telemetry objects
|
||||||
|
device_id: Optional device_id to include in each point
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
List of dicts formatted for GPT service
|
||||||
|
"""
|
||||||
|
data = []
|
||||||
|
for point in queryset:
|
||||||
|
data.append({
|
||||||
|
"device_id": device_id or point.device_id,
|
||||||
|
"metric": point.metric,
|
||||||
|
"value": float(point.value),
|
||||||
|
"timestamp": point.time.isoformat(),
|
||||||
|
})
|
||||||
|
return data
|
||||||
|
|
||||||
|
|
||||||
|
_client_instance = None
|
||||||
|
|
||||||
|
|
||||||
|
def get_gpt_client() -> GPTServiceClient:
|
||||||
|
"""
|
||||||
|
Get or create singleton GPT service client instance.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
GPTServiceClient instance
|
||||||
|
"""
|
||||||
|
global _client_instance
|
||||||
|
if _client_instance is None:
|
||||||
|
_client_instance = GPTServiceClient()
|
||||||
|
return _client_instance
|
||||||
|
|
||||||
|
|
||||||
|
async def analyze_telemetry(
|
||||||
|
telemetry_data: List[Dict[str, Any]],
|
||||||
|
device_info: Optional[Dict[str, Any]] = None,
|
||||||
|
prompt_type: Literal["anomaly_detection", "trend_summary", "custom"] = "trend_summary",
|
||||||
|
custom_prompt: Optional[str] = None
|
||||||
|
) -> Dict[str, Any]:
|
||||||
|
"""
|
||||||
|
Module-level function to analyze telemetry data.
|
||||||
|
|
||||||
|
Uses singleton client instance. Convenience wrapper for Django views.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
telemetry_data: List of telemetry data points
|
||||||
|
device_info: Optional device metadata
|
||||||
|
prompt_type: Type of analysis to perform
|
||||||
|
custom_prompt: Custom prompt for 'custom' type
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Dict containing analysis results
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
GPTServiceError: If analysis fails
|
||||||
|
"""
|
||||||
|
client = get_gpt_client()
|
||||||
|
return await client.analyze_telemetry(
|
||||||
|
telemetry_data=telemetry_data,
|
||||||
|
device_info=device_info,
|
||||||
|
prompt_type=prompt_type,
|
||||||
|
custom_prompt=custom_prompt
|
||||||
|
)
|
||||||
354
iotDashboard/health_insights.py
Normal file
354
iotDashboard/health_insights.py
Normal file
@@ -0,0 +1,354 @@
|
|||||||
|
"""
|
||||||
|
Health Insights Calculator
|
||||||
|
|
||||||
|
Correlates health metrics with environmental data to provide contextual insights.
|
||||||
|
Shows how weather and air quality affect health metrics.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from typing import Dict, List, Optional
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class HealthMetrics:
|
||||||
|
"""Current health metrics."""
|
||||||
|
steps: int = 0
|
||||||
|
active_calories: int = 0
|
||||||
|
heart_rate: Optional[float] = None
|
||||||
|
resting_heart_rate: Optional[float] = None
|
||||||
|
sleep_duration: Optional[float] = None # minutes
|
||||||
|
timestamp: Optional[datetime] = None
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class EnvironmentalContext:
|
||||||
|
"""Environmental context for health insights."""
|
||||||
|
temperature: Optional[float] = None
|
||||||
|
humidity: Optional[float] = None
|
||||||
|
pm25: Optional[float] = None
|
||||||
|
pm10: Optional[float] = None
|
||||||
|
air_quality_status: Optional[str] = None
|
||||||
|
weather_description: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class HealthInsight:
|
||||||
|
"""Individual health insight."""
|
||||||
|
metric: str
|
||||||
|
value: float
|
||||||
|
context: str
|
||||||
|
correlation: Optional[str] = None
|
||||||
|
recommendation: Optional[str] = None
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class HealthInsightsResult:
|
||||||
|
"""Health insights with environmental context."""
|
||||||
|
health_metrics: Dict[str, float]
|
||||||
|
environmental_context: Dict[str, Optional[float]]
|
||||||
|
insights: List[HealthInsight]
|
||||||
|
correlations: List[str]
|
||||||
|
recommendations: List[str]
|
||||||
|
trend_indicators: List[str]
|
||||||
|
|
||||||
|
|
||||||
|
class HealthInsightsCalculator:
|
||||||
|
"""Calculate contextual health insights from health and environmental data."""
|
||||||
|
|
||||||
|
# Thresholds for correlations
|
||||||
|
TEMP_HR_CORRELATION_THRESHOLD = 3.0 # bpm per degree C above 22°C
|
||||||
|
PM25_HR_THRESHOLD = 20.0 # μg/m³ - above this may affect HR
|
||||||
|
PM10_HR_THRESHOLD = 30.0 # μg/m³ - above this may affect HR
|
||||||
|
OPTIMAL_TEMP_FOR_ACTIVITY = (18.0, 22.0) # °C
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def analyze_heart_rate(
|
||||||
|
hr: Optional[float],
|
||||||
|
resting_hr: Optional[float],
|
||||||
|
env: EnvironmentalContext
|
||||||
|
) -> List[HealthInsight]:
|
||||||
|
"""Analyze heart rate with environmental context."""
|
||||||
|
insights = []
|
||||||
|
|
||||||
|
if hr is None:
|
||||||
|
return insights
|
||||||
|
|
||||||
|
# Base insight
|
||||||
|
hr_insight = HealthInsight(
|
||||||
|
metric="Heart Rate",
|
||||||
|
value=hr,
|
||||||
|
context=f"Current: {hr:.0f} bpm"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Compare with resting HR
|
||||||
|
if resting_hr is not None:
|
||||||
|
elevation = hr - resting_hr
|
||||||
|
if elevation > 20:
|
||||||
|
hr_insight.context += f" (elevated by {elevation:.0f} bpm from resting)"
|
||||||
|
if env.temperature is not None and env.temperature > 25:
|
||||||
|
hr_insight.correlation = f"High temperature ({env.temperature:.1f}°C) may be contributing to elevated HR"
|
||||||
|
hr_insight.recommendation = "Stay hydrated and avoid intense activity in heat"
|
||||||
|
elif elevation > 10:
|
||||||
|
hr_insight.context += f" (slightly elevated by {elevation:.0f} bpm)"
|
||||||
|
|
||||||
|
# Temperature correlation
|
||||||
|
if env.temperature is not None:
|
||||||
|
if env.temperature > 25:
|
||||||
|
expected_hr_increase = (env.temperature - 22) * HealthInsightsCalculator.TEMP_HR_CORRELATION_THRESHOLD
|
||||||
|
if hr_insight.correlation is None:
|
||||||
|
hr_insight.correlation = f"Temperature ({env.temperature:.1f}°C) may increase HR by ~{expected_hr_increase:.0f} bpm"
|
||||||
|
elif env.temperature < 15:
|
||||||
|
hr_insight.correlation = f"Cool temperature ({env.temperature:.1f}°C) - HR may be lower than usual"
|
||||||
|
|
||||||
|
# Air quality correlation
|
||||||
|
if env.pm25 is not None and env.pm25 > HealthInsightsCalculator.PM25_HR_THRESHOLD:
|
||||||
|
if hr_insight.correlation:
|
||||||
|
hr_insight.correlation += f". Poor air quality (PM2.5: {env.pm25:.1f} μg/m³) may also affect HR"
|
||||||
|
else:
|
||||||
|
hr_insight.correlation = f"Poor air quality (PM2.5: {env.pm25:.1f} μg/m³) may be affecting HR"
|
||||||
|
hr_insight.recommendation = "Consider indoor activity when air quality improves"
|
||||||
|
|
||||||
|
if env.pm10 is not None and env.pm10 > HealthInsightsCalculator.PM10_HR_THRESHOLD:
|
||||||
|
if hr_insight.correlation and "air quality" not in hr_insight.correlation.lower():
|
||||||
|
hr_insight.correlation += f". High PM10 ({env.pm10:.1f} μg/m³) may affect breathing"
|
||||||
|
|
||||||
|
insights.append(hr_insight)
|
||||||
|
return insights
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def analyze_activity(
|
||||||
|
steps: int,
|
||||||
|
calories: int,
|
||||||
|
env: EnvironmentalContext
|
||||||
|
) -> List[HealthInsight]:
|
||||||
|
"""Analyze activity levels with environmental context."""
|
||||||
|
insights = []
|
||||||
|
|
||||||
|
# Steps insight
|
||||||
|
steps_insight = HealthInsight(
|
||||||
|
metric="Steps",
|
||||||
|
value=steps,
|
||||||
|
context=f"Today: {steps:,} steps"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Activity level assessment
|
||||||
|
if steps >= 10000:
|
||||||
|
steps_insight.context += " - Excellent activity level!"
|
||||||
|
elif steps >= 7500:
|
||||||
|
steps_insight.context += " - Good activity level"
|
||||||
|
elif steps >= 5000:
|
||||||
|
steps_insight.context += " - Moderate activity"
|
||||||
|
else:
|
||||||
|
steps_insight.context += " - Low activity today"
|
||||||
|
if env.temperature is not None:
|
||||||
|
temp_min, temp_max = HealthInsightsCalculator.OPTIMAL_TEMP_FOR_ACTIVITY
|
||||||
|
if temp_min <= env.temperature <= temp_max:
|
||||||
|
steps_insight.recommendation = f"Perfect weather ({env.temperature:.1f}°C) - great time for outdoor activity!"
|
||||||
|
elif env.temperature > temp_max:
|
||||||
|
steps_insight.recommendation = f"Warm weather ({env.temperature:.1f}°C) - consider early morning or evening activity"
|
||||||
|
else:
|
||||||
|
steps_insight.recommendation = f"Cool weather ({env.temperature:.1f}°C) - dress warmly for outdoor activity"
|
||||||
|
|
||||||
|
# Weather correlation
|
||||||
|
if env.weather_description:
|
||||||
|
if "clear" in env.weather_description.lower() or "sunny" in env.weather_description.lower():
|
||||||
|
if steps < 5000:
|
||||||
|
steps_insight.correlation = "Clear weather - perfect for outdoor activity"
|
||||||
|
elif "rain" in env.weather_description.lower() or "snow" in env.weather_description.lower():
|
||||||
|
steps_insight.correlation = f"Weather: {env.weather_description} - may limit outdoor activity"
|
||||||
|
|
||||||
|
insights.append(steps_insight)
|
||||||
|
|
||||||
|
# Calories insight
|
||||||
|
calories_insight = HealthInsight(
|
||||||
|
metric="Active Calories",
|
||||||
|
value=calories,
|
||||||
|
context=f"Today: {calories:,} kcal"
|
||||||
|
)
|
||||||
|
|
||||||
|
if calories >= 500:
|
||||||
|
calories_insight.context += " - Great calorie burn!"
|
||||||
|
elif calories >= 300:
|
||||||
|
calories_insight.context += " - Good calorie burn"
|
||||||
|
|
||||||
|
insights.append(calories_insight)
|
||||||
|
return insights
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def generate_correlations(
|
||||||
|
health: HealthMetrics,
|
||||||
|
env: EnvironmentalContext
|
||||||
|
) -> List[str]:
|
||||||
|
"""Generate correlation statements."""
|
||||||
|
correlations = []
|
||||||
|
|
||||||
|
# HR vs Temperature
|
||||||
|
if health.heart_rate is not None and env.temperature is not None:
|
||||||
|
if env.temperature > 25:
|
||||||
|
correlations.append(f"Your HR ({health.heart_rate:.0f} bpm) may be elevated due to high temperature ({env.temperature:.1f}°C)")
|
||||||
|
elif env.temperature < 15:
|
||||||
|
correlations.append(f"Cool temperature ({env.temperature:.1f}°C) may result in lower HR than usual")
|
||||||
|
|
||||||
|
# HR vs Air Quality
|
||||||
|
if health.heart_rate is not None:
|
||||||
|
if env.pm25 is not None and env.pm25 > HealthInsightsCalculator.PM25_HR_THRESHOLD:
|
||||||
|
correlations.append(f"Elevated HR may be related to poor air quality (PM2.5: {env.pm25:.1f} μg/m³)")
|
||||||
|
if env.pm10 is not None and env.pm10 > HealthInsightsCalculator.PM10_HR_THRESHOLD:
|
||||||
|
correlations.append(f"High PM10 ({env.pm10:.1f} μg/m³) may affect breathing and HR")
|
||||||
|
|
||||||
|
# Activity vs Weather
|
||||||
|
if health.steps > 0 and env.weather_description:
|
||||||
|
if "clear" in env.weather_description.lower() or "sunny" in env.weather_description.lower():
|
||||||
|
if health.steps >= 10000:
|
||||||
|
correlations.append("Great activity on a clear day!")
|
||||||
|
elif "rain" in env.weather_description.lower():
|
||||||
|
correlations.append("Rainy weather may have limited outdoor activity")
|
||||||
|
|
||||||
|
# Activity vs Temperature
|
||||||
|
if health.steps > 0 and env.temperature is not None:
|
||||||
|
temp_min, temp_max = HealthInsightsCalculator.OPTIMAL_TEMP_FOR_ACTIVITY
|
||||||
|
if temp_min <= env.temperature <= temp_max:
|
||||||
|
if health.steps >= 10000:
|
||||||
|
correlations.append(f"Optimal temperature ({env.temperature:.1f}°C) for high activity")
|
||||||
|
elif env.temperature > temp_max:
|
||||||
|
correlations.append(f"Warm weather ({env.temperature:.1f}°C) may reduce activity levels")
|
||||||
|
|
||||||
|
return correlations
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def generate_recommendations(
|
||||||
|
health: HealthMetrics,
|
||||||
|
env: EnvironmentalContext
|
||||||
|
) -> List[str]:
|
||||||
|
"""Generate personalized recommendations."""
|
||||||
|
recommendations = []
|
||||||
|
|
||||||
|
# Activity recommendations
|
||||||
|
if health.steps < 5000:
|
||||||
|
if env.temperature is not None:
|
||||||
|
temp_min, temp_max = HealthInsightsCalculator.OPTIMAL_TEMP_FOR_ACTIVITY
|
||||||
|
if temp_min <= env.temperature <= temp_max:
|
||||||
|
recommendations.append(f"Perfect weather ({env.temperature:.1f}°C) - great time for a walk or run!")
|
||||||
|
elif env.temperature > temp_max:
|
||||||
|
recommendations.append(f"Warm weather ({env.temperature:.1f}°C) - consider early morning or evening activity")
|
||||||
|
else:
|
||||||
|
recommendations.append(f"Cool weather ({env.temperature:.1f}°C) - dress warmly for outdoor activity")
|
||||||
|
|
||||||
|
# Air quality recommendations
|
||||||
|
if env.pm25 is not None and env.pm25 > HealthInsightsCalculator.PM25_HR_THRESHOLD:
|
||||||
|
recommendations.append(f"Air quality is moderate (PM2.5: {env.pm25:.1f} μg/m³) - sensitive individuals should limit outdoor activity")
|
||||||
|
|
||||||
|
if env.pm10 is not None and env.pm10 > HealthInsightsCalculator.PM10_HR_THRESHOLD:
|
||||||
|
recommendations.append(f"High PM10 ({env.pm10:.1f} μg/m³) - consider indoor alternatives")
|
||||||
|
|
||||||
|
# HR recommendations
|
||||||
|
if health.heart_rate is not None and health.resting_heart_rate is not None:
|
||||||
|
elevation = health.heart_rate - health.resting_heart_rate
|
||||||
|
if elevation > 20:
|
||||||
|
if env.temperature is not None and env.temperature > 25:
|
||||||
|
recommendations.append("Elevated HR in hot weather - stay hydrated and rest if needed")
|
||||||
|
elif env.pm25 is not None and env.pm25 > HealthInsightsCalculator.PM25_HR_THRESHOLD:
|
||||||
|
recommendations.append("Elevated HR may be related to air quality - consider indoor rest")
|
||||||
|
else:
|
||||||
|
recommendations.append("Elevated HR - may indicate stress or fatigue, consider rest")
|
||||||
|
|
||||||
|
# Goal progress
|
||||||
|
if health.steps > 0:
|
||||||
|
goal_progress = (health.steps / 10000) * 100
|
||||||
|
if goal_progress < 50:
|
||||||
|
recommendations.append(f"Daily goal: {goal_progress:.0f}% complete - good time for activity")
|
||||||
|
elif goal_progress >= 100:
|
||||||
|
recommendations.append("Daily goal exceeded - excellent work!")
|
||||||
|
|
||||||
|
return recommendations
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def generate_trend_indicators(
|
||||||
|
health: HealthMetrics,
|
||||||
|
env: EnvironmentalContext
|
||||||
|
) -> List[str]:
|
||||||
|
"""Generate trend indicators (simplified - no historical data yet)."""
|
||||||
|
indicators = []
|
||||||
|
|
||||||
|
# Activity trends (would need historical data for real trends)
|
||||||
|
if health.steps >= 10000:
|
||||||
|
indicators.append("High activity level today")
|
||||||
|
elif health.steps >= 7500:
|
||||||
|
indicators.append("Good activity level")
|
||||||
|
|
||||||
|
# Environmental impact indicators
|
||||||
|
if env.temperature is not None:
|
||||||
|
temp_min, temp_max = HealthInsightsCalculator.OPTIMAL_TEMP_FOR_ACTIVITY
|
||||||
|
if temp_min <= env.temperature <= temp_max:
|
||||||
|
indicators.append("Optimal temperature for activity")
|
||||||
|
elif env.temperature > temp_max:
|
||||||
|
indicators.append("Warm weather may reduce activity")
|
||||||
|
|
||||||
|
if env.pm25 is not None and env.pm25 < 15:
|
||||||
|
indicators.append("Good air quality - ideal for outdoor activity")
|
||||||
|
elif env.pm25 is not None and env.pm25 > HealthInsightsCalculator.PM25_HR_THRESHOLD:
|
||||||
|
indicators.append("Moderate air quality - may affect activity")
|
||||||
|
|
||||||
|
return indicators
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def calculate(
|
||||||
|
cls,
|
||||||
|
health: HealthMetrics,
|
||||||
|
env: EnvironmentalContext
|
||||||
|
) -> HealthInsightsResult:
|
||||||
|
"""
|
||||||
|
Calculate health insights with environmental context.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
health: Current health metrics
|
||||||
|
env: Environmental context
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
HealthInsightsResult with insights, correlations, and recommendations
|
||||||
|
"""
|
||||||
|
# Analyze individual metrics
|
||||||
|
insights = []
|
||||||
|
insights.extend(cls.analyze_heart_rate(health.heart_rate, health.resting_heart_rate, env))
|
||||||
|
insights.extend(cls.analyze_activity(health.steps, health.active_calories, env))
|
||||||
|
|
||||||
|
# Generate correlations
|
||||||
|
correlations = cls.generate_correlations(health, env)
|
||||||
|
|
||||||
|
# Generate recommendations
|
||||||
|
recommendations = cls.generate_recommendations(health, env)
|
||||||
|
|
||||||
|
# Generate trend indicators
|
||||||
|
trend_indicators = cls.generate_trend_indicators(health, env)
|
||||||
|
|
||||||
|
# Prepare health metrics dict
|
||||||
|
health_metrics = {
|
||||||
|
"steps": health.steps,
|
||||||
|
"active_calories": health.active_calories,
|
||||||
|
"heart_rate": health.heart_rate,
|
||||||
|
"resting_heart_rate": health.resting_heart_rate,
|
||||||
|
}
|
||||||
|
|
||||||
|
# Prepare environmental context dict
|
||||||
|
environmental_context = {
|
||||||
|
"temperature": env.temperature,
|
||||||
|
"humidity": env.humidity,
|
||||||
|
"pm25": env.pm25,
|
||||||
|
"pm10": env.pm10,
|
||||||
|
"air_quality_status": env.air_quality_status,
|
||||||
|
"weather_description": env.weather_description,
|
||||||
|
}
|
||||||
|
|
||||||
|
return HealthInsightsResult(
|
||||||
|
health_metrics=health_metrics,
|
||||||
|
environmental_context=environmental_context,
|
||||||
|
insights=insights,
|
||||||
|
correlations=correlations,
|
||||||
|
recommendations=recommendations,
|
||||||
|
trend_indicators=trend_indicators,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
23
iotDashboard/jwt_serializers.py
Normal file
23
iotDashboard/jwt_serializers.py
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
"""
|
||||||
|
Custom JWT serializers for IoT Dashboard.
|
||||||
|
Handles string-based user IDs instead of integer IDs.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from rest_framework_simplejwt.serializers import TokenObtainPairSerializer
|
||||||
|
from rest_framework_simplejwt.tokens import RefreshToken
|
||||||
|
|
||||||
|
|
||||||
|
class CustomTokenObtainPairSerializer(TokenObtainPairSerializer):
|
||||||
|
"""
|
||||||
|
Custom token serializer that handles string user IDs.
|
||||||
|
"""
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def get_token(cls, user):
|
||||||
|
token = RefreshToken.for_user(user)
|
||||||
|
|
||||||
|
# Add custom claims
|
||||||
|
token['username'] = user.username
|
||||||
|
token['email'] = user.email
|
||||||
|
|
||||||
|
return token
|
||||||
@@ -10,73 +10,8 @@ class Migration(migrations.Migration):
|
|||||||
]
|
]
|
||||||
|
|
||||||
operations = [
|
operations = [
|
||||||
migrations.CreateModel(
|
# Note: Device model is now managed=False, so we don't modify it in migrations
|
||||||
name="SensorType",
|
# SensorType and Sensor models are also not in current models.py
|
||||||
fields=[
|
# This migration is kept for historical reference but operations are removed
|
||||||
(
|
# to prevent errors with managed=False models
|
||||||
"id",
|
]
|
||||||
models.BigAutoField(
|
|
||||||
auto_created=True,
|
|
||||||
primary_key=True,
|
|
||||||
serialize=False,
|
|
||||||
verbose_name="ID",
|
|
||||||
),
|
|
||||||
),
|
|
||||||
("name", models.CharField(max_length=50, unique=True)),
|
|
||||||
("unit", models.CharField(max_length=20)),
|
|
||||||
(
|
|
||||||
"protocol",
|
|
||||||
models.CharField(
|
|
||||||
choices=[("mqtt", "MQTT"), ("http", "HTTP")], max_length=20
|
|
||||||
),
|
|
||||||
),
|
|
||||||
("topic", models.CharField(blank=True, max_length=100, null=True)),
|
|
||||||
("endpoint", models.CharField(blank=True, max_length=100, null=True)),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
migrations.RemoveField(
|
|
||||||
model_name="device",
|
|
||||||
name="humidity",
|
|
||||||
),
|
|
||||||
migrations.RemoveField(
|
|
||||||
model_name="device",
|
|
||||||
name="temperature",
|
|
||||||
),
|
|
||||||
migrations.AlterField(
|
|
||||||
model_name="device",
|
|
||||||
name="protocol",
|
|
||||||
field=models.CharField(
|
|
||||||
choices=[("mqtt", "MQTT"), ("http", "HTTP")], max_length=20
|
|
||||||
),
|
|
||||||
),
|
|
||||||
migrations.CreateModel(
|
|
||||||
name="Sensor",
|
|
||||||
fields=[
|
|
||||||
(
|
|
||||||
"id",
|
|
||||||
models.BigAutoField(
|
|
||||||
auto_created=True,
|
|
||||||
primary_key=True,
|
|
||||||
serialize=False,
|
|
||||||
verbose_name="ID",
|
|
||||||
),
|
|
||||||
),
|
|
||||||
("enabled", models.BooleanField(default=True)),
|
|
||||||
(
|
|
||||||
"device",
|
|
||||||
models.ForeignKey(
|
|
||||||
on_delete=django.db.models.deletion.CASCADE,
|
|
||||||
related_name="sensors",
|
|
||||||
to="iotDashboard.device",
|
|
||||||
),
|
|
||||||
),
|
|
||||||
(
|
|
||||||
"type",
|
|
||||||
models.ForeignKey(
|
|
||||||
on_delete=django.db.models.deletion.CASCADE,
|
|
||||||
to="iotDashboard.sensortype",
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
]
|
|
||||||
82
iotDashboard/migrations/0003_create_dashboard_layouts.py
Normal file
82
iotDashboard/migrations/0003_create_dashboard_layouts.py
Normal file
@@ -0,0 +1,82 @@
|
|||||||
|
# Generated by Django 5.2.7 on 2025-11-19 22:57
|
||||||
|
|
||||||
|
from django.db import migrations, models
|
||||||
|
|
||||||
|
|
||||||
|
class Migration(migrations.Migration):
|
||||||
|
|
||||||
|
dependencies = [
|
||||||
|
('iotDashboard', '0002_sensortype_remove_device_humidity_and_more'),
|
||||||
|
]
|
||||||
|
|
||||||
|
operations = [
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='DeviceCertificate',
|
||||||
|
fields=[
|
||||||
|
('id', models.CharField(max_length=255, primary_key=True, serialize=False)),
|
||||||
|
('certificate_pem', models.TextField()),
|
||||||
|
('private_key_pem', models.TextField(blank=True, null=True)),
|
||||||
|
('issued_at', models.DateTimeField()),
|
||||||
|
('expires_at', models.DateTimeField()),
|
||||||
|
('revoked_at', models.DateTimeField(blank=True, null=True)),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'db_table': 'device_certificates',
|
||||||
|
'managed': False,
|
||||||
|
},
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='DeviceCredential',
|
||||||
|
fields=[
|
||||||
|
('id', models.CharField(max_length=255, primary_key=True, serialize=False)),
|
||||||
|
('credential_type', models.CharField(max_length=50)),
|
||||||
|
('credential_hash', models.TextField()),
|
||||||
|
('created_at', models.DateTimeField()),
|
||||||
|
('expires_at', models.DateTimeField(blank=True, null=True)),
|
||||||
|
('revoked_at', models.DateTimeField(blank=True, null=True)),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'db_table': 'device_credentials',
|
||||||
|
'managed': False,
|
||||||
|
},
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='Telemetry',
|
||||||
|
fields=[
|
||||||
|
('time', models.DateTimeField(primary_key=True, serialize=False)),
|
||||||
|
('device_id', models.CharField(db_column='device_id', max_length=255)),
|
||||||
|
('metric', models.CharField(max_length=255)),
|
||||||
|
('value', models.FloatField()),
|
||||||
|
('unit', models.CharField(blank=True, max_length=50, null=True)),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'db_table': 'telemetry',
|
||||||
|
'managed': False,
|
||||||
|
},
|
||||||
|
),
|
||||||
|
migrations.AlterModelOptions(
|
||||||
|
name='device',
|
||||||
|
options={'managed': False},
|
||||||
|
),
|
||||||
|
migrations.CreateModel(
|
||||||
|
name='DashboardLayout',
|
||||||
|
fields=[
|
||||||
|
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||||
|
('name', models.CharField(default='default', help_text="Layout name (e.g., 'default', 'mobile', 'work')", max_length=255, unique=True)),
|
||||||
|
('config', models.JSONField(help_text='Full dashboard configuration JSON')),
|
||||||
|
('is_default', models.BooleanField(default=True, help_text='Whether this is the default layout')),
|
||||||
|
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||||
|
('updated_at', models.DateTimeField(auto_now=True)),
|
||||||
|
],
|
||||||
|
options={
|
||||||
|
'db_table': 'dashboard_layouts',
|
||||||
|
'indexes': [models.Index(fields=['is_default'], name='dashboard_l_is_defa_033b71_idx'), models.Index(fields=['name'], name='dashboard_l_name_c36020_idx')],
|
||||||
|
},
|
||||||
|
),
|
||||||
|
migrations.DeleteModel(
|
||||||
|
name='Sensor',
|
||||||
|
),
|
||||||
|
migrations.DeleteModel(
|
||||||
|
name='SensorType',
|
||||||
|
),
|
||||||
|
]
|
||||||
@@ -152,12 +152,15 @@ class DeviceCredential(models.Model):
|
|||||||
|
|
||||||
|
|
||||||
class Telemetry(models.Model):
|
class Telemetry(models.Model):
|
||||||
"""Time-series telemetry data from devices."""
|
"""Time-series telemetry data from devices (TimescaleDB hypertable).
|
||||||
|
|
||||||
|
Note: This table has a composite primary key (time, device_id, metric).
|
||||||
|
Since Django doesn't support composite PKs well, we mark time as the PK
|
||||||
|
but queries should filter by (time, device_id, metric) together.
|
||||||
|
"""
|
||||||
|
|
||||||
time = models.DateTimeField()
|
time = models.DateTimeField(primary_key=True)
|
||||||
device = models.ForeignKey(
|
device_id = models.CharField(max_length=255, db_column="device_id")
|
||||||
Device, on_delete=models.CASCADE, related_name="telemetry", db_column="device_id"
|
|
||||||
)
|
|
||||||
metric = models.CharField(max_length=255)
|
metric = models.CharField(max_length=255)
|
||||||
value = models.FloatField()
|
value = models.FloatField()
|
||||||
unit = models.CharField(max_length=50, null=True, blank=True)
|
unit = models.CharField(max_length=50, null=True, blank=True)
|
||||||
@@ -165,11 +168,28 @@ class Telemetry(models.Model):
|
|||||||
class Meta:
|
class Meta:
|
||||||
managed = False
|
managed = False
|
||||||
db_table = "telemetry"
|
db_table = "telemetry"
|
||||||
unique_together = [["time", "device", "metric"]]
|
# Note: The actual database has composite PK (time, device_id, metric)
|
||||||
indexes = [
|
# Django limitation: can only mark one field as PK
|
||||||
models.Index(fields=["device", "time"]),
|
unique_together = [["time", "device_id", "metric"]]
|
||||||
]
|
|
||||||
|
|
||||||
def __str__(self):
|
def __str__(self):
|
||||||
return f"{self.device.name} - {self.metric}: {self.value} at {self.time}"
|
return f"{self.device.name} - {self.metric}: {self.value} at {self.time}"
|
||||||
|
|
||||||
|
|
||||||
|
class User(models.Model):
|
||||||
|
"""Dashboard users for authentication (mirrors SQLAlchemy model)."""
|
||||||
|
|
||||||
|
id = models.CharField(max_length=255, primary_key=True)
|
||||||
|
username = models.CharField(max_length=255, unique=True)
|
||||||
|
email = models.CharField(max_length=255, unique=True)
|
||||||
|
password_hash = models.CharField(max_length=255)
|
||||||
|
is_active = models.BooleanField(default=True)
|
||||||
|
created_at = models.DateTimeField(auto_now_add=True)
|
||||||
|
|
||||||
|
class Meta:
|
||||||
|
managed = False
|
||||||
|
db_table = "users"
|
||||||
|
|
||||||
|
def __str__(self):
|
||||||
|
return self.username
|
||||||
|
|
||||||
|
|||||||
349
iotDashboard/run_suitability.py
Normal file
349
iotDashboard/run_suitability.py
Normal file
@@ -0,0 +1,349 @@
|
|||||||
|
"""
|
||||||
|
Run Suitability Calculator
|
||||||
|
|
||||||
|
Combines weather, air quality, and health data to determine if conditions are suitable for running.
|
||||||
|
Provides time-based recommendations and personalized insights.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from typing import List, Optional, Tuple
|
||||||
|
from dataclasses import dataclass
|
||||||
|
from datetime import datetime, time
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class WeatherData:
|
||||||
|
"""Weather data for run suitability analysis."""
|
||||||
|
temperature: float
|
||||||
|
apparent_temperature: float
|
||||||
|
wind_speed: float # km/h
|
||||||
|
precipitation: float # mm
|
||||||
|
rain: float # mm
|
||||||
|
weather_code: int
|
||||||
|
humidity: float # %
|
||||||
|
cloud_cover: float # %
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class AirQualityData:
|
||||||
|
"""Air quality data for run suitability analysis."""
|
||||||
|
pm25: Optional[float] = None
|
||||||
|
pm10: Optional[float] = None
|
||||||
|
status: str = "Unknown"
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class HealthData:
|
||||||
|
"""Health data for run suitability analysis."""
|
||||||
|
steps_today: int = 0
|
||||||
|
active_calories: int = 0
|
||||||
|
heart_rate: Optional[float] = None
|
||||||
|
resting_heart_rate: Optional[float] = None
|
||||||
|
daily_goal_steps: int = 10000 # Default goal
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class RunSuitabilityScore:
|
||||||
|
"""Run suitability results with breakdown."""
|
||||||
|
status: str # "GO", "MODERATE", "NO"
|
||||||
|
overall_score: float # 0-100
|
||||||
|
weather_score: float
|
||||||
|
air_quality_score: float
|
||||||
|
health_score: float
|
||||||
|
primary_reason: str
|
||||||
|
detailed_insights: List[str]
|
||||||
|
time_recommendations: List[str]
|
||||||
|
suggestions: List[str]
|
||||||
|
|
||||||
|
|
||||||
|
class RunSuitabilityCalculator:
|
||||||
|
"""Calculate run suitability from weather, air quality, and health data."""
|
||||||
|
|
||||||
|
# Optimal ranges for running
|
||||||
|
OPTIMAL_TEMP_RANGE = (10.0, 20.0) # °C - ideal for running
|
||||||
|
ACCEPTABLE_TEMP_RANGE = (5.0, 25.0) # °C - acceptable but not ideal
|
||||||
|
MAX_WIND_SPEED = 25.0 # km/h - above this is too windy
|
||||||
|
MAX_PRECIPITATION = 0.5 # mm - light drizzle OK, more is not
|
||||||
|
MAX_PM25 = 35.0 # μg/m³ - WHO unhealthy threshold
|
||||||
|
MAX_PM10 = 50.0 # μg/m³ - WHO unhealthy threshold
|
||||||
|
MODERATE_PM25 = 15.0 # μg/m³ - moderate threshold
|
||||||
|
MODERATE_PM10 = 20.0 # μg/m³ - moderate threshold
|
||||||
|
|
||||||
|
# Time-based recommendations
|
||||||
|
BEST_TIMES = [
|
||||||
|
(time(6, 0), time(8, 0), "Early morning - cool temperatures, low pollution"),
|
||||||
|
(time(18, 0), time(20, 0), "Evening - comfortable temperatures, good visibility"),
|
||||||
|
]
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def calculate_weather_score(weather: WeatherData) -> Tuple[float, List[str]]:
|
||||||
|
"""Calculate weather suitability score (0-100)."""
|
||||||
|
score = 100.0
|
||||||
|
issues = []
|
||||||
|
|
||||||
|
# Temperature scoring
|
||||||
|
temp = weather.temperature
|
||||||
|
if RunSuitabilityCalculator.OPTIMAL_TEMP_RANGE[0] <= temp <= RunSuitabilityCalculator.OPTIMAL_TEMP_RANGE[1]:
|
||||||
|
# Perfect temperature
|
||||||
|
pass
|
||||||
|
elif RunSuitabilityCalculator.ACCEPTABLE_TEMP_RANGE[0] <= temp <= RunSuitabilityCalculator.ACCEPTABLE_TEMP_RANGE[1]:
|
||||||
|
# Acceptable but not ideal
|
||||||
|
if temp < RunSuitabilityCalculator.OPTIMAL_TEMP_RANGE[0]:
|
||||||
|
deviation = RunSuitabilityCalculator.OPTIMAL_TEMP_RANGE[0] - temp
|
||||||
|
score -= deviation * 5 # -5 points per degree below optimal
|
||||||
|
issues.append(f"Cool ({temp:.1f}°C) - dress warmly")
|
||||||
|
else:
|
||||||
|
deviation = temp - RunSuitabilityCalculator.OPTIMAL_TEMP_RANGE[1]
|
||||||
|
score -= deviation * 3 # -3 points per degree above optimal
|
||||||
|
issues.append(f"Warm ({temp:.1f}°C) - stay hydrated")
|
||||||
|
else:
|
||||||
|
# Too cold or too hot
|
||||||
|
if temp < RunSuitabilityCalculator.ACCEPTABLE_TEMP_RANGE[0]:
|
||||||
|
score -= 50
|
||||||
|
issues.append(f"Too cold ({temp:.1f}°C) - not suitable for running")
|
||||||
|
else:
|
||||||
|
score -= 50
|
||||||
|
issues.append(f"Too hot ({temp:.1f}°C) - risk of heat exhaustion")
|
||||||
|
|
||||||
|
# Wind scoring
|
||||||
|
if weather.wind_speed > RunSuitabilityCalculator.MAX_WIND_SPEED:
|
||||||
|
score -= 30
|
||||||
|
issues.append(f"High wind ({weather.wind_speed:.1f} km/h) - difficult conditions")
|
||||||
|
elif weather.wind_speed > 15.0:
|
||||||
|
score -= 10
|
||||||
|
issues.append(f"Moderate wind ({weather.wind_speed:.1f} km/h) - may affect pace")
|
||||||
|
|
||||||
|
# Precipitation scoring
|
||||||
|
if weather.precipitation > RunSuitabilityCalculator.MAX_PRECIPITATION:
|
||||||
|
score -= 40
|
||||||
|
if weather.rain > 0:
|
||||||
|
issues.append(f"Rain ({weather.rain:.1f} mm) - slippery conditions")
|
||||||
|
else:
|
||||||
|
issues.append(f"Precipitation ({weather.precipitation:.1f} mm) - wet conditions")
|
||||||
|
|
||||||
|
# Weather code (snow, thunderstorms, etc.)
|
||||||
|
if weather.weather_code >= 71: # Snow
|
||||||
|
score -= 50
|
||||||
|
issues.append("Snow - unsafe for running")
|
||||||
|
elif weather.weather_code >= 95: # Thunderstorm
|
||||||
|
score -= 60
|
||||||
|
issues.append("Thunderstorm - dangerous conditions")
|
||||||
|
|
||||||
|
score = max(0, min(100, score))
|
||||||
|
return score, issues
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def calculate_air_quality_score(air_quality: AirQualityData) -> Tuple[float, List[str]]:
|
||||||
|
"""Calculate air quality suitability score (0-100)."""
|
||||||
|
score = 100.0
|
||||||
|
issues = []
|
||||||
|
|
||||||
|
# PM2.5 scoring
|
||||||
|
if air_quality.pm25 is not None:
|
||||||
|
if air_quality.pm25 > RunSuitabilityCalculator.MAX_PM25:
|
||||||
|
score -= 50
|
||||||
|
issues.append(f"Poor air quality (PM2.5: {air_quality.pm25:.1f} μg/m³) - unhealthy for exercise")
|
||||||
|
elif air_quality.pm25 > RunSuitabilityCalculator.MODERATE_PM25:
|
||||||
|
score -= 20
|
||||||
|
issues.append(f"Moderate air quality (PM2.5: {air_quality.pm25:.1f} μg/m³) - sensitive individuals should avoid")
|
||||||
|
|
||||||
|
# PM10 scoring
|
||||||
|
if air_quality.pm10 is not None:
|
||||||
|
if air_quality.pm10 > RunSuitabilityCalculator.MAX_PM10:
|
||||||
|
score -= 50
|
||||||
|
issues.append(f"Poor air quality (PM10: {air_quality.pm10:.1f} μg/m³) - unhealthy for exercise")
|
||||||
|
elif air_quality.pm10 > RunSuitabilityCalculator.MODERATE_PM10:
|
||||||
|
score -= 20
|
||||||
|
issues.append(f"Moderate air quality (PM10: {air_quality.pm10:.1f} μg/m³) - may affect breathing")
|
||||||
|
|
||||||
|
# Status-based scoring
|
||||||
|
status_lower = air_quality.status.lower()
|
||||||
|
if "unhealthy" in status_lower or "hazardous" in status_lower:
|
||||||
|
score = min(score, 30)
|
||||||
|
elif "moderate" in status_lower or "sensitive" in status_lower:
|
||||||
|
score = min(score, 70)
|
||||||
|
|
||||||
|
score = max(0, min(100, score))
|
||||||
|
return score, issues
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def calculate_health_score(health: HealthData) -> Tuple[float, List[str]]:
|
||||||
|
"""Calculate health context score (0-100)."""
|
||||||
|
score = 100.0
|
||||||
|
insights = []
|
||||||
|
|
||||||
|
# Check if user is already very active today
|
||||||
|
if health.steps_today > 15000:
|
||||||
|
score -= 10
|
||||||
|
insights.append("High activity today - consider rest or light activity")
|
||||||
|
elif health.steps_today > 10000:
|
||||||
|
insights.append(f"Good activity level ({health.steps_today:,} steps) - ready for a run")
|
||||||
|
|
||||||
|
# Check daily goal progress
|
||||||
|
goal_progress = (health.steps_today / health.daily_goal_steps) * 100 if health.daily_goal_steps > 0 else 0
|
||||||
|
if goal_progress < 50:
|
||||||
|
insights.append(f"Daily goal: {goal_progress:.0f}% complete - good time for a run")
|
||||||
|
elif goal_progress > 100:
|
||||||
|
insights.append("Daily goal exceeded - great job!")
|
||||||
|
|
||||||
|
# Heart rate context
|
||||||
|
if health.heart_rate is not None:
|
||||||
|
if health.heart_rate > 100:
|
||||||
|
score -= 15
|
||||||
|
insights.append(f"Elevated heart rate ({health.heart_rate:.0f} bpm) - may need rest")
|
||||||
|
elif health.heart_rate > 85:
|
||||||
|
score -= 5
|
||||||
|
insights.append(f"Slightly elevated HR ({health.heart_rate:.0f} bpm) - consider lighter activity")
|
||||||
|
|
||||||
|
if health.resting_heart_rate is not None and health.heart_rate is not None:
|
||||||
|
hr_elevation = health.heart_rate - health.resting_heart_rate
|
||||||
|
if hr_elevation > 20:
|
||||||
|
insights.append("Heart rate significantly elevated - may indicate stress or fatigue")
|
||||||
|
|
||||||
|
score = max(0, min(100, score))
|
||||||
|
return score, insights
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def get_time_recommendations(current_time: Optional[datetime] = None) -> List[str]:
|
||||||
|
"""Get time-based recommendations for running."""
|
||||||
|
if current_time is None:
|
||||||
|
current_time = datetime.now()
|
||||||
|
|
||||||
|
current_hour = current_time.hour
|
||||||
|
recommendations = []
|
||||||
|
|
||||||
|
# Check if current time is in an optimal range
|
||||||
|
in_optimal_time = False
|
||||||
|
for start_time, end_time, description in RunSuitabilityCalculator.BEST_TIMES:
|
||||||
|
if start_time.hour <= current_hour < end_time.hour:
|
||||||
|
recommendations.append(f"Current time is ideal: {description}")
|
||||||
|
in_optimal_time = True
|
||||||
|
break
|
||||||
|
|
||||||
|
if not in_optimal_time:
|
||||||
|
# Find next optimal time window
|
||||||
|
next_window = None
|
||||||
|
for start_time, end_time, description in RunSuitabilityCalculator.BEST_TIMES:
|
||||||
|
if current_hour < start_time.hour:
|
||||||
|
# This window is later today
|
||||||
|
hours_until = start_time.hour - current_hour
|
||||||
|
next_window = (hours_until, start_time, description, "today")
|
||||||
|
break
|
||||||
|
|
||||||
|
# If no window found later today, next window is tomorrow morning
|
||||||
|
if next_window is None:
|
||||||
|
first_start, _, first_desc = RunSuitabilityCalculator.BEST_TIMES[0]
|
||||||
|
hours_until = (24 - current_hour) + first_start.hour
|
||||||
|
next_window = (hours_until, first_start, first_desc, "tomorrow")
|
||||||
|
|
||||||
|
if next_window:
|
||||||
|
hours_until, next_start, description, when = next_window
|
||||||
|
recommendations.append(
|
||||||
|
f"Next optimal time: {next_start.strftime('%I:%M %p')} {when} "
|
||||||
|
f"(in {hours_until} hours) - {description}"
|
||||||
|
)
|
||||||
|
|
||||||
|
return recommendations
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def calculate(
|
||||||
|
cls,
|
||||||
|
weather: WeatherData,
|
||||||
|
air_quality: AirQualityData,
|
||||||
|
health: HealthData,
|
||||||
|
current_time: Optional[datetime] = None
|
||||||
|
) -> RunSuitabilityScore:
|
||||||
|
"""
|
||||||
|
Calculate overall run suitability.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
weather: Weather data
|
||||||
|
air_quality: Air quality data
|
||||||
|
health: Health data
|
||||||
|
current_time: Current time for recommendations (default: now)
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
RunSuitabilityScore with status, scores, and insights
|
||||||
|
"""
|
||||||
|
if current_time is None:
|
||||||
|
current_time = datetime.now()
|
||||||
|
|
||||||
|
# Calculate component scores
|
||||||
|
weather_score, weather_issues = cls.calculate_weather_score(weather)
|
||||||
|
air_quality_score, air_quality_issues = cls.calculate_air_quality_score(air_quality)
|
||||||
|
health_score, health_insights = cls.calculate_health_score(health)
|
||||||
|
|
||||||
|
# Weighted overall score
|
||||||
|
overall_score = (
|
||||||
|
weather_score * 0.40 +
|
||||||
|
air_quality_score * 0.35 +
|
||||||
|
health_score * 0.25
|
||||||
|
)
|
||||||
|
|
||||||
|
# Determine status
|
||||||
|
if overall_score >= 75:
|
||||||
|
status = "GO"
|
||||||
|
elif overall_score >= 50:
|
||||||
|
status = "MODERATE"
|
||||||
|
else:
|
||||||
|
status = "NO"
|
||||||
|
|
||||||
|
# Primary reason
|
||||||
|
primary_reason = "Conditions are perfect for running!"
|
||||||
|
if status == "NO":
|
||||||
|
if weather_score < 50:
|
||||||
|
primary_reason = weather_issues[0] if weather_issues else "Weather conditions are poor"
|
||||||
|
elif air_quality_score < 50:
|
||||||
|
primary_reason = air_quality_issues[0] if air_quality_issues else "Air quality is poor"
|
||||||
|
else:
|
||||||
|
primary_reason = "Conditions are not suitable for running"
|
||||||
|
elif status == "MODERATE":
|
||||||
|
if weather_score < 70:
|
||||||
|
primary_reason = weather_issues[0] if weather_issues else "Weather conditions are moderate"
|
||||||
|
elif air_quality_score < 70:
|
||||||
|
primary_reason = air_quality_issues[0] if air_quality_issues else "Air quality is moderate"
|
||||||
|
else:
|
||||||
|
primary_reason = "Conditions are okay, but not ideal"
|
||||||
|
|
||||||
|
# Combine all insights
|
||||||
|
detailed_insights = []
|
||||||
|
detailed_insights.extend(weather_issues)
|
||||||
|
detailed_insights.extend(air_quality_issues)
|
||||||
|
detailed_insights.extend(health_insights)
|
||||||
|
|
||||||
|
# Time recommendations
|
||||||
|
time_recommendations = cls.get_time_recommendations(current_time)
|
||||||
|
|
||||||
|
# Suggestions
|
||||||
|
suggestions = []
|
||||||
|
if status == "GO":
|
||||||
|
suggestions.append("Perfect conditions - enjoy your run!")
|
||||||
|
if health.steps_today < health.daily_goal_steps * 0.5:
|
||||||
|
suggestions.append("Great time to work toward your daily step goal")
|
||||||
|
elif status == "MODERATE":
|
||||||
|
suggestions.append("Conditions are acceptable - consider shorter or easier route")
|
||||||
|
if weather_score < 70:
|
||||||
|
suggestions.append("Dress appropriately for weather conditions")
|
||||||
|
if air_quality_score < 70:
|
||||||
|
suggestions.append("Sensitive individuals should consider indoor alternatives")
|
||||||
|
else:
|
||||||
|
suggestions.append("Consider indoor workout or wait for better conditions")
|
||||||
|
if weather_score < 50:
|
||||||
|
suggestions.append("Check weather forecast for better times")
|
||||||
|
if air_quality_score < 50:
|
||||||
|
suggestions.append("Air quality should improve later - check back in a few hours")
|
||||||
|
|
||||||
|
return RunSuitabilityScore(
|
||||||
|
status=status,
|
||||||
|
overall_score=round(overall_score, 1),
|
||||||
|
weather_score=round(weather_score, 1),
|
||||||
|
air_quality_score=round(air_quality_score, 1),
|
||||||
|
health_score=round(health_score, 1),
|
||||||
|
primary_reason=primary_reason,
|
||||||
|
detailed_insights=detailed_insights,
|
||||||
|
time_recommendations=time_recommendations,
|
||||||
|
suggestions=suggestions,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@@ -33,11 +33,12 @@ POSTGRES_USER = os.getenv("POSTGRES_USER")
|
|||||||
POSTGRES_DB = os.getenv("POSTGRES_DB")
|
POSTGRES_DB = os.getenv("POSTGRES_DB")
|
||||||
POSTGRES_PASSWORD = os.getenv("POSTGRES_PASSWORD")
|
POSTGRES_PASSWORD = os.getenv("POSTGRES_PASSWORD")
|
||||||
|
|
||||||
|
GPT_SERVICE_URL = os.getenv("GPT_SERVICE_URL", "http://localhost:8001")
|
||||||
|
|
||||||
# SECURITY WARNING: don't run with debug turned on in production!
|
# SECURITY WARNING: don't run with debug turned on in production!
|
||||||
DEBUG = True
|
DEBUG = True
|
||||||
|
|
||||||
ALLOWED_HOSTS = []
|
ALLOWED_HOSTS = ['*']
|
||||||
|
|
||||||
|
|
||||||
# Application definition
|
# Application definition
|
||||||
|
|
||||||
@@ -48,11 +49,15 @@ INSTALLED_APPS = [
|
|||||||
"django.contrib.sessions",
|
"django.contrib.sessions",
|
||||||
"django.contrib.messages",
|
"django.contrib.messages",
|
||||||
"django.contrib.staticfiles",
|
"django.contrib.staticfiles",
|
||||||
|
"rest_framework",
|
||||||
|
"rest_framework_simplejwt",
|
||||||
|
"corsheaders",
|
||||||
"iotDashboard",
|
"iotDashboard",
|
||||||
]
|
]
|
||||||
|
|
||||||
MIDDLEWARE = [
|
MIDDLEWARE = [
|
||||||
"django.middleware.security.SecurityMiddleware",
|
"django.middleware.security.SecurityMiddleware",
|
||||||
|
"corsheaders.middleware.CorsMiddleware",
|
||||||
"django.contrib.sessions.middleware.SessionMiddleware",
|
"django.contrib.sessions.middleware.SessionMiddleware",
|
||||||
"django.middleware.common.CommonMiddleware",
|
"django.middleware.common.CommonMiddleware",
|
||||||
"django.middleware.csrf.CsrfViewMiddleware",
|
"django.middleware.csrf.CsrfViewMiddleware",
|
||||||
@@ -139,3 +144,41 @@ STATIC_URL = "static/"
|
|||||||
|
|
||||||
DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField"
|
DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField"
|
||||||
|
|
||||||
|
# Django REST Framework
|
||||||
|
# https://www.django-rest-framework.org/api-guide/settings/
|
||||||
|
REST_FRAMEWORK = {
|
||||||
|
'DEFAULT_RENDERER_CLASSES': [
|
||||||
|
'rest_framework.renderers.JSONRenderer',
|
||||||
|
],
|
||||||
|
'DEFAULT_PARSER_CLASSES': [
|
||||||
|
'rest_framework.parsers.JSONParser',
|
||||||
|
],
|
||||||
|
'DEFAULT_AUTHENTICATION_CLASSES': [
|
||||||
|
'rest_framework_simplejwt.authentication.JWTAuthentication',
|
||||||
|
'rest_framework.authentication.SessionAuthentication', # For admin only
|
||||||
|
],
|
||||||
|
'DEFAULT_PERMISSION_CLASSES': [
|
||||||
|
'rest_framework.permissions.AllowAny', # Keep APIs open, auth only in frontend
|
||||||
|
],
|
||||||
|
}
|
||||||
|
|
||||||
|
# Simple JWT Settings
|
||||||
|
from datetime import timedelta
|
||||||
|
SIMPLE_JWT = {
|
||||||
|
'ACCESS_TOKEN_LIFETIME': timedelta(hours=8),
|
||||||
|
'REFRESH_TOKEN_LIFETIME': timedelta(days=30),
|
||||||
|
'ROTATE_REFRESH_TOKENS': True,
|
||||||
|
'AUTH_HEADER_TYPES': ('Bearer',),
|
||||||
|
}
|
||||||
|
|
||||||
|
# CORS Settings (for React frontend)
|
||||||
|
CORS_ALLOWED_ORIGINS = [
|
||||||
|
"http://localhost:5173",
|
||||||
|
"http://127.0.0.1:5173",
|
||||||
|
"http://localhost:3000",
|
||||||
|
"http://127.0.0.1:3000",
|
||||||
|
"https://lyncis.ferdzo.xyz",
|
||||||
|
"http://lyncis.ferdzo.xyz",
|
||||||
|
]
|
||||||
|
|
||||||
|
CORS_ALLOW_CREDENTIALS = True
|
||||||
|
|||||||
@@ -16,30 +16,30 @@ Including another URLconf
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
from django.contrib import admin
|
from django.contrib import admin
|
||||||
from django.urls import path
|
from django.urls import path, include
|
||||||
from iotDashboard import views
|
from iotDashboard import views
|
||||||
|
from rest_framework_simplejwt.views import TokenObtainPairView, TokenRefreshView
|
||||||
|
|
||||||
urlpatterns = [
|
urlpatterns = [
|
||||||
path("admin/", admin.site.urls),
|
path("admin/", admin.site.urls),
|
||||||
|
|
||||||
# Main dashboard
|
# JWT Authentication
|
||||||
path("", views.chart, name="index"),
|
path("api/auth/login/", TokenObtainPairView.as_view(), name="token_obtain_pair"),
|
||||||
path("chart/", views.chart, name="chart"),
|
path("api/auth/refresh/", TokenRefreshView.as_view(), name="token_refresh"),
|
||||||
|
|
||||||
# Device management
|
# REST API
|
||||||
path("devices/", views.device_list, name="device_list"),
|
path("api/", include("iotDashboard.api.urls")),
|
||||||
path("devices/add/", views.add_device, name="add_device"),
|
|
||||||
path("devices/<str:device_id>/", views.view_device, name="view_device"),
|
|
||||||
path("devices/<str:device_id>/delete/", views.delete_device, name="delete_device"),
|
|
||||||
|
|
||||||
# Certificate management (MQTT devices only)
|
# Legacy template views - DISABLED (using React frontend)
|
||||||
path("devices/<str:device_id>/certificate/revoke/", views.revoke_certificate, name="revoke_certificate"),
|
# path("", views.chart, name="index"),
|
||||||
path("devices/<str:device_id>/certificate/renew/", views.renew_certificate, name="renew_certificate"),
|
# path("chart/", views.chart, name="chart"),
|
||||||
|
# path("devices/", views.device_list, name="device_list"),
|
||||||
# Telemetry data API
|
# path("devices/add/", views.add_device, name="add_device"),
|
||||||
path("fetch_device_data/", views.fetch_device_data, name="fetch_device_data"),
|
# path("devices/<str:device_id>/", views.view_device, name="view_device"),
|
||||||
|
# path("devices/<str:device_id>/delete/", views.delete_device, name="delete_device"),
|
||||||
# Legacy/utility endpoints
|
# path("devices/<str:device_id>/certificate/revoke/", views.revoke_certificate, name="revoke_certificate"),
|
||||||
path("devices_api/", views.devices_api, name="devices_api"),
|
# path("devices/<str:device_id>/certificate/renew/", views.renew_certificate, name="renew_certificate"),
|
||||||
path("logout/", views.logout_view, name="logout"),
|
# path("fetch_device_data/", views.fetch_device_data, name="fetch_device_data"),
|
||||||
|
# path("devices_api/", views.devices_api, name="devices_api"),
|
||||||
|
# path("logout/", views.logout_view, name="logout"),
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -180,14 +180,25 @@ def view_device(request, device_id):
|
|||||||
|
|
||||||
|
|
||||||
def delete_device(request, device_id):
|
def delete_device(request, device_id):
|
||||||
"""Delete a device."""
|
"""Delete a device via device_manager microservice."""
|
||||||
try:
|
try:
|
||||||
device = Device.objects.get(id=device_id)
|
device = Device.objects.get(id=device_id)
|
||||||
|
|
||||||
if request.method == "POST":
|
if request.method == "POST":
|
||||||
device_name = device.name
|
device_name = device.name
|
||||||
device.delete()
|
|
||||||
messages.success(request, f"Device '{device_name}' deleted successfully")
|
try:
|
||||||
|
# Call device_manager to delete device and handle certificates
|
||||||
|
device_manager.delete_device(device_id)
|
||||||
|
|
||||||
|
# Delete from Django database (CASCADE will handle related records)
|
||||||
|
device.delete()
|
||||||
|
|
||||||
|
messages.success(request, f"Device '{device_name}' deleted successfully")
|
||||||
|
except DeviceManagerAPIError as e:
|
||||||
|
messages.error(request, f"Failed to delete device: {e.message}")
|
||||||
|
return redirect("device_list")
|
||||||
|
|
||||||
return redirect("device_list")
|
return redirect("device_list")
|
||||||
|
|
||||||
return render(request, "device_confirm_delete.html", {"device": device})
|
return render(request, "device_confirm_delete.html", {"device": device})
|
||||||
@@ -261,3 +272,7 @@ def devices_api(request):
|
|||||||
"""JSON API endpoint for devices."""
|
"""JSON API endpoint for devices."""
|
||||||
devices = list(Device.objects.all().values("id", "name", "protocol", "location"))
|
devices = list(Device.objects.all().values("id", "name", "protocol", "location"))
|
||||||
return JsonResponse(devices, safe=False)
|
return JsonResponse(devices, safe=False)
|
||||||
|
|
||||||
|
def analyze_data(request):
|
||||||
|
"""Calling the GPT Service to analyze the data."""
|
||||||
|
|
||||||
|
|||||||
226
iotDashboard/weather_client.py
Normal file
226
iotDashboard/weather_client.py
Normal file
@@ -0,0 +1,226 @@
|
|||||||
|
"""
|
||||||
|
Weather and Air Quality API clients.
|
||||||
|
|
||||||
|
Weather data: Open-Meteo (https://open-meteo.com)
|
||||||
|
Air Quality data: Pulse.eco (https://pulse.eco)
|
||||||
|
"""
|
||||||
|
import requests
|
||||||
|
from typing import Optional
|
||||||
|
|
||||||
|
|
||||||
|
def fetch_current_weather(latitude: float, longitude: float) -> dict:
|
||||||
|
"""
|
||||||
|
Fetch current weather data using Open-Meteo API (no API key required).
|
||||||
|
|
||||||
|
Args:
|
||||||
|
latitude: Latitude coordinate
|
||||||
|
longitude: Longitude coordinate
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
dict: Current weather data
|
||||||
|
"""
|
||||||
|
base_url = "https://api.open-meteo.com/v1/forecast"
|
||||||
|
params = {
|
||||||
|
"latitude": latitude,
|
||||||
|
"longitude": longitude,
|
||||||
|
"current": "temperature_2m,relative_humidity_2m,apparent_temperature,precipitation,rain,weather_code,cloud_cover,wind_speed_10m,wind_direction_10m",
|
||||||
|
"timezone": "auto",
|
||||||
|
}
|
||||||
|
response = requests.get(base_url, params=params, timeout=10)
|
||||||
|
response.raise_for_status()
|
||||||
|
return response.json()
|
||||||
|
|
||||||
|
|
||||||
|
def parse_weather_data(weather_data: dict, location_name: Optional[str] = None) -> dict:
|
||||||
|
"""
|
||||||
|
Parse relevant weather information from Open-Meteo API response.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
weather_data: Raw API response from Open-Meteo
|
||||||
|
location_name: Optional human-readable location name
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
dict: Parsed weather data with standardized fields
|
||||||
|
"""
|
||||||
|
current = weather_data.get("current", {})
|
||||||
|
|
||||||
|
# Weather code to description mapping (WMO Weather interpretation codes)
|
||||||
|
weather_codes = {
|
||||||
|
0: "Clear sky",
|
||||||
|
1: "Mainly clear",
|
||||||
|
2: "Partly cloudy",
|
||||||
|
3: "Overcast",
|
||||||
|
45: "Foggy",
|
||||||
|
48: "Depositing rime fog",
|
||||||
|
51: "Light drizzle",
|
||||||
|
53: "Moderate drizzle",
|
||||||
|
55: "Dense drizzle",
|
||||||
|
61: "Slight rain",
|
||||||
|
63: "Moderate rain",
|
||||||
|
65: "Heavy rain",
|
||||||
|
71: "Slight snow",
|
||||||
|
73: "Moderate snow",
|
||||||
|
75: "Heavy snow",
|
||||||
|
77: "Snow grains",
|
||||||
|
80: "Slight rain showers",
|
||||||
|
81: "Moderate rain showers",
|
||||||
|
82: "Violent rain showers",
|
||||||
|
85: "Slight snow showers",
|
||||||
|
86: "Heavy snow showers",
|
||||||
|
95: "Thunderstorm",
|
||||||
|
96: "Thunderstorm with slight hail",
|
||||||
|
99: "Thunderstorm with heavy hail",
|
||||||
|
}
|
||||||
|
|
||||||
|
weather_code = current.get("weather_code", 0)
|
||||||
|
|
||||||
|
parsed_data = {
|
||||||
|
"location": location_name or f"({weather_data.get('latitude')}, {weather_data.get('longitude')})",
|
||||||
|
"temperature": current.get("temperature_2m"),
|
||||||
|
"apparent_temperature": current.get("apparent_temperature"),
|
||||||
|
"humidity": current.get("relative_humidity_2m"),
|
||||||
|
"weather_description": weather_codes.get(weather_code, "Unknown"),
|
||||||
|
"weather_code": weather_code,
|
||||||
|
"precipitation": current.get("precipitation"),
|
||||||
|
"rain": current.get("rain"),
|
||||||
|
"cloud_cover": current.get("cloud_cover"),
|
||||||
|
"wind_speed": current.get("wind_speed_10m"),
|
||||||
|
"wind_direction": current.get("wind_direction_10m"),
|
||||||
|
"time": current.get("time"),
|
||||||
|
"timezone": weather_data.get("timezone"),
|
||||||
|
}
|
||||||
|
return parsed_data
|
||||||
|
|
||||||
|
|
||||||
|
def get_air_quality(city: str) -> dict:
|
||||||
|
"""
|
||||||
|
Fetch current air quality data from Pulse.eco API.
|
||||||
|
|
||||||
|
Pulse.eco provides air quality data for cities in North Macedonia and other regions.
|
||||||
|
No API key required for public data.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
city: City name (e.g., 'skopje', 'bitola', 'tetovo')
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
dict: Current air quality measurements
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
requests.HTTPError: If city not found or API error
|
||||||
|
"""
|
||||||
|
base_url = f"https://{city.lower()}.pulse.eco/rest/current"
|
||||||
|
|
||||||
|
response = requests.get(base_url, timeout=10)
|
||||||
|
response.raise_for_status()
|
||||||
|
return response.json()
|
||||||
|
|
||||||
|
|
||||||
|
def parse_air_quality_data(air_quality_data: list, city: str) -> dict:
|
||||||
|
"""
|
||||||
|
Parse air quality data from Pulse.eco API response.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
air_quality_data: List of sensor measurements from Pulse.eco
|
||||||
|
city: City name
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
dict: Aggregated air quality data with averages per pollutant
|
||||||
|
"""
|
||||||
|
if not air_quality_data:
|
||||||
|
return {
|
||||||
|
"city": city,
|
||||||
|
"measurements": {},
|
||||||
|
"status": "No data available"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Aggregate measurements by type
|
||||||
|
pollutants = {}
|
||||||
|
for measurement in air_quality_data:
|
||||||
|
pollutant_type = measurement.get("type")
|
||||||
|
value = measurement.get("value")
|
||||||
|
|
||||||
|
if pollutant_type and value is not None:
|
||||||
|
# Convert value to float (API might return string)
|
||||||
|
try:
|
||||||
|
value_float = float(value)
|
||||||
|
if pollutant_type not in pollutants:
|
||||||
|
pollutants[pollutant_type] = []
|
||||||
|
pollutants[pollutant_type].append(value_float)
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
# Skip invalid values
|
||||||
|
continue
|
||||||
|
|
||||||
|
# Calculate averages
|
||||||
|
averages = {}
|
||||||
|
for pollutant, values in pollutants.items():
|
||||||
|
averages[pollutant] = {
|
||||||
|
"average": sum(values) / len(values),
|
||||||
|
"min": min(values),
|
||||||
|
"max": max(values),
|
||||||
|
"count": len(values),
|
||||||
|
}
|
||||||
|
|
||||||
|
# Determine overall AQI status based on PM10 (most common metric)
|
||||||
|
pm10_avg = averages.get("pm10", {}).get("average")
|
||||||
|
if pm10_avg is not None:
|
||||||
|
if pm10_avg <= 20:
|
||||||
|
status = "Good"
|
||||||
|
elif pm10_avg <= 40:
|
||||||
|
status = "Moderate"
|
||||||
|
elif pm10_avg <= 50:
|
||||||
|
status = "Unhealthy for Sensitive Groups"
|
||||||
|
elif pm10_avg <= 100:
|
||||||
|
status = "Unhealthy"
|
||||||
|
elif pm10_avg <= 150:
|
||||||
|
status = "Very Unhealthy"
|
||||||
|
else:
|
||||||
|
status = "Hazardous"
|
||||||
|
else:
|
||||||
|
status = "Unknown"
|
||||||
|
|
||||||
|
return {
|
||||||
|
"city": city,
|
||||||
|
"measurements": averages,
|
||||||
|
"status": status,
|
||||||
|
"timestamp": air_quality_data[0].get("stamp") if air_quality_data else None,
|
||||||
|
"sensor_count": len(air_quality_data),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def get_weather_by_city(city: str) -> dict:
|
||||||
|
"""
|
||||||
|
Fetch weather data by city name (geocodes city first).
|
||||||
|
|
||||||
|
Uses Open-Meteo geocoding API to convert city name to coordinates,
|
||||||
|
then fetches weather data.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
city: City name (e.g., "Skopje", "Berlin")
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
dict: Parsed weather data
|
||||||
|
"""
|
||||||
|
# Geocode city name to coordinates
|
||||||
|
geocode_url = "https://geocoding-api.open-meteo.com/v1/search"
|
||||||
|
geocode_params = {
|
||||||
|
"name": city,
|
||||||
|
"count": 1,
|
||||||
|
"language": "en",
|
||||||
|
"format": "json",
|
||||||
|
}
|
||||||
|
|
||||||
|
geocode_response = requests.get(geocode_url, params=geocode_params, timeout=10)
|
||||||
|
geocode_response.raise_for_status()
|
||||||
|
geocode_data = geocode_response.json()
|
||||||
|
|
||||||
|
if not geocode_data.get("results"):
|
||||||
|
raise ValueError(f"City '{city}' not found")
|
||||||
|
|
||||||
|
location = geocode_data["results"][0]
|
||||||
|
latitude = location["latitude"]
|
||||||
|
longitude = location["longitude"]
|
||||||
|
location_name = location.get("name", city)
|
||||||
|
|
||||||
|
# Fetch weather data
|
||||||
|
weather_data = fetch_current_weather(latitude, longitude)
|
||||||
|
return parse_weather_data(weather_data, location_name)
|
||||||
@@ -7,12 +7,17 @@ requires-python = ">=3.13"
|
|||||||
dependencies = [
|
dependencies = [
|
||||||
"alembic>=1.17.0",
|
"alembic>=1.17.0",
|
||||||
"django>=5.2.7",
|
"django>=5.2.7",
|
||||||
|
"django-cors-headers>=4.9.0",
|
||||||
|
"django-rest>=0.8.7",
|
||||||
|
"djangorestframework>=3.16.1",
|
||||||
|
"djangorestframework-simplejwt>=5.5.1",
|
||||||
"openai>=2.6.1",
|
"openai>=2.6.1",
|
||||||
"paho-mqtt>=2.1.0",
|
"paho-mqtt>=2.1.0",
|
||||||
"psycopg2-binary>=2.9.11",
|
"psycopg2-binary>=2.9.11",
|
||||||
"python-dotenv>=1.2.1",
|
"python-dotenv>=1.2.1",
|
||||||
"redis>=7.0.1",
|
"redis>=7.0.1",
|
||||||
"requests>=2.32.5",
|
"requests>=2.32.5",
|
||||||
|
"uvicorn>=0.38.0",
|
||||||
]
|
]
|
||||||
|
|
||||||
[dependency-groups]
|
[dependency-groups]
|
||||||
|
|||||||
@@ -20,6 +20,8 @@ WORKDIR /app
|
|||||||
|
|
||||||
COPY --from=builder /app/.venv /app/.venv
|
COPY --from=builder /app/.venv /app/.venv
|
||||||
|
|
||||||
|
COPY --from=builder /app/src/ /app/src/
|
||||||
|
|
||||||
COPY --from=builder /app/*.py /app/
|
COPY --from=builder /app/*.py /app/
|
||||||
|
|
||||||
RUN adduser -D -u 1000 appuser && \
|
RUN adduser -D -u 1000 appuser && \
|
||||||
|
|||||||
@@ -2,405 +2,6 @@
|
|||||||
|
|
||||||
A robust, production-ready service that reads sensor data from Redis streams and writes it to PostgreSQL/TimescaleDB. Part of the IoT Dashboard project.
|
A robust, production-ready service that reads sensor data from Redis streams and writes it to PostgreSQL/TimescaleDB. Part of the IoT Dashboard project.
|
||||||
|
|
||||||
## Features
|
|
||||||
|
|
||||||
- ✅ **Reliable consumption** from Redis streams using consumer groups
|
|
||||||
- ✅ **Batch processing** for high throughput
|
|
||||||
- ✅ **At-least-once delivery** with message acknowledgments
|
|
||||||
- ✅ **Dead letter queue** for failed messages
|
|
||||||
- ✅ **Connection pooling** for database efficiency
|
|
||||||
- ✅ **Graceful shutdown** handling
|
|
||||||
- ✅ **Flexible schema** that adapts to changes
|
|
||||||
- ✅ **Structured logging** with JSON output
|
|
||||||
- ✅ **Health checks** for monitoring
|
|
||||||
- ✅ **TimescaleDB support** for time-series optimization
|
|
||||||
|
|
||||||
## Architecture
|
|
||||||
|
|
||||||
```
|
|
||||||
Redis Streams → Consumer Group → Transform → Database → Acknowledge
|
|
||||||
↓
|
|
||||||
Failed messages
|
|
||||||
↓
|
|
||||||
Dead Letter Queue
|
|
||||||
```
|
|
||||||
|
|
||||||
### Components
|
|
||||||
|
|
||||||
- **`main.py`**: Service orchestration and processing loop
|
|
||||||
- **`redis_reader.py`**: Redis stream consumer with fault tolerance
|
|
||||||
- **`db_writer.py`**: Database operations with connection pooling
|
|
||||||
- **`schema.py`**: Data transformation and validation
|
|
||||||
- **`config.py`**: Configuration management
|
|
||||||
|
|
||||||
## Quick Start
|
|
||||||
|
|
||||||
### Prerequisites
|
|
||||||
|
|
||||||
- Python 3.13+
|
|
||||||
- [uv](https://github.com/astral-sh/uv) package manager
|
|
||||||
- Redis server with streams
|
|
||||||
- PostgreSQL or TimescaleDB
|
|
||||||
|
|
||||||
### Installation
|
|
||||||
|
|
||||||
1. **Navigate to the service directory**:
|
|
||||||
```bash
|
|
||||||
cd services/db_write
|
|
||||||
```
|
|
||||||
|
|
||||||
2. **Copy and configure environment variables**:
|
|
||||||
```bash
|
|
||||||
cp .env.example .env
|
|
||||||
# Edit .env with your DATABASE_URL and other settings
|
|
||||||
```
|
|
||||||
|
|
||||||
3. **Install dependencies**:
|
|
||||||
```bash
|
|
||||||
uv sync
|
|
||||||
```
|
|
||||||
|
|
||||||
4. **Setup database schema** (IMPORTANT - do this before running):
|
|
||||||
```bash
|
|
||||||
# Review the schema in models.py first
|
|
||||||
cat models.py
|
|
||||||
|
|
||||||
# Create initial migration
|
|
||||||
chmod +x migrate.sh
|
|
||||||
./migrate.sh create "initial schema"
|
|
||||||
|
|
||||||
# Review the generated migration
|
|
||||||
ls -lt alembic/versions/
|
|
||||||
|
|
||||||
# Apply migrations
|
|
||||||
./migrate.sh upgrade
|
|
||||||
```
|
|
||||||
|
|
||||||
5. **Run the service**:
|
|
||||||
```bash
|
|
||||||
uv run main.py
|
|
||||||
```
|
|
||||||
|
|
||||||
Or use the standalone script:
|
|
||||||
```bash
|
|
||||||
chmod +x run-standalone.sh
|
|
||||||
./run-standalone.sh
|
|
||||||
```
|
|
||||||
|
|
||||||
### ⚠️ Important: Schema Management
|
|
||||||
|
|
||||||
This service uses **Alembic** for database migrations. The service will NOT create tables automatically.
|
|
||||||
|
|
||||||
- Schema is defined in `models.py`
|
|
||||||
- Migrations are managed with `./migrate.sh` or `alembic` commands
|
|
||||||
- See `SCHEMA_MANAGEMENT.md` for detailed guide
|
|
||||||
|
|
||||||
## Schema Management
|
|
||||||
|
|
||||||
This service uses **SQLAlchemy** for models and **Alembic** for migrations.
|
|
||||||
|
|
||||||
### Key Files
|
|
||||||
|
|
||||||
- **`models.py`**: Define your database schema here (SQLAlchemy models)
|
|
||||||
- **`alembic/`**: Migration scripts directory
|
|
||||||
- **`migrate.sh`**: Helper script for common migration tasks
|
|
||||||
- **`SCHEMA_MANAGEMENT.md`**: Comprehensive migration guide
|
|
||||||
|
|
||||||
### Quick Migration Commands
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Create a new migration after editing models.py
|
|
||||||
./migrate.sh create "add new column"
|
|
||||||
|
|
||||||
# Apply pending migrations
|
|
||||||
./migrate.sh upgrade
|
|
||||||
|
|
||||||
# Check migration status
|
|
||||||
./migrate.sh check
|
|
||||||
|
|
||||||
# View migration history
|
|
||||||
./migrate.sh history
|
|
||||||
|
|
||||||
# Rollback last migration
|
|
||||||
./migrate.sh downgrade 1
|
|
||||||
```
|
|
||||||
|
|
||||||
**See `SCHEMA_MANAGEMENT.md` for detailed documentation.**
|
|
||||||
|
|
||||||
## Configuration
|
|
||||||
|
|
||||||
All configuration is done via environment variables. See `.env.example` for all available options.
|
|
||||||
|
|
||||||
### Required Settings
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Redis connection
|
|
||||||
REDIS_HOST=localhost
|
|
||||||
REDIS_PORT=6379
|
|
||||||
|
|
||||||
# Database connection
|
|
||||||
DATABASE_URL=postgresql://user:password@localhost:5432/iot_dashboard
|
|
||||||
```
|
|
||||||
|
|
||||||
### Optional Settings
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Consumer configuration
|
|
||||||
CONSUMER_GROUP_NAME=db_writer # Consumer group name
|
|
||||||
CONSUMER_NAME=worker-01 # Unique consumer name
|
|
||||||
BATCH_SIZE=100 # Messages per batch
|
|
||||||
BATCH_TIMEOUT_SEC=5 # Read timeout
|
|
||||||
PROCESSING_INTERVAL_SEC=1 # Delay between batches
|
|
||||||
|
|
||||||
# Stream configuration
|
|
||||||
STREAM_PATTERN=mqtt_stream:* # Stream name pattern
|
|
||||||
DEAD_LETTER_STREAM=mqtt_stream:failed
|
|
||||||
|
|
||||||
# Database
|
|
||||||
TABLE_NAME=sensor_readings # Target table name
|
|
||||||
ENABLE_TIMESCALE=false # Use TimescaleDB features
|
|
||||||
|
|
||||||
# Logging
|
|
||||||
LOG_LEVEL=INFO # DEBUG, INFO, WARNING, ERROR
|
|
||||||
LOG_FORMAT=json # json or console
|
|
||||||
```
|
|
||||||
|
|
||||||
## Data Flow
|
|
||||||
|
|
||||||
### Input (Redis Streams)
|
|
||||||
|
|
||||||
The service reads from Redis streams with the format:
|
|
||||||
```
|
|
||||||
mqtt_stream:{device_id}:{sensor_type}
|
|
||||||
```
|
|
||||||
|
|
||||||
Each message contains:
|
|
||||||
```
|
|
||||||
{
|
|
||||||
"value": "23.5",
|
|
||||||
"timestamp": "2023-10-18T14:30:00Z",
|
|
||||||
"metadata": "{...}" (optional)
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Output (Database)
|
|
||||||
|
|
||||||
Data is written to the `sensor_readings` table:
|
|
||||||
|
|
||||||
```sql
|
|
||||||
CREATE TABLE sensor_readings (
|
|
||||||
id BIGSERIAL PRIMARY KEY,
|
|
||||||
timestamp TIMESTAMPTZ NOT NULL,
|
|
||||||
device_id VARCHAR(100) NOT NULL,
|
|
||||||
sensor_type VARCHAR(100) NOT NULL,
|
|
||||||
value DOUBLE PRECISION NOT NULL,
|
|
||||||
metadata JSONB,
|
|
||||||
created_at TIMESTAMPTZ DEFAULT NOW()
|
|
||||||
);
|
|
||||||
```
|
|
||||||
|
|
||||||
**Note**: The table is automatically created if it doesn't exist.
|
|
||||||
|
|
||||||
## Running with Docker
|
|
||||||
|
|
||||||
### Build the image
|
|
||||||
|
|
||||||
```bash
|
|
||||||
docker build -t db-writer:latest .
|
|
||||||
```
|
|
||||||
|
|
||||||
### Run the container
|
|
||||||
|
|
||||||
```bash
|
|
||||||
docker run -d \
|
|
||||||
--name db-writer \
|
|
||||||
-e REDIS_HOST=redis \
|
|
||||||
-e DATABASE_URL=postgresql://user:pass@postgres:5432/iot \
|
|
||||||
db-writer:latest
|
|
||||||
```
|
|
||||||
|
|
||||||
## Consumer Groups
|
|
||||||
|
|
||||||
The service uses Redis consumer groups for reliable, distributed processing:
|
|
||||||
|
|
||||||
- **Multiple instances**: Run multiple workers for load balancing
|
|
||||||
- **Fault tolerance**: Messages are not lost if a consumer crashes
|
|
||||||
- **Acknowledgments**: Messages are only removed after successful processing
|
|
||||||
- **Pending messages**: Unacknowledged messages can be reclaimed
|
|
||||||
|
|
||||||
### Running Multiple Workers
|
|
||||||
|
|
||||||
```bash
|
|
||||||
# Terminal 1
|
|
||||||
CONSUMER_NAME=worker-01 uv run main.py
|
|
||||||
|
|
||||||
# Terminal 2
|
|
||||||
CONSUMER_NAME=worker-02 uv run main.py
|
|
||||||
```
|
|
||||||
|
|
||||||
All workers in the same consumer group will share the load.
|
|
||||||
|
|
||||||
## Error Handling
|
|
||||||
|
|
||||||
### Dead Letter Queue
|
|
||||||
|
|
||||||
Failed messages are sent to the dead letter stream (`mqtt_stream:failed`) with error information:
|
|
||||||
|
|
||||||
```
|
|
||||||
{
|
|
||||||
"original_stream": "mqtt_stream:esp32:temperature",
|
|
||||||
"original_id": "1634567890123-0",
|
|
||||||
"device_id": "esp32",
|
|
||||||
"sensor_type": "temperature",
|
|
||||||
"value": "23.5",
|
|
||||||
"error": "Database connection failed",
|
|
||||||
"failed_at": "1634567890.123"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Retry Strategy
|
|
||||||
|
|
||||||
- **Transient errors**: Automatic retry with backoff
|
|
||||||
- **Data errors**: Immediate send to DLQ
|
|
||||||
- **Connection errors**: Reconnection attempts
|
|
||||||
|
|
||||||
## Monitoring
|
|
||||||
|
|
||||||
### Health Checks
|
|
||||||
|
|
||||||
Check service health programmatically:
|
|
||||||
|
|
||||||
```python
|
|
||||||
from main import DatabaseWriterService
|
|
||||||
|
|
||||||
service = DatabaseWriterService()
|
|
||||||
health = service.health_check()
|
|
||||||
print(health)
|
|
||||||
# {
|
|
||||||
# 'running': True,
|
|
||||||
# 'redis': True,
|
|
||||||
# 'database': True,
|
|
||||||
# 'stats': {...}
|
|
||||||
# }
|
|
||||||
```
|
|
||||||
|
|
||||||
### Logs
|
|
||||||
|
|
||||||
The service outputs structured logs:
|
|
||||||
|
|
||||||
```json
|
|
||||||
{
|
|
||||||
"event": "Processed batch",
|
|
||||||
"rows_written": 100,
|
|
||||||
"messages_acknowledged": 100,
|
|
||||||
"timestamp": "2023-10-18T14:30:00Z",
|
|
||||||
"level": "info"
|
|
||||||
}
|
|
||||||
```
|
|
||||||
|
|
||||||
### Statistics
|
|
||||||
|
|
||||||
Runtime statistics are tracked:
|
|
||||||
- `messages_read`: Total messages consumed
|
|
||||||
- `messages_written`: Total rows inserted
|
|
||||||
- `messages_failed`: Failed messages sent to DLQ
|
|
||||||
- `batches_processed`: Number of successful batches
|
|
||||||
- `errors`: Total errors encountered
|
|
||||||
|
|
||||||
## Development
|
|
||||||
|
|
||||||
### Project Structure
|
|
||||||
|
|
||||||
```
|
|
||||||
db_write/
|
|
||||||
├── config.py # Configuration management
|
|
||||||
├── db_writer.py # Database operations
|
|
||||||
├── redis_reader.py # Redis stream consumer
|
|
||||||
├── schema.py # Data models and transformation
|
|
||||||
├── main.py # Service entry point
|
|
||||||
├── pyproject.toml # Dependencies
|
|
||||||
├── .env.example # Configuration template
|
|
||||||
└── README.md # This file
|
|
||||||
```
|
|
||||||
|
|
||||||
### Adding Dependencies
|
|
||||||
|
|
||||||
```bash
|
|
||||||
uv add package-name
|
|
||||||
```
|
|
||||||
|
|
||||||
### Running Tests
|
|
||||||
|
|
||||||
```bash
|
|
||||||
uv run pytest
|
|
||||||
```
|
|
||||||
|
|
||||||
## Troubleshooting
|
|
||||||
|
|
||||||
### Service won't start
|
|
||||||
|
|
||||||
1. **Check configuration**: Verify all required environment variables are set
|
|
||||||
2. **Test connections**: Ensure Redis and PostgreSQL are accessible
|
|
||||||
3. **Check logs**: Look for specific error messages
|
|
||||||
|
|
||||||
### No messages being processed
|
|
||||||
|
|
||||||
1. **Check streams exist**: `redis-cli KEYS "mqtt_stream:*"`
|
|
||||||
2. **Verify consumer group**: The service creates it automatically, but check Redis logs
|
|
||||||
3. **Check stream pattern**: Ensure `STREAM_PATTERN` matches your stream names
|
|
||||||
|
|
||||||
### Messages going to dead letter queue
|
|
||||||
|
|
||||||
1. **Check DLQ**: `redis-cli XRANGE mqtt_stream:failed - + COUNT 10`
|
|
||||||
2. **Review error messages**: Each DLQ entry contains the error reason
|
|
||||||
3. **Validate data format**: Ensure messages match expected schema
|
|
||||||
|
|
||||||
### High memory usage
|
|
||||||
|
|
||||||
1. **Reduce batch size**: Lower `BATCH_SIZE` in configuration
|
|
||||||
2. **Check connection pool**: May need to adjust pool size
|
|
||||||
3. **Monitor pending messages**: Use `XPENDING` to check backlog
|
|
||||||
|
|
||||||
## Performance Tuning
|
|
||||||
|
|
||||||
### Throughput Optimization
|
|
||||||
|
|
||||||
- **Increase batch size**: Process more messages per batch
|
|
||||||
- **Multiple workers**: Run multiple consumer instances
|
|
||||||
- **Connection pooling**: Adjust pool size based on load
|
|
||||||
- **Processing interval**: Reduce delay between batches
|
|
||||||
|
|
||||||
### Latency Optimization
|
|
||||||
|
|
||||||
- **Decrease batch size**: Process smaller batches more frequently
|
|
||||||
- **Reduce timeout**: Lower `BATCH_TIMEOUT_SEC`
|
|
||||||
- **Single worker**: Avoid consumer group coordination overhead
|
|
||||||
|
|
||||||
## Production Deployment
|
|
||||||
|
|
||||||
### Recommended Settings
|
|
||||||
|
|
||||||
```bash
|
|
||||||
BATCH_SIZE=500
|
|
||||||
PROCESSING_INTERVAL_SEC=0.1
|
|
||||||
LOG_LEVEL=INFO
|
|
||||||
LOG_FORMAT=json
|
|
||||||
ENABLE_TIMESCALE=true
|
|
||||||
```
|
|
||||||
|
|
||||||
### Monitoring
|
|
||||||
|
|
||||||
- Monitor consumer lag using Redis `XPENDING`
|
|
||||||
- Track database insert latency
|
|
||||||
- Alert on error rate > 5%
|
|
||||||
- Monitor DLQ depth
|
|
||||||
|
|
||||||
### Scaling
|
|
||||||
|
|
||||||
1. **Horizontal**: Add more consumer instances with unique `CONSUMER_NAME`
|
|
||||||
2. **Vertical**: Increase resources for database writes
|
|
||||||
3. **Database**: Use TimescaleDB for better time-series performance
|
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
Part of the IoT Dashboard project.
|
Part of the IoT Dashboard project.
|
||||||
|
|||||||
@@ -6,4 +6,5 @@ __pycache__/
|
|||||||
*.pyd
|
*.pyd
|
||||||
*.crl
|
*.crl
|
||||||
*.crt
|
*.crt
|
||||||
*.pem
|
*.pem
|
||||||
|
*.sh
|
||||||
|
|||||||
@@ -0,0 +1,24 @@
|
|||||||
|
# Device Manager Environment Configuration
|
||||||
|
|
||||||
|
# Database connection
|
||||||
|
DATABASE_URL=postgresql://iot_user:your_password@localhost:5432/iot_dashboard
|
||||||
|
|
||||||
|
# Certificate paths
|
||||||
|
CA_CERT_PATH=certs/ca.crt
|
||||||
|
CA_KEY_PATH=certs/ca.key
|
||||||
|
CRL_PATH=certs/ca.crl
|
||||||
|
|
||||||
|
# Certificate settings
|
||||||
|
CERT_VALIDITY_DAYS=365
|
||||||
|
CERT_KEY_SIZE=4096
|
||||||
|
|
||||||
|
|
||||||
|
SERVER_IP=10.10.1.114
|
||||||
|
|
||||||
|
# Device ID settings
|
||||||
|
DEVICE_ID_LENGTH=8
|
||||||
|
|
||||||
|
# Service settings
|
||||||
|
DEVICE_MANAGER_HOST=0.0.0.0
|
||||||
|
DEVICE_MANAGER_PORT=8000
|
||||||
|
LOG_LEVEL=INFO
|
||||||
|
|||||||
@@ -1,12 +1,15 @@
|
|||||||
import datetime
|
import datetime
|
||||||
import logging
|
import logging
|
||||||
|
import secrets
|
||||||
|
|
||||||
from fastapi import FastAPI, HTTPException
|
from cryptography import x509
|
||||||
|
from fastapi import FastAPI, HTTPException, Query
|
||||||
|
|
||||||
from app.cert_manager import CertificateManager
|
from app.cert_manager import CertificateManager
|
||||||
from app.database import get_db_context
|
from app.database import get_db_context
|
||||||
from app.db_models import Device, DeviceCertificate
|
from app.db_models import Device, DeviceCertificate, DeviceOnboardingToken
|
||||||
from app.models import (
|
from app.models import (
|
||||||
|
DeviceCertificateResponse,
|
||||||
DeviceRegistrationRequest,
|
DeviceRegistrationRequest,
|
||||||
DeviceRegistrationResponse,
|
DeviceRegistrationResponse,
|
||||||
DeviceResponse,
|
DeviceResponse,
|
||||||
@@ -61,6 +64,19 @@ async def register_device(
|
|||||||
)
|
)
|
||||||
db.add(device_cert)
|
db.add(device_cert)
|
||||||
|
|
||||||
|
onboarding_token = secrets.token_urlsafe(32)
|
||||||
|
token_expires = datetime.datetime.now(datetime.UTC) + datetime.timedelta(minutes=15)
|
||||||
|
|
||||||
|
db_token = DeviceOnboardingToken(
|
||||||
|
token=onboarding_token,
|
||||||
|
device_id=cert_response.device_id,
|
||||||
|
certificate_id=cert_response.certificate_id,
|
||||||
|
created_at=datetime.datetime.now(datetime.UTC),
|
||||||
|
expires_at=token_expires,
|
||||||
|
)
|
||||||
|
db.add(db_token)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
return DeviceRegistrationResponse(
|
return DeviceRegistrationResponse(
|
||||||
device_id=cert_response.device_id,
|
device_id=cert_response.device_id,
|
||||||
protocol=request.protocol,
|
protocol=request.protocol,
|
||||||
@@ -69,6 +85,7 @@ async def register_device(
|
|||||||
certificate_pem=cert_response.certificate_pem,
|
certificate_pem=cert_response.certificate_pem,
|
||||||
private_key_pem=cert_response.private_key_pem,
|
private_key_pem=cert_response.private_key_pem,
|
||||||
expires_at=cert_response.expires_at,
|
expires_at=cert_response.expires_at,
|
||||||
|
onboarding_token=onboarding_token,
|
||||||
)
|
)
|
||||||
|
|
||||||
else:
|
else:
|
||||||
@@ -99,6 +116,63 @@ async def get_ca_certificate() -> str:
|
|||||||
raise HTTPException(status_code=500, detail="Failed to retrieve CA certificate.") from e
|
raise HTTPException(status_code=500, detail="Failed to retrieve CA certificate.") from e
|
||||||
|
|
||||||
|
|
||||||
|
@app.get("/devices/{device_id}/credentials")
|
||||||
|
async def get_device_credentials(device_id: str, token: str = Query(...)) -> DeviceCertificateResponse:
|
||||||
|
"""
|
||||||
|
Securely fetch device credentials using one-time token from QR code.
|
||||||
|
Token is valid for 15 minutes and can only be used once.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
with get_db_context() as db:
|
||||||
|
db_token = db.query(DeviceOnboardingToken).filter(
|
||||||
|
DeviceOnboardingToken.token == token,
|
||||||
|
DeviceOnboardingToken.device_id == device_id
|
||||||
|
).first()
|
||||||
|
|
||||||
|
if not db_token:
|
||||||
|
raise HTTPException(status_code=404, detail="Invalid or expired onboarding token")
|
||||||
|
|
||||||
|
if db_token.used_at is not None:
|
||||||
|
raise HTTPException(status_code=403, detail="Token has already been used")
|
||||||
|
|
||||||
|
# Check if token expired
|
||||||
|
if datetime.datetime.now(datetime.UTC) > db_token.expires_at:
|
||||||
|
raise HTTPException(status_code=403, detail="Token has expired")
|
||||||
|
|
||||||
|
device_cert = db.query(DeviceCertificate).filter(
|
||||||
|
DeviceCertificate.id == db_token.certificate_id,
|
||||||
|
DeviceCertificate.device_id == device_id,
|
||||||
|
DeviceCertificate.revoked_at.is_(None)
|
||||||
|
).first()
|
||||||
|
|
||||||
|
if not device_cert:
|
||||||
|
raise HTTPException(status_code=404, detail="Certificate not found or revoked")
|
||||||
|
|
||||||
|
db_token.used_at = datetime.datetime.now(datetime.UTC)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
ca_cert_pem = cert_manager.get_ca_certificate_pem()
|
||||||
|
|
||||||
|
logger.info(f"Device {device_id} fetched credentials using onboarding token")
|
||||||
|
|
||||||
|
return DeviceCertificateResponse(
|
||||||
|
certificate_id=device_cert.id,
|
||||||
|
device_id=device_cert.device_id,
|
||||||
|
ca_certificate_pem=ca_cert_pem,
|
||||||
|
certificate_pem=device_cert.certificate_pem,
|
||||||
|
private_key_pem=device_cert.private_key_pem,
|
||||||
|
expires_at=device_cert.expires_at,
|
||||||
|
)
|
||||||
|
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to fetch credentials for device {device_id}: {str(e)}", exc_info=True)
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=500, detail="Failed to fetch device credentials"
|
||||||
|
) from e
|
||||||
|
|
||||||
|
|
||||||
@app.get("/devices/{device_id}")
|
@app.get("/devices/{device_id}")
|
||||||
async def get_device(device_id: str) -> DeviceResponse:
|
async def get_device(device_id: str) -> DeviceResponse:
|
||||||
"""
|
"""
|
||||||
@@ -208,6 +282,34 @@ async def get_crl():
|
|||||||
logger.error(f"Failed to retrieve CRL: {str(e)}", exc_info=True)
|
logger.error(f"Failed to retrieve CRL: {str(e)}", exc_info=True)
|
||||||
raise HTTPException(status_code=500, detail="Failed to retrieve CRL.") from e
|
raise HTTPException(status_code=500, detail="Failed to retrieve CRL.") from e
|
||||||
|
|
||||||
|
@app.post("/devices/{device_id}/delete")
|
||||||
|
async def delete_device(device_id: str):
|
||||||
|
"""
|
||||||
|
Delete a device and its associated certificates from the database.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
with get_db_context() as db:
|
||||||
|
device = db.query(Device).filter(Device.id == device_id).first()
|
||||||
|
if not device:
|
||||||
|
raise HTTPException(status_code=404, detail="Device not found")
|
||||||
|
|
||||||
|
# Delete associated certificates
|
||||||
|
db.query(DeviceCertificate).filter(DeviceCertificate.device_id == device_id).delete()
|
||||||
|
|
||||||
|
# Delete the device
|
||||||
|
db.delete(device)
|
||||||
|
db.commit()
|
||||||
|
|
||||||
|
logger.info(f"Successfully deleted device {device_id} and its certificates")
|
||||||
|
|
||||||
|
return {"message": f"Device {device_id} and its certificates have been deleted."}
|
||||||
|
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to delete device {device_id}: {str(e)}", exc_info=True)
|
||||||
|
raise HTTPException(status_code=500, detail="Failed to delete device.") from e
|
||||||
|
|
||||||
|
|
||||||
@app.post("/devices/{device_id}/renew")
|
@app.post("/devices/{device_id}/renew")
|
||||||
async def renew_certificate(device_id: str):
|
async def renew_certificate(device_id: str):
|
||||||
@@ -254,8 +356,6 @@ async def renew_certificate(device_id: str):
|
|||||||
)
|
)
|
||||||
|
|
||||||
# Extract certificate ID (serial number) from the new certificate
|
# Extract certificate ID (serial number) from the new certificate
|
||||||
from cryptography import x509
|
|
||||||
|
|
||||||
new_cert = x509.load_pem_x509_certificate(new_cert_pem)
|
new_cert = x509.load_pem_x509_certificate(new_cert_pem)
|
||||||
new_cert_id = format(new_cert.serial_number, "x")
|
new_cert_id = format(new_cert.serial_number, "x")
|
||||||
|
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user