Added auth, environment brief, docker for db_migrations,frontend,backend.

This commit is contained in:
2025-12-15 23:40:34 +01:00
parent 3ab81fad8c
commit 1a5bef277d
36 changed files with 1059 additions and 132 deletions

34
.dockerignore Normal file
View 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

49
Dockerfile Normal file
View File

@@ -0,0 +1,49 @@
# Django Backend Dockerfile
FROM ghcr.io/astral-sh/uv:python3.13-alpine AS builder
WORKDIR /app
ENV UV_COMPILE_BYTECODE=1
# Copy dependency files
COPY pyproject.toml uv.lock ./
# Install dependencies
RUN uv sync --frozen --no-dev --no-install-project
# Copy application code
COPY iotDashboard/ ./iotDashboard/
COPY manage.py ./
COPY create_user.py ./
# Sync the project
RUN uv sync --frozen --no-dev
# Stage 2: Runtime
FROM python:3.13-alpine
WORKDIR /app
# Install runtime dependencies
RUN apk add --no-cache postgresql-client
# Copy virtual environment and application
COPY --from=builder /app/.venv /app/.venv
COPY --from=builder /app/iotDashboard/ /app/iotDashboard/
COPY --from=builder /app/manage.py /app/
COPY --from=builder /app/create_user.py /app/
# Create non-root user
RUN adduser -D -u 1000 appuser && \
chown -R appuser:appuser /app
USER appuser
ENV PATH="/app/.venv/bin:$PATH"
ENV PYTHONUNBUFFERED=1
EXPOSE 3000
# Run Django with uvicorn for ASGI
CMD ["python", "-m", "uvicorn", "iotDashboard.asgi:application", "--host", "0.0.0.0", "--port", "3000"]

107
create_user.py Executable file
View 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 *
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()

View File

@@ -0,0 +1,7 @@
.env*
.md
__pycache__/
*.pyc
*.pyo
*.pyd
*.log

12
db_migrations/Dockerfile Normal file
View File

@@ -0,0 +1,12 @@
FROM ghcr.io/astral-sh/uv:python3.13-alpine
WORKDIR /app
COPY pyproject.toml ./
RUN uv sync
COPY . .
CMD ["uv", "run", "alembic", "upgrade", "head"]

View File

@@ -0,0 +1,83 @@
"""add users table
Revision ID: 7c71d43d53e3
Revises: 4b84a36e13f5
Create Date: 2025-12-15 21:24:36.718471+00:00
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
# revision identifiers, used by Alembic.
revision: str = '7c71d43d53e3'
down_revision: Union[str, Sequence[str], None] = '4b84a36e13f5'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
"""Upgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.create_table('users',
sa.Column('id', sa.Text(), nullable=False),
sa.Column('username', sa.Text(), nullable=False),
sa.Column('email', sa.Text(), nullable=False),
sa.Column('password_hash', sa.Text(), nullable=False),
sa.Column('is_active', sa.Boolean(), nullable=True),
sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=True),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('email'),
sa.UniqueConstraint('username')
)
op.create_index('idx_users_email', 'users', ['email'], unique=False)
op.create_index('idx_users_username', 'users', ['username'], unique=False)
op.drop_index(op.f('dashboard_l_is_defa_033b71_idx'), table_name='dashboard_layouts')
op.drop_index(op.f('dashboard_l_name_c36020_idx'), table_name='dashboard_layouts')
op.drop_index(op.f('dashboard_layouts_name_349f3640_like'), table_name='dashboard_layouts', postgresql_ops={'name': 'varchar_pattern_ops'})
op.drop_table('dashboard_layouts')
op.drop_table('django_migrations')
op.drop_table('iotDashboard_device')
op.drop_constraint(op.f('telemetry_device_id_fkey'), 'telemetry', type_='foreignkey')
op.create_foreign_key(None, 'telemetry', 'devices', ['device_id'], ['id'])
# ### end Alembic commands ###
def downgrade() -> None:
"""Downgrade schema."""
# ### commands auto generated by Alembic - please adjust! ###
op.drop_constraint(None, 'telemetry', type_='foreignkey')
op.create_foreign_key(op.f('telemetry_device_id_fkey'), 'telemetry', 'devices', ['device_id'], ['id'], ondelete='CASCADE')
op.create_table('iotDashboard_device',
sa.Column('id', sa.BIGINT(), sa.Identity(always=False, start=1, increment=1, minvalue=1, maxvalue=9223372036854775807, cycle=False, cache=1), autoincrement=True, nullable=False),
sa.Column('name', sa.VARCHAR(length=50), autoincrement=False, nullable=False),
sa.Column('ip', sa.VARCHAR(length=20), autoincrement=False, nullable=False),
sa.Column('protocol', sa.VARCHAR(length=20), autoincrement=False, nullable=False),
sa.PrimaryKeyConstraint('id', name=op.f('iotDashboard_device_pkey'))
)
op.create_table('django_migrations',
sa.Column('id', sa.BIGINT(), sa.Identity(always=False, start=1, increment=1, minvalue=1, maxvalue=9223372036854775807, cycle=False, cache=1), autoincrement=True, nullable=False),
sa.Column('app', sa.VARCHAR(length=255), autoincrement=False, nullable=False),
sa.Column('name', sa.VARCHAR(length=255), autoincrement=False, nullable=False),
sa.Column('applied', postgresql.TIMESTAMP(timezone=True), autoincrement=False, nullable=False),
sa.PrimaryKeyConstraint('id', name=op.f('django_migrations_pkey'))
)
op.create_table('dashboard_layouts',
sa.Column('id', sa.BIGINT(), sa.Identity(always=False, start=1, increment=1, minvalue=1, maxvalue=9223372036854775807, cycle=False, cache=1), autoincrement=True, nullable=False),
sa.Column('name', sa.VARCHAR(length=255), autoincrement=False, nullable=False),
sa.Column('config', postgresql.JSONB(astext_type=sa.Text()), autoincrement=False, nullable=False),
sa.Column('is_default', sa.BOOLEAN(), autoincrement=False, nullable=False),
sa.Column('created_at', postgresql.TIMESTAMP(timezone=True), autoincrement=False, nullable=False),
sa.Column('updated_at', postgresql.TIMESTAMP(timezone=True), autoincrement=False, nullable=False),
sa.PrimaryKeyConstraint('id', name=op.f('dashboard_layouts_pkey')),
sa.UniqueConstraint('name', name=op.f('dashboard_layouts_name_key'), postgresql_include=[], postgresql_nulls_not_distinct=False)
)
op.create_index(op.f('dashboard_layouts_name_349f3640_like'), 'dashboard_layouts', ['name'], unique=False, postgresql_ops={'name': 'varchar_pattern_ops'})
op.create_index(op.f('dashboard_l_name_c36020_idx'), 'dashboard_layouts', ['name'], unique=False)
op.create_index(op.f('dashboard_l_is_defa_033b71_idx'), 'dashboard_layouts', ['is_default'], unique=False)
op.drop_index('idx_users_username', table_name='users')
op.drop_index('idx_users_email', table_name='users')
op.drop_table('users')
# ### end Alembic commands ###

View File

@@ -0,0 +1,45 @@
"""add cascade delete to telemetry foreign key
Revision ID: 1dfb0bb45f93
Revises: 7c71d43d53e3
Create Date: 2025-12-15 21:56:13.260281+00:00
"""
from typing import Sequence, Union
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
# revision identifiers, used by Alembic.
revision: str = '1dfb0bb45f93'
down_revision: Union[str, Sequence[str], None] = '7c71d43d53e3'
branch_labels: Union[str, Sequence[str], None] = None
depends_on: Union[str, Sequence[str], None] = None
def upgrade() -> None:
"""Upgrade schema - only update telemetry foreign key."""
# Drop old foreign key constraint
op.drop_constraint('telemetry_device_id_fkey', 'telemetry', type_='foreignkey')
# Add new foreign key constraint with CASCADE delete
op.create_foreign_key(
'telemetry_device_id_fkey',
'telemetry', 'devices',
['device_id'], ['id'],
ondelete='CASCADE'
)
def downgrade() -> None:
"""Downgrade schema - revert foreign key change."""
# Drop CASCADE foreign key
op.drop_constraint('telemetry_device_id_fkey', 'telemetry', type_='foreignkey')
# Add back original foreign key without CASCADE
op.create_foreign_key(
'telemetry_device_id_fkey',
'telemetry', 'devices',
['device_id'], ['id']
)

View File

@@ -109,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)
@@ -118,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})>"

13
frontend/.dockerignore Normal file
View File

@@ -0,0 +1,13 @@
node_modules
dist
.env
.env.local
.env.*.local
npm-debug.log*
.DS_Store
.vscode
.idea
*.swp
*.swo
*.log
coverage

22
frontend/Dockerfile Normal file
View File

@@ -0,0 +1,22 @@
FROM node:22-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
FROM nginx:alpine
COPY --from=builder /app/dist /usr/share/nginx/html
COPY nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]

View File

@@ -1,73 +1,111 @@
# React + TypeScript + Vite # IoT Dashboard Frontend
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules. React-based dashboard for visualizing IoT telemetry data with customizable widgets.
Currently, two official plugins are available: ## Technology Stack
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Babel](https://babeljs.io/) (or [oxc](https://oxc.rs) when used in [rolldown-vite](https://vite.dev/guide/rolldown)) for Fast Refresh | Technology | Purpose |
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh |------------|---------|
| React 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 |
## React Compiler ## Features
The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation). - Customizable widget-based dashboard
- Drag-and-drop layout editing
- Multiple widget types (weather, charts, calendar, AI briefings)
- Responsive design
- Dark/light theme support
## Expanding the ESLint configuration ## Widget Types
If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules: | 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 |
```js ## Project Structure
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
// Other configs...
// Remove tseslint.configs.recommended and replace with this ```
tseslint.configs.recommendedTypeChecked, frontend/
// Alternatively, use this for stricter rules ├── src/
tseslint.configs.strictTypeChecked, │ ├── api/ # API client functions
// Optionally, add this for stylistic rules │ ├── components/ # React components
tseslint.configs.stylisticTypeChecked, ├── widgets/ # Widget components
│ │ └── ...
// Other configs... ├── hooks/ # Custom React hooks
], │ ├── types/ # TypeScript type definitions
languageOptions: { │ ├── utils/ # Utility functions
parserOptions: { │ ├── App.tsx # Main application component
project: ['./tsconfig.node.json', './tsconfig.app.json'], └── main.tsx # Entry point
tsconfigRootDir: import.meta.dirname, ├── public/ # Static assets
}, ├── package.json # Dependencies
// other options... └── vite.config.ts # Vite configuration
},
},
])
``` ```
You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules: ## Running
```js ```bash
// eslint.config.js cd frontend
import reactX from 'eslint-plugin-react-x' npm install
import reactDom from 'eslint-plugin-react-dom' npm run dev
```
export default defineConfig([
globalIgnores(['dist']), Development server runs at http://localhost:5173
{
files: ['**/*.{ts,tsx}'], ## Configuration
extends: [
// Other configs... The frontend connects to the Django API. Configure the API URL in `vite.config.ts`:
// Enable lint rules for React
reactX.configs['recommended-typescript'], ```typescript
// Enable lint rules for React DOM proxy: {
reactDom.configs.recommended, '/api': {
], target: 'http://localhost:8000',
languageOptions: { changeOrigin: true,
parserOptions: { },
project: ['./tsconfig.node.json', './tsconfig.app.json'], }
tsconfigRootDir: import.meta.dirname, ```
},
// other options... ## 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),
});
``` ```

36
frontend/nginx.conf Normal file
View File

@@ -0,0 +1,36 @@
server {
listen 80;
server_name localhost;
root /usr/share/nginx/html;
index index.html;
# Gzip compression
gzip on;
gzip_vary on;
gzip_min_length 1024;
gzip_types text/plain text/css text/xml text/javascript application/x-javascript application/xml+rss application/javascript application/json;
# SPA routing - serve index.html for all routes
location / {
try_files $uri $uri/ /index.html;
}
# Proxy API requests to Django backend
location /api/ {
proxy_pass http://django:3000;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection 'upgrade';
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_cache_bypass $http_upgrade;
}
# Cache static assets
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {
expires 1y;
add_header Cache-Control "public, immutable";
}
}

View File

@@ -24,7 +24,8 @@
"react-hook-form": "^7.66.1", "react-hook-form": "^7.66.1",
"react-hot-toast": "^2.6.0", "react-hot-toast": "^2.6.0",
"react-router-dom": "^7.9.6", "react-router-dom": "^7.9.6",
"recharts": "^3.4.1" "recharts": "^3.4.1",
"tailwind-merge": "^3.4.0"
}, },
"devDependencies": { "devDependencies": {
"@eslint/js": "^9.36.0", "@eslint/js": "^9.36.0",
@@ -4957,6 +4958,16 @@
"node": ">=8" "node": ">=8"
} }
}, },
"node_modules/tailwind-merge": {
"version": "3.4.0",
"resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-3.4.0.tgz",
"integrity": "sha512-uSaO4gnW+b3Y2aWoWfFpX62vn2sR3skfhbjsEnaBI81WD1wBLlHZe5sWf0AqjksNdYTbGBEd0UasQMT3SNV15g==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/dcastil"
}
},
"node_modules/tailwindcss": { "node_modules/tailwindcss": {
"version": "4.1.17", "version": "4.1.17",
"resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.17.tgz", "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.1.17.tgz",

View File

@@ -26,7 +26,8 @@
"react-hook-form": "^7.66.1", "react-hook-form": "^7.66.1",
"react-hot-toast": "^2.6.0", "react-hot-toast": "^2.6.0",
"react-router-dom": "^7.9.6", "react-router-dom": "^7.9.6",
"recharts": "^3.4.1" "recharts": "^3.4.1",
"tailwind-merge": "^3.4.0"
}, },
"devDependencies": { "devDependencies": {
"@eslint/js": "^9.36.0", "@eslint/js": "^9.36.0",

View File

@@ -1,16 +1,25 @@
import { QueryClient, QueryClientProvider } from '@tanstack/react-query' import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { BrowserRouter, Routes, Route, Link, NavLink } from 'react-router-dom' import { BrowserRouter, Routes, Route, Link, NavLink, Navigate } from 'react-router-dom'
import { Toaster } from 'react-hot-toast' import { Toaster } from 'react-hot-toast'
import { WellnessStateProvider } from './hooks/useWellnessState' import { WellnessStateProvider } from './hooks/useWellnessState'
import { AuthProvider, useAuth } from './contexts/AuthContext'
import Dashboard from './pages/Dashboard' import Dashboard from './pages/Dashboard'
import DeviceList from './pages/DeviceList' import DeviceList from './pages/DeviceList'
import DeviceDetail from './pages/DeviceDetail' import DeviceDetail from './pages/DeviceDetail'
import AddDevice from './pages/AddDevice' import AddDevice from './pages/AddDevice'
import Login from './pages/Login'
import './App.css' import './App.css'
const queryClient = new QueryClient() 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 }) { function AppLayout({ children }: { children: React.ReactNode }) {
const { logout } = useAuth()
return ( return (
<div className="drawer lg:drawer-open"> <div className="drawer lg:drawer-open">
<input id="main-drawer" type="checkbox" className="drawer-toggle" /> <input id="main-drawer" type="checkbox" className="drawer-toggle" />
@@ -27,6 +36,11 @@ function AppLayout({ children }: { children: React.ReactNode }) {
<div className="flex-1"> <div className="flex-1">
<span className="text-xl font-bold">IoT Dashboard</span> <span className="text-xl font-bold">IoT Dashboard</span>
</div> </div>
<div className="flex-none">
<button onClick={logout} className="btn btn-ghost btn-sm">
Logout
</button>
</div>
</div> </div>
{/* Page content */} {/* Page content */}
@@ -38,7 +52,7 @@ function AppLayout({ children }: { children: React.ReactNode }) {
{/* Sidebar */} {/* Sidebar */}
<div className="drawer-side"> <div className="drawer-side">
<label htmlFor="main-drawer" className="drawer-overlay"></label> <label htmlFor="main-drawer" className="drawer-overlay"></label>
<aside className="bg-base-100 w-64 min-h-full"> <aside className="bg-base-100 w-64 min-h-full flex flex-col">
<div className="p-4"> <div className="p-4">
<Link to="/" className="flex items-center gap-2 text-2xl font-bold"> <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"> <svg xmlns="http://www.w3.org/2000/svg" className="h-8 w-8" fill="none" viewBox="0 0 24 24" stroke="currentColor">
@@ -73,6 +87,15 @@ function AppLayout({ children }: { children: React.ReactNode }) {
</NavLink> </NavLink>
</li> </li>
</ul> </ul>
<div className="mt-auto p-4">
<button onClick={logout} className="btn btn-ghost btn-sm w-full">
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1" />
</svg>
Logout
</button>
</div>
</aside> </aside>
</div> </div>
</div> </div>
@@ -82,17 +105,21 @@ function AppLayout({ children }: { children: React.ReactNode }) {
function App() { function App() {
return ( return (
<QueryClientProvider client={queryClient}> <QueryClientProvider client={queryClient}>
<WellnessStateProvider> <AuthProvider>
<BrowserRouter> <WellnessStateProvider>
<Toaster position="top-right" /> <BrowserRouter>
<Routes> <Toaster position="top-right" />
<Route path="/" element={<AppLayout><Dashboard /></AppLayout>} /> <Routes>
<Route path="/devices" element={<AppLayout><DeviceList /></AppLayout>} /> <Route path="/login" element={<Login />} />
<Route path="/devices/add" element={<AppLayout><AddDevice /></AppLayout>} /> <Route path="/" element={<ProtectedRoute><AppLayout><Dashboard /></AppLayout></ProtectedRoute>} />
<Route path="/devices/:id" element={<AppLayout><DeviceDetail /></AppLayout>} /> <Route path="/dashboard" element={<ProtectedRoute><AppLayout><Dashboard /></AppLayout></ProtectedRoute>} />
</Routes> <Route path="/devices" element={<ProtectedRoute><AppLayout><DeviceList /></AppLayout></ProtectedRoute>} />
</BrowserRouter> <Route path="/devices/add" element={<ProtectedRoute><AppLayout><AddDevice /></AppLayout></ProtectedRoute>} />
</WellnessStateProvider> <Route path="/devices/:id" element={<ProtectedRoute><AppLayout><DeviceDetail /></AppLayout></ProtectedRoute>} />
</Routes>
</BrowserRouter>
</WellnessStateProvider>
</AuthProvider>
</QueryClientProvider> </QueryClientProvider>
) )
} }

View File

@@ -113,7 +113,7 @@ export default function AddWidgetModal({ isOpen, onClose, onAdd }: AddWidgetModa
defaultTitle = `Run Suitability - ${city}` defaultTitle = `Run Suitability - ${city}`
} else if (widgetType === 'health-stats') { } else if (widgetType === 'health-stats') {
const selectedDevice = devices.find(d => d.id === selectedDevices[0]) const selectedDevice = devices.find(d => d.id === selectedDevices[0])
defaultTitle = `Health StaTts - ${selectedDevice?.name || 'Device'}` defaultTitle = `Health Stats - ${selectedDevice?.name || 'Device'}`
} else if (widgetType === 'calendar') { } else if (widgetType === 'calendar') {
defaultTitle = 'Calendar Agenda' defaultTitle = 'Calendar Agenda'
} else if (widgetType === 'daily-briefing') { } else if (widgetType === 'daily-briefing') {

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

View File

@@ -4,7 +4,7 @@ import 'gridstack/dist/gridstack.min.css'
// Define the widget type based on gridstack.js structure // Define the widget type based on gridstack.js structure
export type GridStackWidget = { export type GridStackWidget = {
id?: string | number id?: string
x?: number x?: number
y?: number y?: number
w?: number w?: number
@@ -74,7 +74,7 @@ export function useGridstack(options: UseGridstackOptions = {}) {
// Handle layout change // Handle layout change
if (onLayoutChange) { if (onLayoutChange) {
grid.on('change', (event, items) => { grid.on('change', () => {
const serialized = grid.save(false) as GridStackWidget[] const serialized = grid.save(false) as GridStackWidget[]
onLayoutChange(serialized) onLayoutChange(serialized)
}) })

View File

@@ -1,4 +1,4 @@
import { createContext, useContext, useState, ReactNode } from 'react' import { createContext, useContext, useState, type ReactNode } from 'react'
interface WellnessState { interface WellnessState {
healthDeviceId: string | null healthDeviceId: string | null

View File

@@ -10,11 +10,47 @@ export const apiClient = axios.create({
}, },
}); });
// Add response interceptor for error handling // 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( apiClient.interceptors.response.use(
(response) => response, (response) => response,
(error) => { async (error) => {
// Basic error handling - can be extended if needed 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); return Promise.reject(error);
} }
); );

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

View File

@@ -20,7 +20,6 @@ from iotDashboard import weather_client
from iotDashboard.comfort_index import ( from iotDashboard.comfort_index import (
ComfortMetrics, ComfortMetrics,
ComfortIndexCalculator, ComfortIndexCalculator,
calculate_comfort_index_from_telemetry,
) )
from iotDashboard.run_suitability import ( from iotDashboard.run_suitability import (
RunSuitabilityCalculator, RunSuitabilityCalculator,
@@ -89,11 +88,18 @@ class DeviceViewSet(viewsets.ModelViewSet):
) )
def destroy(self, request, pk=None): def destroy(self, request, pk=None):
"""Delete a device.""" """Delete a device via device_manager microservice."""
try: try:
device = self.get_object() device = self.get_object()
device_name = device.name device_name = device.name
device_id = device.id
# 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() device.delete()
return Response( return Response(
{'message': f"Device '{device_name}' deleted successfully"}, {'message': f"Device '{device_name}' deleted successfully"},
status=status.HTTP_204_NO_CONTENT status=status.HTTP_204_NO_CONTENT
@@ -103,6 +109,11 @@ class DeviceViewSet(viewsets.ModelViewSet):
{'error': 'Device not found'}, {'error': 'Device not found'},
status=status.HTTP_404_NOT_FOUND status=status.HTTP_404_NOT_FOUND
) )
except DeviceManagerAPIError as e:
return Response(
{'error': e.message, 'details': e.details},
status=e.status_code or status.HTTP_500_INTERNAL_SERVER_ERROR
)
@action(detail=True, methods=['post']) @action(detail=True, methods=['post'])
def revoke(self, request, pk=None): def revoke(self, request, pk=None):
@@ -298,7 +309,6 @@ class TelemetryViewSet(viewsets.ReadOnlyModelViewSet):
queryset = Telemetry.objects.all() queryset = Telemetry.objects.all()
serializer_class = TelemetrySerializer serializer_class = TelemetrySerializer
# permission_classes = [IsAuthenticated]
def get_queryset(self): def get_queryset(self):
"""Filter telemetry by query parameters.""" """Filter telemetry by query parameters."""
@@ -664,9 +674,9 @@ class WellnessViewSet(viewsets.ViewSet):
) )
try: try:
# Get health device # Validate health device exists
try: try:
health_device = Device.objects.get(id=health_device_id) Device.objects.get(id=health_device_id)
except Device.DoesNotExist: except Device.DoesNotExist:
return Response( return Response(
{'error': f'Health device {health_device_id} not found'}, {'error': f'Health device {health_device_id} not found'},
@@ -811,9 +821,9 @@ class WellnessViewSet(viewsets.ViewSet):
) )
try: try:
# Get health device # Validate health device exists
try: try:
health_device = Device.objects.get(id=health_device_id) Device.objects.get(id=health_device_id)
except Device.DoesNotExist: except Device.DoesNotExist:
return Response( return Response(
{'error': f'Health device {health_device_id} not found'}, {'error': f'Health device {health_device_id} not found'},
@@ -973,7 +983,7 @@ class WellnessViewSet(viewsets.ViewSet):
outdoor_data['pm25'] = aq.get('measurements', {}).get('pm25', {}).get('average') outdoor_data['pm25'] = aq.get('measurements', {}).get('pm25', {}).get('average')
outdoor_data['pm10'] = aq.get('measurements', {}).get('pm10', {}).get('average') outdoor_data['pm10'] = aq.get('measurements', {}).get('pm10', {}).get('average')
outdoor_data['air_quality_status'] = aq.get('status') outdoor_data['air_quality_status'] = aq.get('status')
except Exception as e: except Exception:
pass # Air quality optional pass # Air quality optional
# Gather health data if device specified # Gather health data if device specified
@@ -1013,7 +1023,7 @@ class WellnessViewSet(viewsets.ViewSet):
current_time, current_time,
calendar_range_hours calendar_range_hours
) )
except Exception as e: except Exception:
pass # Calendar optional pass # Calendar optional
# Call GPT service # Call GPT service
@@ -1095,7 +1105,7 @@ class WellnessViewSet(viewsets.ViewSet):
continue continue
if dt.replace(tzinfo=None) > end_time.replace(tzinfo=None): if dt.replace(tzinfo=None) > end_time.replace(tzinfo=None):
continue continue
except: except ValueError:
event['start'] = 'TBD' event['start'] = 'TBD'
# Extract location # Extract location

View 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

View File

@@ -151,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
@@ -213,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)

View 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

View File

@@ -175,3 +175,21 @@ class Telemetry(models.Model):
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

View File

@@ -50,6 +50,7 @@ INSTALLED_APPS = [
"django.contrib.messages", "django.contrib.messages",
"django.contrib.staticfiles", "django.contrib.staticfiles",
"rest_framework", "rest_framework",
"rest_framework_simplejwt",
"corsheaders", "corsheaders",
"iotDashboard", "iotDashboard",
] ]
@@ -153,13 +154,23 @@ REST_FRAMEWORK = {
'rest_framework.parsers.JSONParser', 'rest_framework.parsers.JSONParser',
], ],
'DEFAULT_AUTHENTICATION_CLASSES': [ 'DEFAULT_AUTHENTICATION_CLASSES': [
'rest_framework_simplejwt.authentication.JWTAuthentication',
'rest_framework.authentication.SessionAuthentication', # For admin only 'rest_framework.authentication.SessionAuthentication', # For admin only
], ],
'DEFAULT_PERMISSION_CLASSES': [ 'DEFAULT_PERMISSION_CLASSES': [
'rest_framework.permissions.AllowAny', # Single-user system, no auth required '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 Settings (for React frontend)
CORS_ALLOWED_ORIGINS = [ CORS_ALLOWED_ORIGINS = [
"http://localhost:5173", "http://localhost:5173",

View File

@@ -18,10 +18,15 @@ Including another URLconf
from django.contrib import admin from django.contrib import admin
from django.urls import path, include 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),
# JWT Authentication
path("api/auth/login/", TokenObtainPairView.as_view(), name="token_obtain_pair"),
path("api/auth/refresh/", TokenRefreshView.as_view(), name="token_refresh"),
# REST API # REST API
path("api/", include("iotDashboard.api.urls")), path("api/", include("iotDashboard.api.urls")),

View File

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

View File

@@ -6,4 +6,5 @@ __pycache__/
*.pyd *.pyd
*.crl *.crl
*.crt *.crt
*.pem *.pem
*.sh

View File

@@ -0,0 +1,34 @@
#!/bin/bash
set -e
CERT_DIR="certs"
SERVER_IP="${1:-localhost}"
mkdir -p "$CERT_DIR"
openssl genrsa -out "$CERT_DIR/ca.key" 4096
openssl req -new -x509 -days 3650 -key "$CERT_DIR/ca.key" -out "$CERT_DIR/ca.crt" \
-subj "/C=MK/ST=State/L=City/O=IoTDashboard/OU=DeviceManager/CN=IoT Device CA"
openssl genrsa -out "$CERT_DIR/server.key" 4096
openssl req -new -key "$CERT_DIR/server.key" -out "$CERT_DIR/server.csr" \
-subj "/C=MK/ST=State/L=City/O=IoTDashboard/OU=MQTT/CN=$SERVER_IP"
cat > "$CERT_DIR/server.ext" << EOF
subjectAltName = @alt_names
[alt_names]
IP.1 = $SERVER_IP
DNS.1 = localhost
EOF
openssl x509 -req -in "$CERT_DIR/server.csr" -CA "$CERT_DIR/ca.crt" -CAkey "$CERT_DIR/ca.key" \
-CAcreateserial -out "$CERT_DIR/server.crt" -days 365 -sha256 -extfile "$CERT_DIR/server.ext"
rm "$CERT_DIR/server.csr" "$CERT_DIR/server.ext" "$CERT_DIR/ca.srl"
chmod 600 "$CERT_DIR/ca.key" "$CERT_DIR/server.key"
chmod 644 "$CERT_DIR/ca.crt" "$CERT_DIR/server.crt"
echo "Certificates created:"
echo " CA: $CERT_DIR/ca.crt"
echo " Server: $CERT_DIR/server.crt (valid for $SERVER_IP)"

View File

@@ -1,34 +1,39 @@
#!/bin/bash #!/bin/bash
# Script to generate a Certificate Authority (CA) for IoT devices set -e
CERT_DIR="certs"
CA_KEY="$CERT_DIR/ca.key" CERT_DIR="certs"
CA_CERT="$CERT_DIR/ca.crt" DOMAINS="${@:-localhost}"
# Create certs directory
mkdir -p "$CERT_DIR" mkdir -p "$CERT_DIR"
echo "Generating CA Certificate Authority..." echo "Generating CA..."
openssl genrsa -out "$CERT_DIR/ca.key" 4096
openssl req -new -x509 -days 3650 -key "$CERT_DIR/ca.key" -out "$CERT_DIR/ca.crt" \
-subj "/C=MK/ST=State/L=City/O=IoTDashboard/OU=DeviceManager/CN=IoT Device CA"
# Generate CA private key (4096-bit RSA) echo "Generating server certificate..."
openssl genrsa -out "$CA_KEY" 4096 openssl genrsa -out "$CERT_DIR/server.key" 4096
echo "Generated CA private key: $CA_KEY" openssl req -new -key "$CERT_DIR/server.key" -out "$CERT_DIR/server.csr" \
-subj "/C=MK/ST=State/L=City/O=IoTDashboard/OU=MQTT/CN=${1:-localhost}"
# Generate CA certificate (valid for 10 years) echo "subjectAltName = @alt_names" > "$CERT_DIR/server.ext"
openssl req -new -x509 -days 3650 -key "$CA_KEY" -out "$CA_CERT" \ echo "[alt_names]" >> "$CERT_DIR/server.ext"
-subj "/C=US/ST=State/L=City/O=IoTDashboard/OU=DeviceManager/CN=IoT Device CA"
echo "Generated CA certificate: $CA_CERT"
# Set secure permissions INDEX=1
chmod 600 "$CA_KEY" for DOMAIN in $DOMAINS; do
chmod 644 "$CA_CERT" if [[ $DOMAIN =~ ^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+$ ]]; then
echo "IP.$INDEX = $DOMAIN" >> "$CERT_DIR/server.ext"
else
echo "DNS.$INDEX = $DOMAIN" >> "$CERT_DIR/server.ext"
fi
INDEX=$((INDEX + 1))
done
echo "" openssl x509 -req -in "$CERT_DIR/server.csr" -CA "$CERT_DIR/ca.crt" -CAkey "$CERT_DIR/ca.key" \
echo "CA Certificate Authority created successfully!" -CAcreateserial -out "$CERT_DIR/server.crt" -days 365 -sha256 -extfile "$CERT_DIR/server.ext"
echo ""
echo "CA Certificate Details:" rm "$CERT_DIR/server.csr" "$CERT_DIR/server.ext" "$CERT_DIR/ca.srl"
openssl x509 -in "$CA_CERT" -noout -text | grep -A 2 "Subject:" chmod 600 "$CERT_DIR/ca.key" "$CERT_DIR/server.key"
echo "" chmod 644 "$CERT_DIR/ca.crt" "$CERT_DIR/server.crt"
echo "Valid from:"
openssl x509 -in "$CA_CERT" -noout -startdate echo "Done! Server cert valid for: $DOMAINS"
echo "Valid until:"
openssl x509 -in "$CA_CERT" -noout -enddate

View File

@@ -446,7 +446,6 @@ Keep all text concise: summary under 50 words, each item under 20 words.""",
context = "\n\n".join(context_sections) context = "\n\n".join(context_sections)
# Build briefing-specific prompts
prompts = { prompts = {
"schedule": f"""You are a smart wellness coach for office workers (software engineers, tech/finance professionals). "schedule": f"""You are a smart wellness coach for office workers (software engineers, tech/finance professionals).
Generate a Schedule Briefing focused on calendar and activity optimization. Generate a Schedule Briefing focused on calendar and activity optimization.

View File

@@ -15,12 +15,10 @@ gpt_service = None
async def lifespan(app: FastAPI): async def lifespan(app: FastAPI):
"""Lifespan event handler for startup and shutdown.""" """Lifespan event handler for startup and shutdown."""
global gpt_service global gpt_service
# Startup
logger.info("Initializing GPT Service...") logger.info("Initializing GPT Service...")
gpt_service = GPTService() gpt_service = GPTService()
logger.info("GPT Service initialized successfully") logger.info("GPT Service initialized successfully")
yield yield
# Shutdown (cleanup if needed)
logger.info("Shutting down GPT Service...") logger.info("Shutting down GPT Service...")
@@ -96,10 +94,8 @@ async def analyze_telemetry(request: AnalyzeRequest):
logger.info(f"Analyzing {len(request.telemetry_data)} telemetry points with prompt_type={request.prompt_type}") logger.info(f"Analyzing {len(request.telemetry_data)} telemetry points with prompt_type={request.prompt_type}")
# Convert Pydantic models to dicts for GPTService
telemetry_dicts = [point.model_dump() for point in request.telemetry_data] telemetry_dicts = [point.model_dump() for point in request.telemetry_data]
# Call GPT service analysis
analysis_result = await gpt_service.analyze( analysis_result = await gpt_service.analyze(
telemetry_data=telemetry_dicts, telemetry_data=telemetry_dicts,
device_info=request.device_info, device_info=request.device_info,
@@ -133,7 +129,6 @@ async def generate_daily_briefing(request: DailyBriefingRequest):
logger.info(f"Generating {request.briefing_type} briefing") logger.info(f"Generating {request.briefing_type} briefing")
# Convert calendar events to dicts
calendar_events = None calendar_events = None
if request.calendar_events: if request.calendar_events:
calendar_events = [event.model_dump() for event in request.calendar_events] calendar_events = [event.model_dump() for event in request.calendar_events]

View File

@@ -1,3 +1,98 @@
# MQTT Ingestion service # MQTT Ingestion Service
This service ingests all the data from the MQTT topics and sends the data to Redis Subscribes to MQTT topics and writes telemetry data to Redis streams for downstream processing.
## Purpose
This service acts as the bridge between MQTT devices and the data pipeline. It:
- Connects to Mosquitto MQTT broker
- Subscribes to device topics using wildcard pattern
- Parses incoming messages
- Writes structured data to Redis stream
## Architecture
```
MQTT Broker (port 8883)
|
v
+-------------------+
| mqtt_ingestion |
| - MQTT subscriber |
| - Topic parser |
| - Redis writer |
+-------------------+
|
v
Redis Stream: mqtt:ingestion
```
## Topic Format
Devices publish to: `devices/{device_id}/{metric}`
Examples:
- `devices/a1b2c3d4/temperature` - Temperature reading
- `devices/a1b2c3d4/humidity` - Humidity reading
- `devices/a1b2c3d4/heart_rate` - Health metric
The service subscribes to `devices/#` to receive all device messages.
## Redis Stream Format
Each message written to `mqtt:ingestion` contains:
| Field | Type | Description |
|-------|------|-------------|
| device_id | string | 8-character device identifier |
| metric | string | Metric name (temperature, humidity, etc.) |
| value | string | Metric value (stored as string) |
| timestamp | string | ISO 8601 timestamp |
Example:
```json
{
"device_id": "a1b2c3d4",
"metric": "temperature",
"value": "23.5",
"timestamp": "2025-01-15T10:30:00.000Z"
}
```
## Configuration
Environment variables (`.env` file):
| Variable | Description | Default |
|----------|-------------|---------|
| MQTT_HOST | MQTT broker hostname | localhost |
| MQTT_PORT | MQTT broker port | 8883 |
| REDIS_HOST | Redis hostname | localhost |
| REDIS_PORT | Redis port | 6379 |
## Running
```bash
cd services/mqtt_ingestion
uv sync
uv run main.py
```
## Key Files
| File | Purpose |
|------|---------|
| `main.py` | Entry point, service initialization |
| `src/mqtt_client.py` | MQTT connection and subscription logic |
| `src/redis_writer.py` | Redis stream writing |
## Error Handling
- Invalid topics (not matching `devices/{id}/{metric}`) are logged and dropped
- Connection failures trigger automatic reconnection
- Redis write failures are logged (messages may be lost)
## Integration Points
- **Upstream**: Mosquitto MQTT broker with mTLS
- **Downstream**: Redis stream consumed by db_write service