mirror of
https://github.com/ferdzo/iotDashboard.git
synced 2026-04-05 17:16:26 +00:00
Compare commits
7 Commits
develop
...
3ab81fad8c
| Author | SHA1 | Date | |
|---|---|---|---|
| 3ab81fad8c | |||
| ab72c01999 | |||
| 8c699bd121 | |||
| 7f2aec96dd | |||
| ed105fccd3 | |||
| 7921049f56 | |||
| 8e98f5ad7d |
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
|
||||
- This repo is a microservices-based IoT platform for device management, data ingestion, and telemetry storage. The system uses MQTT with mTLS authentication, Redis streams for message queuing, and PostgreSQL/TimescaleDB for persistent storage.
|
||||
|
||||
Never forget to involve human developers for more complex tasks and decisions. You are encouraged to ask more.
|
||||
|
||||
Big Picture
|
||||
- Architecture: Device → MQTT (mTLS) → mqtt_ingestion → Redis → db_write → PostgreSQL/TimescaleDB
|
||||
- Components:
|
||||
|
||||
@@ -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})>"
|
||||
|
||||
|
||||
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.
|
||||
"""
|
||||
|
||||
8
frontend/.env.example
Normal file
8
frontend/.env.example
Normal file
@@ -0,0 +1,8 @@
|
||||
# API Configuration
|
||||
VITE_API_URL=/api
|
||||
# Django proxy URL (used for QR code API endpoint)
|
||||
VITE_DEVICE_MANAGER_URL=http://10.10.1.114:3000/api
|
||||
|
||||
# MQTT Configuration
|
||||
VITE_MQTT_BROKER=10.10.1.114
|
||||
VITE_MQTT_PORT=8883
|
||||
25
frontend/.gitignore
vendored
Normal file
25
frontend/.gitignore
vendored
Normal file
@@ -0,0 +1,25 @@
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
.env
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
.DS_Store
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
73
frontend/README.md
Normal file
73
frontend/README.md
Normal file
@@ -0,0 +1,73 @@
|
||||
# React + TypeScript + Vite
|
||||
|
||||
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
|
||||
|
||||
Currently, two official plugins are available:
|
||||
|
||||
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Babel](https://babeljs.io/) (or [oxc](https://oxc.rs) when used in [rolldown-vite](https://vite.dev/guide/rolldown)) for Fast Refresh
|
||||
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh
|
||||
|
||||
## React Compiler
|
||||
|
||||
The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).
|
||||
|
||||
## Expanding the ESLint configuration
|
||||
|
||||
If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules:
|
||||
|
||||
```js
|
||||
export default defineConfig([
|
||||
globalIgnores(['dist']),
|
||||
{
|
||||
files: ['**/*.{ts,tsx}'],
|
||||
extends: [
|
||||
// Other configs...
|
||||
|
||||
// Remove tseslint.configs.recommended and replace with this
|
||||
tseslint.configs.recommendedTypeChecked,
|
||||
// Alternatively, use this for stricter rules
|
||||
tseslint.configs.strictTypeChecked,
|
||||
// Optionally, add this for stylistic rules
|
||||
tseslint.configs.stylisticTypeChecked,
|
||||
|
||||
// Other configs...
|
||||
],
|
||||
languageOptions: {
|
||||
parserOptions: {
|
||||
project: ['./tsconfig.node.json', './tsconfig.app.json'],
|
||||
tsconfigRootDir: import.meta.dirname,
|
||||
},
|
||||
// other options...
|
||||
},
|
||||
},
|
||||
])
|
||||
```
|
||||
|
||||
You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules:
|
||||
|
||||
```js
|
||||
// eslint.config.js
|
||||
import reactX from 'eslint-plugin-react-x'
|
||||
import reactDom from 'eslint-plugin-react-dom'
|
||||
|
||||
export default defineConfig([
|
||||
globalIgnores(['dist']),
|
||||
{
|
||||
files: ['**/*.{ts,tsx}'],
|
||||
extends: [
|
||||
// Other configs...
|
||||
// Enable lint rules for React
|
||||
reactX.configs['recommended-typescript'],
|
||||
// Enable lint rules for React DOM
|
||||
reactDom.configs.recommended,
|
||||
],
|
||||
languageOptions: {
|
||||
parserOptions: {
|
||||
project: ['./tsconfig.node.json', './tsconfig.app.json'],
|
||||
tsconfigRootDir: import.meta.dirname,
|
||||
},
|
||||
// other options...
|
||||
},
|
||||
},
|
||||
])
|
||||
```
|
||||
23
frontend/eslint.config.js
Normal file
23
frontend/eslint.config.js
Normal file
@@ -0,0 +1,23 @@
|
||||
import js from '@eslint/js'
|
||||
import globals from 'globals'
|
||||
import reactHooks from 'eslint-plugin-react-hooks'
|
||||
import reactRefresh from 'eslint-plugin-react-refresh'
|
||||
import tseslint from 'typescript-eslint'
|
||||
import { defineConfig, globalIgnores } from 'eslint/config'
|
||||
|
||||
export default defineConfig([
|
||||
globalIgnores(['dist']),
|
||||
{
|
||||
files: ['**/*.{ts,tsx}'],
|
||||
extends: [
|
||||
js.configs.recommended,
|
||||
tseslint.configs.recommended,
|
||||
reactHooks.configs['recommended-latest'],
|
||||
reactRefresh.configs.vite,
|
||||
],
|
||||
languageOptions: {
|
||||
ecmaVersion: 2020,
|
||||
globals: globals.browser,
|
||||
},
|
||||
},
|
||||
])
|
||||
13
frontend/index.html
Normal file
13
frontend/index.html
Normal file
@@ -0,0 +1,13 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<!-- <link rel="icon" type="image/svg+xml" href="/vite.svg" /> -->
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Lyncis</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
5385
frontend/package-lock.json
generated
Normal file
5385
frontend/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
45
frontend/package.json
Normal file
45
frontend/package.json
Normal file
@@ -0,0 +1,45 @@
|
||||
{
|
||||
"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"
|
||||
},
|
||||
"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));
|
||||
}
|
||||
100
frontend/src/App.tsx
Normal file
100
frontend/src/App.tsx
Normal file
@@ -0,0 +1,100 @@
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import { BrowserRouter, Routes, Route, Link, NavLink } from 'react-router-dom'
|
||||
import { Toaster } from 'react-hot-toast'
|
||||
import { WellnessStateProvider } from './hooks/useWellnessState'
|
||||
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>Lyncis</span>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<ul className="menu p-4 space-y-2">
|
||||
<li>
|
||||
<NavLink
|
||||
to="/"
|
||||
className={({ isActive }) => isActive ? 'active' : ''}
|
||||
end
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 12l2-2m0 0l7-7 7 7M5 10v10a1 1 0 001 1h3m10-11l2 2m-2-2v10a1 1 0 01-1 1h-3m-6 0a1 1 0 001-1v-4a1 1 0 011-1h2a1 1 0 011 1v4a1 1 0 001 1m-6 0h6" />
|
||||
</svg>
|
||||
Dashboard
|
||||
</NavLink>
|
||||
</li>
|
||||
<li>
|
||||
<NavLink
|
||||
to="/devices"
|
||||
className={({ isActive }) => isActive ? 'active' : ''}
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 3v2m6-2v2M9 19v2m6-2v2M5 9H3m2 6H3m18-6h-2m2 6h-2M7 19h10a2 2 0 002-2V7a2 2 0 00-2-2H7a2 2 0 00-2 2v10a2 2 0 002 2zM9 9h6v6H9V9z" />
|
||||
</svg>
|
||||
Devices
|
||||
</NavLink>
|
||||
</li>
|
||||
</ul>
|
||||
</aside>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function App() {
|
||||
return (
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<WellnessStateProvider>
|
||||
<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>
|
||||
</WellnessStateProvider>
|
||||
</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
|
||||
295
frontend/src/api/index.ts
Normal file
295
frontend/src/api/index.ts
Normal file
@@ -0,0 +1,295 @@
|
||||
import apiClient from '../lib/api-client';
|
||||
import type {
|
||||
Device,
|
||||
DeviceRegistrationRequest,
|
||||
DeviceRegistrationResponse,
|
||||
Telemetry,
|
||||
DashboardOverview,
|
||||
} from '../types/api';
|
||||
|
||||
// Paginated response type from Django REST Framework
|
||||
interface PaginatedResponse<T> {
|
||||
count: number;
|
||||
next: string | null;
|
||||
previous: string | null;
|
||||
results: T[];
|
||||
}
|
||||
|
||||
// Device API
|
||||
export const devicesApi = {
|
||||
getAll: async () => {
|
||||
const response = await apiClient.get<Device[] | PaginatedResponse<Device>>('/devices/');
|
||||
// Handle both paginated and non-paginated responses
|
||||
if (Array.isArray(response.data)) {
|
||||
// Non-paginated response - wrap it
|
||||
return {
|
||||
...response,
|
||||
data: {
|
||||
count: response.data.length,
|
||||
next: null,
|
||||
previous: null,
|
||||
results: response.data,
|
||||
},
|
||||
};
|
||||
}
|
||||
// Already paginated
|
||||
return response as typeof response & { data: PaginatedResponse<Device> };
|
||||
},
|
||||
|
||||
getOne: (id: string) => apiClient.get<Device>(`/devices/${id}/`),
|
||||
|
||||
create: (data: DeviceRegistrationRequest) =>
|
||||
apiClient.post<DeviceRegistrationResponse>('/devices/', data),
|
||||
|
||||
delete: (id: string) => apiClient.delete(`/devices/${id}/`),
|
||||
|
||||
revoke: (id: string) => apiClient.post(`/devices/${id}/revoke/`),
|
||||
|
||||
renew: (id: string) =>
|
||||
apiClient.post<DeviceRegistrationResponse>(`/devices/${id}/renew/`),
|
||||
|
||||
getTelemetry: (id: string, params?: {
|
||||
metric?: string;
|
||||
hours?: number;
|
||||
limit?: number;
|
||||
}) => apiClient.get<Telemetry[]>(`/devices/${id}/telemetry/`, { params }),
|
||||
|
||||
getMetrics: (id: string) =>
|
||||
apiClient.get<{ device_id: string; device_name: string; metrics: string[] }>(
|
||||
`/devices/${id}/metrics/`
|
||||
),
|
||||
|
||||
getComfortIndex: (id: string) =>
|
||||
apiClient.get<{
|
||||
device_id: string;
|
||||
device_name: string;
|
||||
overall_score: number;
|
||||
rating: string;
|
||||
components: {
|
||||
temperature: number;
|
||||
humidity: number;
|
||||
air_quality: number;
|
||||
acoustic: number;
|
||||
light: number;
|
||||
};
|
||||
suggestions: string[];
|
||||
raw_readings: Record<string, number>;
|
||||
}>(`/devices/${id}/comfort_index/`),
|
||||
};
|
||||
|
||||
// Telemetry API
|
||||
export const telemetryApi = {
|
||||
query: (params?: {
|
||||
device_id?: string;
|
||||
metric?: string;
|
||||
hours?: number;
|
||||
start_time?: string;
|
||||
end_time?: string;
|
||||
page_size?: number;
|
||||
page?: number;
|
||||
}) => apiClient.get<PaginatedResponse<Telemetry>>('/telemetry/', { params }),
|
||||
|
||||
getLatest: (params?: { limit?: number }) =>
|
||||
apiClient.get<PaginatedResponse<Telemetry>>('/telemetry/latest/', { params }),
|
||||
|
||||
getMetrics: () => apiClient.get<{ metrics: string[] }>('/telemetry/metrics/'),
|
||||
|
||||
analyze: (data: {
|
||||
device_id: string;
|
||||
metric?: string;
|
||||
hours?: number;
|
||||
limit?: number;
|
||||
prompt_type?: 'anomaly_detection' | 'trend_summary' | 'custom';
|
||||
custom_prompt?: string;
|
||||
}) => apiClient.post<{
|
||||
analysis: string;
|
||||
prompt_type: string;
|
||||
data_points_analyzed: number;
|
||||
}>('/telemetry/analyze/', data),
|
||||
};
|
||||
|
||||
// Dashboard API
|
||||
export const dashboardApi = {
|
||||
getOverview: () => apiClient.get<DashboardOverview>('/dashboard/overview/'),
|
||||
};
|
||||
|
||||
// Weather API
|
||||
export const weatherApi = {
|
||||
getCurrent: (params: { city?: string; lat?: number; lon?: number }) =>
|
||||
apiClient.get<{
|
||||
location: string;
|
||||
temperature: number;
|
||||
apparent_temperature: number;
|
||||
humidity: number;
|
||||
weather_description: string;
|
||||
weather_code: number;
|
||||
precipitation: number;
|
||||
rain: number;
|
||||
cloud_cover: number;
|
||||
wind_speed: number;
|
||||
wind_direction: number;
|
||||
time: string;
|
||||
timezone: string;
|
||||
}>('/weather/current/', { params }),
|
||||
|
||||
getAirQuality: (city: string) =>
|
||||
apiClient.get<{
|
||||
city: string;
|
||||
measurements: Record<string, {
|
||||
average: number;
|
||||
min: number;
|
||||
max: number;
|
||||
count: number;
|
||||
}>;
|
||||
status: string;
|
||||
timestamp: string;
|
||||
sensor_count: number;
|
||||
}>('/weather/air_quality/', { params: { city } }),
|
||||
};
|
||||
|
||||
// Dashboard Layout API
|
||||
export const dashboardLayoutApi = {
|
||||
getAll: () =>
|
||||
apiClient.get<Array<{
|
||||
id: number
|
||||
name: string
|
||||
config: any
|
||||
is_default: boolean
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}>>('/dashboard-layouts/'),
|
||||
|
||||
getDefault: () =>
|
||||
apiClient.get<{
|
||||
id: number
|
||||
name: string
|
||||
config: any
|
||||
is_default: boolean
|
||||
created_at: string
|
||||
updated_at: string
|
||||
}>('/dashboard-layouts/default/'),
|
||||
|
||||
create: (data: {
|
||||
name: string
|
||||
config: any
|
||||
is_default?: boolean
|
||||
}) =>
|
||||
apiClient.post('/dashboard-layouts/', data),
|
||||
|
||||
update: (id: number, data: {
|
||||
name?: string
|
||||
config?: any
|
||||
is_default?: boolean
|
||||
}) =>
|
||||
apiClient.put(`/dashboard-layouts/${id}/`, data),
|
||||
|
||||
delete: (id: number) =>
|
||||
apiClient.delete(`/dashboard-layouts/${id}/`),
|
||||
|
||||
setDefault: (id: number) =>
|
||||
apiClient.post(`/dashboard-layouts/${id}/set_default/`),
|
||||
}
|
||||
|
||||
// Wellness API
|
||||
export const wellnessApi = {
|
||||
getRunSuitability: (healthDeviceId: string, city: string, timeOfDay?: string) =>
|
||||
apiClient.get<{
|
||||
status: 'GO' | 'MODERATE' | 'NO';
|
||||
overall_score: number;
|
||||
scores: {
|
||||
weather: number;
|
||||
air_quality: number;
|
||||
health: number;
|
||||
};
|
||||
primary_reason: string;
|
||||
detailed_insights: string[];
|
||||
time_recommendations: string[];
|
||||
suggestions: string[];
|
||||
weather_data: {
|
||||
temperature: number;
|
||||
wind_speed: number;
|
||||
precipitation: number;
|
||||
description: string;
|
||||
};
|
||||
air_quality_data: {
|
||||
pm25: number | null;
|
||||
pm10: number | null;
|
||||
status: string;
|
||||
};
|
||||
health_data: {
|
||||
steps: number;
|
||||
active_calories: number;
|
||||
heart_rate: number | null;
|
||||
};
|
||||
}>('/wellness/run_suitability/', {
|
||||
params: {
|
||||
health_device_id: healthDeviceId,
|
||||
city,
|
||||
...(timeOfDay && { time_of_day: timeOfDay }),
|
||||
},
|
||||
}),
|
||||
|
||||
getHealthInsights: (healthDeviceId: string, city?: string) =>
|
||||
apiClient.get<{
|
||||
health_metrics: {
|
||||
steps: number;
|
||||
active_calories: number;
|
||||
heart_rate: number | null;
|
||||
resting_heart_rate: number | null;
|
||||
};
|
||||
environmental_context: {
|
||||
temperature: number | null;
|
||||
humidity: number | null;
|
||||
pm25: number | null;
|
||||
pm10: number | null;
|
||||
air_quality_status: string | null;
|
||||
weather_description: string | null;
|
||||
};
|
||||
insights: Array<{
|
||||
metric: string;
|
||||
value: number;
|
||||
context: string;
|
||||
correlation: string | null;
|
||||
recommendation: string | null;
|
||||
}>;
|
||||
correlations: string[];
|
||||
recommendations: string[];
|
||||
trend_indicators: string[];
|
||||
}>('/wellness/health_insights/', {
|
||||
params: {
|
||||
health_device_id: healthDeviceId,
|
||||
...(city && { city }),
|
||||
},
|
||||
}),
|
||||
|
||||
getDailyBriefing: (params: {
|
||||
briefing_type: 'schedule' | 'environment' | 'full';
|
||||
city: string;
|
||||
health_device_id?: string;
|
||||
calendar_url?: string;
|
||||
calendar_range_hours?: number;
|
||||
}) =>
|
||||
apiClient.post<{
|
||||
status_emoji: string;
|
||||
status_line: string;
|
||||
insights: string[];
|
||||
recommendations: string[];
|
||||
briefing_type: string;
|
||||
generated_at: string;
|
||||
context: {
|
||||
indoor: Record<string, number> | null;
|
||||
outdoor: Record<string, number | string | null> | null;
|
||||
health: Record<string, number> | null;
|
||||
calendar_event_count: number;
|
||||
};
|
||||
}>('/wellness/daily_briefing/', params),
|
||||
};
|
||||
|
||||
// Calendar API
|
||||
export const calendarApi = {
|
||||
fetch: (calendarUrl: string, options?: { signal?: AbortSignal }) =>
|
||||
apiClient.get<{ calendar_data: string }>('/calendar/fetch/', {
|
||||
params: { calendar_url: calendarUrl },
|
||||
signal: options?.signal,
|
||||
}),
|
||||
};
|
||||
1
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 StaTts - ${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>
|
||||
)
|
||||
}
|
||||
192
frontend/src/components/CredentialsViewer.tsx
Normal file
192
frontend/src/components/CredentialsViewer.tsx
Normal file
@@ -0,0 +1,192 @@
|
||||
import { useState } from 'react'
|
||||
import { QRCodeSVG } from 'qrcode.react'
|
||||
import toast from 'react-hot-toast'
|
||||
import type { DeviceRegistrationResponse } from '../types/api'
|
||||
|
||||
interface CredentialsViewerProps {
|
||||
credentials: DeviceRegistrationResponse
|
||||
deviceId?: string
|
||||
}
|
||||
|
||||
const downloadFile = (content: string, filename: string) => {
|
||||
const blob = new Blob([content], { type: 'text/plain' })
|
||||
const url = URL.createObjectURL(blob)
|
||||
const link = document.createElement('a')
|
||||
link.href = url
|
||||
link.download = filename
|
||||
document.body.appendChild(link)
|
||||
link.click()
|
||||
document.body.removeChild(link)
|
||||
URL.revokeObjectURL(url)
|
||||
toast.success(`${filename} downloaded`)
|
||||
}
|
||||
|
||||
const copyToClipboard = (content: string, label: string) => {
|
||||
navigator.clipboard.writeText(content)
|
||||
toast.success(`${label} copied to clipboard`)
|
||||
}
|
||||
|
||||
export default function CredentialsViewer({ credentials, deviceId }: CredentialsViewerProps) {
|
||||
const resolvedDeviceId = credentials.device_id || deviceId || 'device'
|
||||
const expiresAt = credentials.expires_at ? new Date(credentials.expires_at).toLocaleString() : null
|
||||
const [showQR, setShowQR] = useState(false)
|
||||
|
||||
// Read configuration from environment variables
|
||||
const deviceManagerUrl = import.meta.env.VITE_DEVICE_MANAGER_URL || 'http://localhost:8000'
|
||||
const mqttBroker = import.meta.env.VITE_MQTT_BROKER || 'localhost'
|
||||
const mqttPort = import.meta.env.VITE_MQTT_PORT || '8883'
|
||||
|
||||
const qrData = credentials.onboarding_token ? JSON.stringify({
|
||||
type: 'iot_device_onboarding',
|
||||
device_id: resolvedDeviceId,
|
||||
token: credentials.onboarding_token,
|
||||
api_url: deviceManagerUrl,
|
||||
broker: mqttBroker,
|
||||
port: parseInt(mqttPort, 10),
|
||||
}) : null
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Secure QR Code for Mobile Onboarding */}
|
||||
{qrData && (
|
||||
<div className="rounded-lg bg-success/10 border border-success/30 p-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-6 w-6 text-success shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z" />
|
||||
</svg>
|
||||
<div className="flex-1">
|
||||
<h3 className="font-semibold mb-1">Secure Mobile Onboarding</h3>
|
||||
<p className="text-sm opacity-80 mb-2">
|
||||
Scan this QR code with your mobile app to securely fetch certificates. Token expires in <strong>15 minutes</strong> and can only be used <strong>once</strong>.
|
||||
</p>
|
||||
<div className="alert alert-warning alert-sm mb-3">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="stroke-current shrink-0 h-5 w-5" fill="none" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
|
||||
</svg>
|
||||
<span className="text-xs">This QR code will not be shown again. Scan it now!</span>
|
||||
</div>
|
||||
<button
|
||||
className="btn btn-sm btn-success"
|
||||
onClick={() => setShowQR(!showQR)}
|
||||
>
|
||||
{showQR ? 'Hide QR Code' : 'Show QR Code'}
|
||||
</button>
|
||||
{showQR && (
|
||||
<div className="mt-4 flex justify-center p-6 bg-white rounded-lg border-2 border-success">
|
||||
<QRCodeSVG
|
||||
value={qrData}
|
||||
size={280}
|
||||
level="H"
|
||||
includeMargin={true}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(credentials.certificate_id || expiresAt) && (
|
||||
<div className="rounded-lg bg-base-200 p-4 text-sm">
|
||||
<div className="flex flex-col gap-2">
|
||||
{credentials.certificate_id && (
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="font-semibold">Certificate ID</span>
|
||||
<code className="bg-base-100 px-2 py-1 rounded">
|
||||
{credentials.certificate_id}
|
||||
</code>
|
||||
</div>
|
||||
)}
|
||||
{expiresAt && (
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="font-semibold">Expires At</span>
|
||||
<span>{expiresAt}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{credentials.ca_certificate_pem && (
|
||||
<div>
|
||||
<label className="label">
|
||||
<span className="label-text font-semibold">CA Certificate</span>
|
||||
</label>
|
||||
<textarea
|
||||
className="textarea textarea-bordered w-full font-mono text-xs h-32"
|
||||
value={credentials.ca_certificate_pem}
|
||||
readOnly
|
||||
/>
|
||||
<div className="flex gap-2 mt-2">
|
||||
<button
|
||||
className="btn btn-sm btn-outline"
|
||||
onClick={() => copyToClipboard(credentials.ca_certificate_pem!, 'CA certificate')}
|
||||
>
|
||||
Copy
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-sm btn-outline"
|
||||
onClick={() => downloadFile(credentials.ca_certificate_pem!, 'ca.crt')}
|
||||
>
|
||||
Download
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{credentials.certificate_pem && (
|
||||
<div>
|
||||
<label className="label">
|
||||
<span className="label-text font-semibold">Device Certificate</span>
|
||||
</label>
|
||||
<textarea
|
||||
className="textarea textarea-bordered w-full font-mono text-xs h-32"
|
||||
value={credentials.certificate_pem}
|
||||
readOnly
|
||||
/>
|
||||
<div className="flex gap-2 mt-2">
|
||||
<button
|
||||
className="btn btn-sm btn-outline"
|
||||
onClick={() => copyToClipboard(credentials.certificate_pem!, 'Device certificate')}
|
||||
>
|
||||
Copy
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-sm btn-outline"
|
||||
onClick={() => downloadFile(credentials.certificate_pem!, `${resolvedDeviceId}.crt`)}
|
||||
>
|
||||
Download
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{credentials.private_key_pem && (
|
||||
<div>
|
||||
<label className="label">
|
||||
<span className="label-text font-semibold">Private Key</span>
|
||||
</label>
|
||||
<textarea
|
||||
className="textarea textarea-bordered w-full font-mono text-xs h-32"
|
||||
value={credentials.private_key_pem}
|
||||
readOnly
|
||||
/>
|
||||
<div className="flex gap-2 mt-2">
|
||||
<button
|
||||
className="btn btn-sm btn-outline"
|
||||
onClick={() => copyToClipboard(credentials.private_key_pem!, 'Private key')}
|
||||
>
|
||||
Copy
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-sm btn-outline"
|
||||
onClick={() => downloadFile(credentials.private_key_pem!, `${resolvedDeviceId}.key`)}
|
||||
>
|
||||
Download
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
76
frontend/src/components/DeleteDeviceDialog.tsx
Normal file
76
frontend/src/components/DeleteDeviceDialog.tsx
Normal file
@@ -0,0 +1,76 @@
|
||||
import * as AlertDialog from '@radix-ui/react-alert-dialog'
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { devicesApi } from '../api'
|
||||
import toast from 'react-hot-toast'
|
||||
import type { AxiosError } from 'axios'
|
||||
import type { Device } from '../types/api'
|
||||
|
||||
interface DeleteDeviceDialogProps {
|
||||
device: Device
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
onDeleted?: () => void
|
||||
}
|
||||
|
||||
export default function DeleteDeviceDialog({ device, open, onOpenChange, onDeleted }: DeleteDeviceDialogProps) {
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: () => devicesApi.delete(device.id),
|
||||
onSuccess: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ['devices'] })
|
||||
queryClient.invalidateQueries({ queryKey: ['device', device.id] })
|
||||
toast.success(`Device "${device.name}" deleted successfully`)
|
||||
onDeleted?.()
|
||||
onOpenChange(false)
|
||||
},
|
||||
onError: (error) => {
|
||||
const axiosError = error as AxiosError<{ detail?: string }>
|
||||
const message = axiosError.response?.data?.detail || axiosError.message
|
||||
toast.error(`Failed to delete device: ${message}`)
|
||||
},
|
||||
})
|
||||
|
||||
return (
|
||||
<AlertDialog.Root open={open} onOpenChange={onOpenChange}>
|
||||
<AlertDialog.Portal>
|
||||
<AlertDialog.Overlay className="fixed inset-0 bg-black/50 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0" />
|
||||
<AlertDialog.Content className="fixed left-[50%] top-[50%] z-50 max-h-[85vh] w-[90vw] max-w-[500px] translate-x-[-50%] translate-y-[-50%] rounded-lg bg-base-100 p-6 shadow-xl">
|
||||
<AlertDialog.Title className="text-2xl font-bold mb-2">
|
||||
Delete Device
|
||||
</AlertDialog.Title>
|
||||
<AlertDialog.Description className="text-base-content/70 mb-6">
|
||||
Are you sure you want to delete <strong>{device.name}</strong>? This action cannot be undone.
|
||||
All associated telemetry data and certificates will be permanently removed.
|
||||
</AlertDialog.Description>
|
||||
<div className="flex justify-end gap-3">
|
||||
<AlertDialog.Cancel asChild>
|
||||
<button className="btn btn-ghost" disabled={deleteMutation.isPending}>
|
||||
Cancel
|
||||
</button>
|
||||
</AlertDialog.Cancel>
|
||||
<AlertDialog.Action asChild>
|
||||
<button
|
||||
className="btn btn-error"
|
||||
onClick={(e) => {
|
||||
e.preventDefault()
|
||||
deleteMutation.mutate()
|
||||
}}
|
||||
disabled={deleteMutation.isPending}
|
||||
>
|
||||
{deleteMutation.isPending ? (
|
||||
<>
|
||||
<span className="loading loading-spinner loading-sm"></span>
|
||||
Deleting...
|
||||
</>
|
||||
) : (
|
||||
'Delete Device'
|
||||
)}
|
||||
</button>
|
||||
</AlertDialog.Action>
|
||||
</div>
|
||||
</AlertDialog.Content>
|
||||
</AlertDialog.Portal>
|
||||
</AlertDialog.Root>
|
||||
)
|
||||
}
|
||||
40
frontend/src/components/DeviceCredentialsDialog.tsx
Normal file
40
frontend/src/components/DeviceCredentialsDialog.tsx
Normal file
@@ -0,0 +1,40 @@
|
||||
import * as Dialog from '@radix-ui/react-dialog'
|
||||
import CredentialsViewer from './CredentialsViewer'
|
||||
import type { DeviceRegistrationResponse } from '../types/api'
|
||||
|
||||
interface DeviceCredentialsDialogProps {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
credentials: DeviceRegistrationResponse | null
|
||||
deviceName?: string
|
||||
}
|
||||
|
||||
export default function DeviceCredentialsDialog({ open, onOpenChange, credentials, deviceName }: DeviceCredentialsDialogProps) {
|
||||
if (!credentials) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog.Root open={open} onOpenChange={onOpenChange}>
|
||||
<Dialog.Portal>
|
||||
<Dialog.Overlay className="fixed inset-0 bg-black/50 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0" />
|
||||
<Dialog.Content className="fixed left-1/2 top-1/2 z-50 max-h-[85vh] w-[90vw] max-w-[600px] -translate-x-1/2 -translate-y-1/2 rounded-lg bg-base-100 p-6 shadow-xl overflow-y-auto">
|
||||
<Dialog.Title className="text-2xl font-bold mb-4">
|
||||
{deviceName ? `${deviceName} Credentials` : 'Device Credentials'}
|
||||
</Dialog.Title>
|
||||
<Dialog.Description className="text-base-content/70 mb-4">
|
||||
Store these credentials securely. They are only shown once after issuing the certificate.
|
||||
</Dialog.Description>
|
||||
|
||||
<CredentialsViewer credentials={credentials} deviceId={credentials.device_id} />
|
||||
|
||||
<div className="flex justify-end mt-6">
|
||||
<Dialog.Close asChild>
|
||||
<button className="btn btn-primary">Done</button>
|
||||
</Dialog.Close>
|
||||
</div>
|
||||
</Dialog.Content>
|
||||
</Dialog.Portal>
|
||||
</Dialog.Root>
|
||||
)
|
||||
}
|
||||
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 metric = metricIds[0]
|
||||
|
||||
const {
|
||||
data: analysis,
|
||||
isLoading,
|
||||
refetch,
|
||||
error,
|
||||
} = useQuery({
|
||||
queryKey: ['ai-insight', deviceId, metric, promptType, timeframe],
|
||||
queryFn: async () => {
|
||||
const response = await telemetryApi.analyze({
|
||||
device_id: deviceId,
|
||||
metric,
|
||||
hours: timeframe.hours || 240,
|
||||
limit: 200,
|
||||
prompt_type: promptType,
|
||||
})
|
||||
return response.data
|
||||
},
|
||||
enabled: false, // Manual trigger
|
||||
})
|
||||
|
||||
const handleAnalyze = () => {
|
||||
setShowAnalysis(true)
|
||||
refetch()
|
||||
}
|
||||
|
||||
// Parse JSON analysis if it's a string
|
||||
const parsedAnalysis = analysis?.analysis ? (() => {
|
||||
try {
|
||||
return typeof analysis.analysis === 'string'
|
||||
? JSON.parse(analysis.analysis)
|
||||
: analysis.analysis
|
||||
} catch {
|
||||
return null // If parsing fails, return null to show raw text
|
||||
}
|
||||
})() : null
|
||||
|
||||
const isTrendSummary = promptType === 'trend_summary' && parsedAnalysis
|
||||
const isAnomalyDetection = promptType === 'anomaly_detection' && parsedAnalysis
|
||||
|
||||
const getStatusColor = (status: string) => {
|
||||
switch (status) {
|
||||
case 'excellent': return 'badge-success'
|
||||
case 'good': return 'badge-info'
|
||||
case 'fair': return 'badge-warning'
|
||||
case 'poor': return 'badge-error'
|
||||
case 'normal': return 'badge-success'
|
||||
case 'warning': return 'badge-warning'
|
||||
case 'critical': return 'badge-error'
|
||||
default: return 'badge-ghost'
|
||||
}
|
||||
}
|
||||
|
||||
const getDirectionIcon = (direction: string) => {
|
||||
switch (direction) {
|
||||
case 'improving': return '↑'
|
||||
case 'degrading': return '↓'
|
||||
case 'stable': return '→'
|
||||
default: return '•'
|
||||
}
|
||||
}
|
||||
|
||||
const getSeverityColor = (severity: string) => {
|
||||
switch (severity) {
|
||||
case 'critical': return 'badge-error'
|
||||
case 'high': return 'badge-warning'
|
||||
case 'medium': return 'badge-warning badge-outline'
|
||||
case 'low': return 'badge-info'
|
||||
default: return 'badge-ghost'
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="card bg-base-100 shadow">
|
||||
<div className="card-body">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="card-title text-base">{title || 'AI Environmental Insights'}</h3>
|
||||
<div className="flex gap-2">
|
||||
<select
|
||||
className="select select-bordered select-sm"
|
||||
value={promptType}
|
||||
onChange={(e) => setPromptType(e.target.value as 'trend_summary' | 'anomaly_detection')}
|
||||
>
|
||||
<option value="trend_summary">Trend Summary</option>
|
||||
<option value="anomaly_detection">Anomaly Detection</option>
|
||||
</select>
|
||||
<button
|
||||
className="btn btn-primary btn-sm"
|
||||
onClick={handleAnalyze}
|
||||
disabled={isLoading}
|
||||
>
|
||||
{isLoading ? (
|
||||
<>
|
||||
<span className="loading loading-spinner loading-xs"></span>
|
||||
Analyzing...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9.663 17h4.673M12 3v1m6.364 1.636l-.707.707M21 12h-1M4 12H3m3.343-5.657l-.707-.707m2.828 9.9a5 5 0 117.072 0l-.548.547A3.374 3.374 0 0014 18.469V19a2 2 0 11-4 0v-.531c0-.895-.356-1.754-.988-2.386l-.548-.547z" />
|
||||
</svg>
|
||||
Analyze
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isLoading && (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<div className="text-center space-y-4">
|
||||
<span className="loading loading-spinner loading-lg text-primary"></span>
|
||||
<p className="text-sm text-base-content/60">Analyzing environmental data...</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<div className="alert alert-error mt-4">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="stroke-current shrink-0 h-6 w-6" fill="none" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
<div>
|
||||
<div className="font-bold">Analysis Failed</div>
|
||||
<div className="text-sm">{(error as Error)?.message || 'Could not connect to GPT service'}</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{analysis && showAnalysis && !isLoading && (
|
||||
<div className="space-y-4 mt-4">
|
||||
{/* Structured Display for Trend Summary */}
|
||||
{isTrendSummary && parsedAnalysis && (
|
||||
<>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className={`badge badge-lg ${getStatusColor(parsedAnalysis.status)}`}>
|
||||
{parsedAnalysis.status.toUpperCase()}
|
||||
</div>
|
||||
<div className="text-xs text-base-content/50">
|
||||
{analysis.data_points_analyzed} data points
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Summary */}
|
||||
<div className="alert alert-info">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
<div>
|
||||
<div className="font-bold">Summary</div>
|
||||
<div className="text-sm">{parsedAnalysis.summary}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Comfort Score */}
|
||||
{parsedAnalysis.comfort_score && (
|
||||
<div className="card bg-base-200">
|
||||
<div className="card-body p-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="radial-progress text-primary" style={{ "--value": parsedAnalysis.comfort_score.rating } as React.CSSProperties}>
|
||||
{parsedAnalysis.comfort_score.rating}
|
||||
</div>
|
||||
<div>
|
||||
<div className="font-bold">Comfort Score</div>
|
||||
<div className="text-sm text-base-content/70">{parsedAnalysis.comfort_score.description}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Trends */}
|
||||
{parsedAnalysis.trends && parsedAnalysis.trends.length > 0 && (
|
||||
<div>
|
||||
<h4 className="font-bold text-sm mb-2">Trends</h4>
|
||||
<div className="space-y-2">
|
||||
{parsedAnalysis.trends.map((trend: TrendSummary['trends'][0], i: number) => (
|
||||
<div key={i} className="card bg-base-200">
|
||||
<div className="card-body p-3">
|
||||
<div className="flex items-start gap-2">
|
||||
<span className="text-lg">{getDirectionIcon(trend.direction)}</span>
|
||||
<div className="flex-1">
|
||||
<div className="font-semibold text-sm">{trend.metric}</div>
|
||||
<div className="text-xs text-base-content/70">{trend.description}</div>
|
||||
</div>
|
||||
<div className="badge badge-sm">{trend.direction}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Patterns */}
|
||||
{parsedAnalysis.patterns && parsedAnalysis.patterns.length > 0 && (
|
||||
<div>
|
||||
<h4 className="font-bold text-sm mb-2">Patterns Detected</h4>
|
||||
<ul className="space-y-1">
|
||||
{parsedAnalysis.patterns.map((pattern: string, i: number) => (
|
||||
<li key={i} className="text-sm flex items-start gap-2">
|
||||
<span className="text-primary mt-0.5">▸</span>
|
||||
<span>{pattern}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Recommendations */}
|
||||
{parsedAnalysis.recommendations && parsedAnalysis.recommendations.length > 0 && (
|
||||
<div>
|
||||
<h4 className="font-bold text-sm mb-2">Recommendations</h4>
|
||||
<div className="space-y-2">
|
||||
{parsedAnalysis.recommendations.map((rec: string, i: number) => (
|
||||
<div key={i} className="alert alert-success alert-sm">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-4 w-4 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
<span className="text-sm">{rec}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Forecast */}
|
||||
{parsedAnalysis.forecast && (
|
||||
<div className="alert">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 7h8m0 0v8m0-8l-8 8-4-4-6 6" />
|
||||
</svg>
|
||||
<div>
|
||||
<div className="font-bold text-sm">Forecast</div>
|
||||
<div className="text-xs">{parsedAnalysis.forecast}</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Structured Display for Anomaly Detection */}
|
||||
{isAnomalyDetection && parsedAnalysis && (
|
||||
<>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className={`badge badge-lg ${getStatusColor(parsedAnalysis.status)}`}>
|
||||
{parsedAnalysis.status.toUpperCase()}
|
||||
</div>
|
||||
<div className="text-xs text-base-content/50">
|
||||
{analysis.data_points_analyzed} data points
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Summary */}
|
||||
<div className={`alert ${parsedAnalysis.status === 'critical' ? 'alert-error' : parsedAnalysis.status === 'warning' ? 'alert-warning' : 'alert-success'}`}>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
<div>
|
||||
<div className="font-bold">Summary</div>
|
||||
<div className="text-sm">{parsedAnalysis.summary}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Anomalies */}
|
||||
{parsedAnalysis.anomalies && parsedAnalysis.anomalies.length > 0 && (
|
||||
<div>
|
||||
<h4 className="font-bold text-sm mb-2">Anomalies Detected</h4>
|
||||
<div className="space-y-2">
|
||||
{parsedAnalysis.anomalies.map((anomaly: AnomalyDetection['anomalies'][0], i: number) => (
|
||||
<div key={i} className="card bg-base-200 border-l-4 border-error">
|
||||
<div className="card-body p-3">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="flex-1">
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<span className="font-semibold text-sm">{anomaly.metric}</span>
|
||||
<span className={`badge badge-sm ${getSeverityColor(anomaly.severity)}`}>
|
||||
{anomaly.severity}
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-xs text-base-content/70 mb-1">{anomaly.description}</div>
|
||||
<div className="text-xs">
|
||||
<span className="text-error font-semibold">Current: {anomaly.value}</span>
|
||||
{' • '}
|
||||
<span className="text-base-content/60">Expected: {anomaly.expected}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Impacts */}
|
||||
{parsedAnalysis.impacts && parsedAnalysis.impacts.length > 0 && (
|
||||
<div>
|
||||
<h4 className="font-bold text-sm mb-2">Potential Impacts</h4>
|
||||
<ul className="space-y-1">
|
||||
{parsedAnalysis.impacts.map((impact: string, i: number) => (
|
||||
<li key={i} className="text-sm flex items-start gap-2">
|
||||
<span className="text-warning mt-0.5">▸</span>
|
||||
<span>{impact}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Actions */}
|
||||
{parsedAnalysis.actions && parsedAnalysis.actions.length > 0 && (
|
||||
<div>
|
||||
<h4 className="font-bold text-sm mb-2">Recommended Actions</h4>
|
||||
<div className="space-y-2">
|
||||
{parsedAnalysis.actions.map((action: string, i: number) => (
|
||||
<div key={i} className="alert alert-warning alert-sm">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-4 w-4 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
|
||||
</svg>
|
||||
<span className="text-sm">{action}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Root Causes */}
|
||||
{parsedAnalysis.root_causes && parsedAnalysis.root_causes.length > 0 && (
|
||||
<div>
|
||||
<h4 className="font-bold text-sm mb-2">Possible Root Causes</h4>
|
||||
<ul className="space-y-1">
|
||||
{parsedAnalysis.root_causes.map((cause: string, i: number) => (
|
||||
<li key={i} className="text-sm flex items-start gap-2">
|
||||
<span className="text-info mt-0.5">▸</span>
|
||||
<span>{cause}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Fallback: Raw Text Display */}
|
||||
{!parsedAnalysis && (
|
||||
<>
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="badge badge-primary badge-lg">
|
||||
{promptType === 'trend_summary' ? 'Trend Analysis' : 'Anomaly Detection'}
|
||||
</div>
|
||||
<div className="text-xs text-base-content/50">
|
||||
{analysis.data_points_analyzed} data points analyzed
|
||||
</div>
|
||||
</div>
|
||||
<div className="divider my-2"></div>
|
||||
<div className="prose max-w-none">
|
||||
<div className="whitespace-pre-wrap text-sm leading-relaxed max-h-96 overflow-y-auto">
|
||||
{analysis.analysis}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className="flex justify-end gap-2 mt-4">
|
||||
<button
|
||||
className="btn btn-ghost btn-sm"
|
||||
onClick={() => setShowAnalysis(false)}
|
||||
>
|
||||
Close
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-outline btn-sm"
|
||||
onClick={() => refetch()}
|
||||
>
|
||||
Refresh Analysis
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!showAnalysis && (
|
||||
<div className="text-center py-8 text-base-content/60">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-12 w-12 mx-auto mb-4 opacity-50" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9.663 17h4.673M12 3v1m6.364 1.636l-.707.707M21 12h-1M4 12H3m3.343-5.657l-.707-.707m2.828 9.9a5 5 0 117.072 0l-.548.547A3.374 3.374 0 0014 18.469V19a2 2 0 11-4 0v-.531c0-.895-.356-1.754-.988-2.386l-.548-.547z" />
|
||||
</svg>
|
||||
<p className="text-sm">Click Analyze to get AI-powered environmental insights</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
141
frontend/src/components/widgets/AirQualityWidget.tsx
Normal file
141
frontend/src/components/widgets/AirQualityWidget.tsx
Normal file
@@ -0,0 +1,141 @@
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import type { WidgetConfig } from '../../hooks'
|
||||
import { weatherApi } from '../../api'
|
||||
import './widget-styles.css'
|
||||
|
||||
interface AirQualityWidgetProps {
|
||||
config: WidgetConfig
|
||||
}
|
||||
|
||||
export default function AirQualityWidget({ config }: AirQualityWidgetProps) {
|
||||
// Get city from config or use default (Pulse.eco city)
|
||||
const city = (config.visualization as Record<string, unknown>)?.city as string || 'skopje'
|
||||
|
||||
const { data: airQuality, isLoading, error } = useQuery({
|
||||
queryKey: ['air-quality', city],
|
||||
queryFn: async () => {
|
||||
const response = await weatherApi.getAirQuality(city)
|
||||
return response.data
|
||||
},
|
||||
refetchInterval: 300000, // Refresh every 5 minutes
|
||||
staleTime: 240000, // Consider fresh for 4 minutes
|
||||
})
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="widget-card card bg-base-100 h-full">
|
||||
<div className="card-body flex items-center justify-center">
|
||||
<span className="loading loading-spinner loading-lg"></span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="widget-card card bg-base-100 h-full">
|
||||
<div className="card-body">
|
||||
<h2 className="card-title text-sm truncate">{config.title}</h2>
|
||||
<div className="flex flex-col items-center justify-center flex-1">
|
||||
<p className="text-error text-sm text-center">
|
||||
Failed to load air quality data for {city}
|
||||
</p>
|
||||
<p className="text-xs text-base-content/60 mt-2">
|
||||
Try: skopje, bitola, tetovo
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!airQuality) return null
|
||||
|
||||
// Get AQI color based on status
|
||||
const getStatusColor = (status: string) => {
|
||||
switch (status.toLowerCase()) {
|
||||
case 'good':
|
||||
return 'success'
|
||||
case 'moderate':
|
||||
return 'warning'
|
||||
case 'unhealthy for sensitive groups':
|
||||
case 'unhealthy':
|
||||
return 'error'
|
||||
case 'very unhealthy':
|
||||
case 'hazardous':
|
||||
return 'error'
|
||||
default:
|
||||
return 'base-content/40'
|
||||
}
|
||||
}
|
||||
|
||||
const statusColor = getStatusColor(airQuality.status)
|
||||
const pm10 = airQuality.measurements.pm10
|
||||
const pm25 = airQuality.measurements.pm25
|
||||
|
||||
return (
|
||||
<div className="widget-card card bg-base-100 h-full">
|
||||
<div className="card-body">
|
||||
<h2 className="card-title text-sm truncate">{config.title}</h2>
|
||||
<div className="flex flex-col items-center justify-center flex-1">
|
||||
{/* Air quality icon */}
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
className={`h-12 w-12 text-${statusColor} mb-1`}
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M3 15a4 4 0 004 4h9a5 5 0 10-.1-9.999 5.002 5.002 0 10-9.78 2.096A4.001 4.001 0 003 15z"
|
||||
/>
|
||||
</svg>
|
||||
|
||||
{/* PM Values */}
|
||||
<div className="grid grid-cols-2 gap-2 w-full mb-2">
|
||||
{pm10 && (
|
||||
<div className="text-center">
|
||||
<div className="text-xl font-bold">{pm10.average.toFixed(1)}</div>
|
||||
<div className="text-xs text-base-content/60">PM10 μg/m³</div>
|
||||
</div>
|
||||
)}
|
||||
{pm25 && (
|
||||
<div className="text-center">
|
||||
<div className="text-xl font-bold">{pm25.average.toFixed(1)}</div>
|
||||
<div className="text-xs text-base-content/60">PM2.5 μg/m³</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* AQI Status badge */}
|
||||
<div className={`badge badge-${statusColor} truncate max-w-full`}>
|
||||
{airQuality.status}
|
||||
</div>
|
||||
|
||||
{/* Additional pollutants */}
|
||||
<div className="grid grid-cols-2 gap-2 mt-2 w-full text-xs">
|
||||
{Object.entries(airQuality.measurements).map(([pollutant, data]) => {
|
||||
if (pollutant === 'pm10' || pollutant === 'pm25') return null
|
||||
return (
|
||||
<div key={pollutant} className="flex justify-between">
|
||||
<span className="opacity-60">{pollutant.toUpperCase()}:</span>
|
||||
<span className="font-semibold">{data.average.toFixed(1)}</span>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* City and sensor count */}
|
||||
<div className="text-xs text-base-content/40 mt-2 px-2 w-full overflow-hidden">
|
||||
<div className="truncate text-center">
|
||||
{airQuality.city.charAt(0).toUpperCase() + airQuality.city.slice(1)} • {airQuality.sensor_count} sensors
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
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: 60000, // Refresh every minute
|
||||
enabled: !!deviceId,
|
||||
})
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="widget-card card bg-base-100 h-full">
|
||||
<div className="card-body flex items-center justify-center">
|
||||
<span className="loading loading-spinner loading-lg"></span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (error || !data) {
|
||||
return (
|
||||
<div className="widget-card card bg-base-100 h-full">
|
||||
<div className="card-body flex items-center justify-center">
|
||||
<div className="alert alert-error">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="stroke-current shrink-0 h-6 w-6" fill="none" viewBox="0 0 24 24">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M10 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2m7-2a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
<span>Failed to load comfort index</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const getRatingColor = (rating: string) => {
|
||||
switch (rating) {
|
||||
case 'Excellent': return 'text-success'
|
||||
case 'Good': return 'text-info'
|
||||
case 'Fair': return 'text-warning'
|
||||
case 'Poor': return 'text-error'
|
||||
case 'Very Poor': return 'text-error'
|
||||
default: return 'text-base-content'
|
||||
}
|
||||
}
|
||||
|
||||
const getScoreColor = (score: number) => {
|
||||
if (score >= 90) return 'text-success'
|
||||
if (score >= 75) return 'text-info'
|
||||
if (score >= 60) return 'text-warning'
|
||||
if (score >= 40) return 'text-error'
|
||||
return 'text-error'
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="widget-card card bg-base-100 h-full">
|
||||
<div className="card-body p-3 gap-2">
|
||||
{/* Title */}
|
||||
<h2 className="card-title text-sm mb-1">{config.title}</h2>
|
||||
|
||||
{/* Overall Score */}
|
||||
<div className="text-center">
|
||||
<div className={`text-4xl font-bold ${getScoreColor(data.overall_score)}`}>
|
||||
{data.overall_score}
|
||||
</div>
|
||||
<div className={`text-lg font-semibold ${getRatingColor(data.rating)} mt-0.5`}>
|
||||
{data.rating}
|
||||
</div>
|
||||
<div className="text-xs text-base-content/60">Comfort Index</div>
|
||||
</div>
|
||||
|
||||
{/* Component Scores */}
|
||||
<div className="grid grid-cols-2 gap-2 text-xs">
|
||||
<div className="flex items-center justify-between p-2 bg-base-200 rounded">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<ThermometerIcon className="w-4 h-4" />
|
||||
<span className="truncate">Temperature</span>
|
||||
</div>
|
||||
<span className={`font-bold ${getScoreColor(data.components.temperature)}`}>
|
||||
{data.components.temperature}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between p-2 bg-base-200 rounded">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<DropletIcon className="w-4 h-4" />
|
||||
<span className="truncate">Humidity</span>
|
||||
</div>
|
||||
<span className={`font-bold ${getScoreColor(data.components.humidity)}`}>
|
||||
{data.components.humidity}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between p-2 bg-base-200 rounded">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<AirQualityIcon className="w-4 h-4" />
|
||||
<span className="truncate">Air Quality</span>
|
||||
</div>
|
||||
<span className={`font-bold ${getScoreColor(data.components.air_quality)}`}>
|
||||
{data.components.air_quality}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between p-2 bg-base-200 rounded">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<AcousticIcon className="w-4 h-4" />
|
||||
<span className="truncate">Acoustic</span>
|
||||
</div>
|
||||
<span className={`font-bold ${getScoreColor(data.components.acoustic)}`}>
|
||||
{data.components.acoustic}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between p-2 bg-base-200 rounded col-span-2">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<LightIcon className="w-4 h-4" />
|
||||
<span className="truncate">Lighting</span>
|
||||
</div>
|
||||
<span className={`font-bold ${getScoreColor(data.components.light)}`}>
|
||||
{data.components.light}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Suggestions */}
|
||||
{data.suggestions.length > 0 && (
|
||||
<div className="mt-auto">
|
||||
<div className="flex items-center gap-1 text-xs font-semibold mb-1">
|
||||
<LightIcon className="w-3.5 h-3.5" />
|
||||
<span>Suggestions</span>
|
||||
</div>
|
||||
<div className="space-y-1 max-h-20 overflow-y-auto">
|
||||
{data.suggestions.map((suggestion, i) => (
|
||||
<div key={i} className="text-xs bg-warning/10 p-1 rounded border-l-2 border-warning">
|
||||
{suggestion}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
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: 60000, // Refresh every minute
|
||||
enabled: !!deviceId,
|
||||
})
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="widget-card card bg-base-100 h-full">
|
||||
<div className="card-body flex items-center justify-center">
|
||||
<span className="loading loading-spinner loading-lg"></span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="widget-card card bg-base-100 h-full">
|
||||
<div className="card-body flex items-center justify-center">
|
||||
<div className="alert alert-error text-xs">
|
||||
<span>
|
||||
{error instanceof Error ? error.message : 'Failed to load health data'}
|
||||
</span>
|
||||
</div>
|
||||
{!deviceId && (
|
||||
<p className="text-xs text-base-content/60 mt-2">Select a health device</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!data) return null
|
||||
|
||||
const StepIcon = () => (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z" />
|
||||
</svg>
|
||||
)
|
||||
|
||||
const FireIcon = () => (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M17.657 18.657A8 8 0 016.343 7.343S7 9 9 10c0-2 .5-5 2.986-7C14 5 16.09 5.777 17.656 7.343A7.975 7.975 0 0120 13a7.975 7.975 0 01-2.343 5.657z" />
|
||||
</svg>
|
||||
)
|
||||
|
||||
const HeartIcon = () => (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4.318 6.318a4.5 4.5 0 000 6.364L12 20.364l7.682-7.682a4.5 4.5 0 00-6.364-6.364L12 7.636l-1.318-1.318a4.5 4.5 0 00-6.364 0z" />
|
||||
</svg>
|
||||
)
|
||||
|
||||
const CheckIcon = () => (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
)
|
||||
|
||||
const WarningIcon = () => (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z" />
|
||||
</svg>
|
||||
)
|
||||
|
||||
const stats = [
|
||||
{
|
||||
label: 'Steps',
|
||||
value: data.health_metrics.steps,
|
||||
unit: '',
|
||||
icon: <StepIcon />,
|
||||
color: 'text-primary'
|
||||
},
|
||||
{
|
||||
label: 'Calories',
|
||||
value: data.health_metrics.active_calories,
|
||||
unit: 'kcal',
|
||||
icon: <FireIcon />,
|
||||
color: 'text-secondary'
|
||||
},
|
||||
{
|
||||
label: 'Heart Rate',
|
||||
value: data.health_metrics.heart_rate,
|
||||
unit: 'bpm',
|
||||
icon: <HeartIcon />,
|
||||
color: 'text-error'
|
||||
},
|
||||
]
|
||||
|
||||
// Find insights for each stat
|
||||
const getInsightForMetric = (metric: string) => {
|
||||
return data.insights.find(i => i.metric === metric)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="widget-card card bg-base-100 h-full flex flex-col">
|
||||
<div className="card-body p-3 flex-1 min-h-0">
|
||||
<h2 className="card-title text-sm mb-3">{config.title}</h2>
|
||||
|
||||
{/* Health Stats */}
|
||||
<div className="space-y-3">
|
||||
{stats.map((stat, i) => {
|
||||
const insight = getInsightForMetric(stat.label)
|
||||
const hasValue = stat.value !== null && stat.value !== undefined
|
||||
|
||||
return (
|
||||
<div key={i} className="space-y-1">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className={stat.color}>{stat.icon}</span>
|
||||
<span className="text-sm opacity-70">{stat.label}</span>
|
||||
</div>
|
||||
<div className="flex items-baseline gap-1">
|
||||
<span className={`text-xl font-bold ${stat.color}`}>
|
||||
{hasValue ? Math.round(Number(stat.value)).toLocaleString() : '—'}
|
||||
</span>
|
||||
{hasValue && <span className="text-xs opacity-50">{stat.unit}</span>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Context/Correlation */}
|
||||
{insight && (
|
||||
<div className="text-xs space-y-0.5 ml-7">
|
||||
{insight.context && (
|
||||
<div className="opacity-70">{insight.context}</div>
|
||||
)}
|
||||
{insight.correlation && (
|
||||
<div className="text-warning opacity-80 flex items-center gap-1">
|
||||
<WarningIcon />
|
||||
{insight.correlation}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Recommendations */}
|
||||
{data.recommendations.length > 0 && (
|
||||
<div className="mt-2 pt-2 border-t border-base-300">
|
||||
<div className="text-xs">
|
||||
<div className="flex items-start gap-1.5">
|
||||
<span className="text-success"><CheckIcon /></span>
|
||||
<span className="opacity-80">{data.recommendations[0]}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
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: 300000, // Refresh every 5 minutes
|
||||
enabled: !!deviceId && !!widgetCity,
|
||||
})
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="widget-card card bg-base-100 h-full">
|
||||
<div className="card-body flex items-center justify-center">
|
||||
<span className="loading loading-spinner loading-lg"></span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (error || !data) {
|
||||
return (
|
||||
<div className="widget-card card bg-base-100 h-full">
|
||||
<div className="card-body flex flex-col items-center justify-center text-center gap-2">
|
||||
<div className="alert alert-error text-xs">
|
||||
<span>
|
||||
{error instanceof Error ? error.message : 'No data available'}
|
||||
</span>
|
||||
</div>
|
||||
{(!deviceId || !widgetCity) && (
|
||||
<p className="text-xs text-base-content/60">
|
||||
{!deviceId && 'Select a health device'}
|
||||
{!deviceId && !widgetCity && ' and '}
|
||||
{!widgetCity && 'Select a city'}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
const getStatusColor = (status: string) => {
|
||||
switch (status) {
|
||||
case 'GO': return 'text-success'
|
||||
case 'MODERATE': return 'text-warning'
|
||||
case 'NO': return 'text-error'
|
||||
default: return 'text-base-content'
|
||||
}
|
||||
}
|
||||
|
||||
const getBgColor = (status: string) => {
|
||||
switch (status) {
|
||||
case 'GO': return 'bg-success/10 border-success/20'
|
||||
case 'MODERATE': return 'bg-warning/10 border-warning/20'
|
||||
case 'NO': return 'bg-error/10 border-error/20'
|
||||
default: return 'bg-base-200'
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="widget-card card bg-base-100 h-full flex flex-col">
|
||||
<div className="card-body p-3 flex flex-col gap-2 flex-1 min-h-0">
|
||||
<h2 className="card-title text-sm mb-2">{config.title}</h2>
|
||||
|
||||
{/* Status Badge */}
|
||||
<div className="flex flex-col items-center justify-center text-center gap-2">
|
||||
<div className={`w-20 h-20 rounded-full flex items-center justify-center border-4 ${getStatusColor(data.status)} ${getBgColor(data.status)}`}>
|
||||
<span className={`text-2xl font-black ${getStatusColor(data.status)}`}>
|
||||
{data.status}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Primary Reason */}
|
||||
<div className="space-y-1">
|
||||
<p className="font-medium text-sm">{data.primary_reason}</p>
|
||||
|
||||
{/* Score Breakdown */}
|
||||
<div className="flex gap-2 text-xs text-base-content/60 justify-center">
|
||||
<span>Weather: {data.scores.weather.toFixed(0)}</span>
|
||||
<span>•</span>
|
||||
<span>Air: {data.scores.air_quality.toFixed(0)}</span>
|
||||
<span>•</span>
|
||||
<span>Health: {data.scores.health.toFixed(0)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Detailed Insights */}
|
||||
{data.detailed_insights.length > 0 && (
|
||||
<div className="space-y-1 mt-1">
|
||||
<div className="text-xs font-semibold opacity-70">Conditions:</div>
|
||||
<ul className="text-xs space-y-0.5">
|
||||
{data.detailed_insights.slice(0, 2).map((insight, i) => (
|
||||
<li key={i} className="opacity-80">• {insight}</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Time Recommendations */}
|
||||
{data.time_recommendations.length > 0 && (
|
||||
<div className="space-y-1 mt-1">
|
||||
<div className="text-xs font-semibold opacity-70">Best Time:</div>
|
||||
<div className="text-xs opacity-80">• {data.time_recommendations[0]}</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Suggestions */}
|
||||
{data.suggestions.length > 0 && (
|
||||
<div className="mt-1 pt-1 border-t border-base-300">
|
||||
<div className="text-xs">
|
||||
<div className="flex items-start gap-1.5">
|
||||
<span className="text-success"><CheckIcon /></span>
|
||||
<span className="opacity-80">{data.suggestions[0]}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Quick Health Stats */}
|
||||
<div className="text-xs text-base-content/60 flex gap-2 justify-center pt-1 border-t border-base-300 flex-shrink-0 mt-auto">
|
||||
<span className="flex items-center gap-1">
|
||||
<StepIcon />
|
||||
{data.health_data.steps.toLocaleString()} steps
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
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: 300000, // Refresh every 5 minutes
|
||||
staleTime: 240000, // Consider fresh for 4 minutes
|
||||
})
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="widget-card card bg-base-100 h-full">
|
||||
<div className="card-body flex items-center justify-center">
|
||||
<span className="loading loading-spinner loading-lg"></span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="widget-card card bg-base-100 h-full">
|
||||
<div className="card-body">
|
||||
<h2 className="card-title text-sm truncate">{config.title}</h2>
|
||||
<div className="flex flex-col items-center justify-center flex-1">
|
||||
<p className="text-error">Failed to load weather data</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!weather) return null
|
||||
|
||||
const getWeatherIcon = (code: number) => {
|
||||
if (code === 0 || code === 1) return <SunIcon className="w-16 h-16 text-warning" />
|
||||
if (code === 2) return <PartlyCloudyIcon className="w-16 h-16 text-primary" />
|
||||
if (code === 3) return <CloudIcon className="w-16 h-16 text-primary" />
|
||||
if (code >= 45 && code <= 48) return <FogIcon className="w-16 h-16 text-primary" />
|
||||
if (code >= 51 && code <= 55) return <RainIcon className="w-16 h-16 text-primary" />
|
||||
if (code >= 61 && code <= 65) return <RainIcon className="w-16 h-16 text-primary" />
|
||||
if (code >= 71 && code <= 77) return <SnowIcon className="w-16 h-16 text-primary" />
|
||||
if (code >= 80 && code <= 82) return <RainIcon className="w-16 h-16 text-primary" />
|
||||
if (code >= 85 && code <= 86) return <SnowIcon className="w-16 h-16 text-primary" />
|
||||
if (code >= 95) return <ThunderIcon className="w-16 h-16 text-primary" />
|
||||
return <ThermometerIcon className="w-16 h-16 text-primary" />
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="widget-card card bg-base-100 h-full">
|
||||
<div className="card-body">
|
||||
<h2 className="card-title text-sm truncate">{config.title}</h2>
|
||||
<div className="flex flex-col items-center justify-center flex-1">
|
||||
{/* Weather Icon */}
|
||||
<div className="mb-1 text-primary">{getWeatherIcon(weather.weather_code)}</div>
|
||||
|
||||
{/* Temperature */}
|
||||
<div className="text-3xl font-bold">{weather.temperature.toFixed(1)}°C</div>
|
||||
<div className="text-xs text-base-content/60">
|
||||
Feels like {weather.apparent_temperature.toFixed(1)}°C
|
||||
</div>
|
||||
|
||||
{/* Weather Description */}
|
||||
<div className="badge badge-primary mt-1 truncate max-w-full">
|
||||
{weather.weather_description}
|
||||
</div>
|
||||
|
||||
{/* Additional Info */}
|
||||
<div className="grid grid-cols-2 gap-2 mt-2 w-full text-xs">
|
||||
<div className="flex items-center gap-2">
|
||||
<DropletIcon className="w-4 h-4 opacity-70" />
|
||||
<span>{weather.humidity}%</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<WindIcon className="w-4 h-4 opacity-70" />
|
||||
<span>{weather.wind_speed.toFixed(1)} km/h</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<CloudCoverIcon className="w-4 h-4 opacity-70" />
|
||||
<span>{weather.cloud_cover}%</span>
|
||||
</div>
|
||||
{weather.precipitation > 0 && (
|
||||
<div className="flex items-center gap-2">
|
||||
<RainDropIcon className="w-4 h-4 opacity-70" />
|
||||
<span>{weather.precipitation} mm</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Location */}
|
||||
<div className="text-xs text-base-content/40 mt-3 px-2 w-full overflow-hidden">
|
||||
<div className="truncate text-center">{weather.location}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
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;
|
||||
}
|
||||
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'
|
||||
220
frontend/src/hooks/useDashboardConfig.ts
Normal file
220
frontend/src/hooks/useDashboardConfig.ts
Normal file
@@ -0,0 +1,220 @@
|
||||
import { useState, useEffect, useRef } from 'react'
|
||||
import { dashboardLayoutApi } from '../api'
|
||||
|
||||
export type WidgetType = 'line-chart' | 'gauge' | 'stat' | 'ai-insight' | 'bar-chart' | 'air-quality' | 'weather' | 'comfort-index' | 'run-suitability' | 'health-stats' | 'calendar' | 'daily-briefing'
|
||||
|
||||
export interface WidgetConfig {
|
||||
id: string
|
||||
type: WidgetType
|
||||
title: string
|
||||
deviceIds: string[]
|
||||
metricIds: string[]
|
||||
timeframe: {
|
||||
hours?: number
|
||||
startTime?: string
|
||||
endTime?: string
|
||||
}
|
||||
visualization?: {
|
||||
colors?: string[]
|
||||
showLegend?: boolean
|
||||
showGrid?: boolean
|
||||
height?: number
|
||||
city?: string
|
||||
}
|
||||
calendar?: {
|
||||
icalUrl: string
|
||||
timeRangeHours?: number
|
||||
}
|
||||
briefing?: {
|
||||
briefingType: 'schedule' | 'environment' | 'full'
|
||||
city: string
|
||||
healthDeviceId?: string
|
||||
calendarUrl?: string
|
||||
calendarRangeHours?: number
|
||||
}
|
||||
position?: {
|
||||
x: number
|
||||
y: number
|
||||
w: number
|
||||
h: number
|
||||
}
|
||||
}
|
||||
|
||||
export interface DashboardConfig {
|
||||
widgets: WidgetConfig[]
|
||||
layout: 'grid' | 'freeform'
|
||||
refreshInterval?: number
|
||||
}
|
||||
|
||||
const DEFAULT_CONFIG: DashboardConfig = {
|
||||
widgets: [],
|
||||
layout: 'grid',
|
||||
refreshInterval: 30000,
|
||||
}
|
||||
|
||||
const STORAGE_KEY = 'iot-dashboard-config'
|
||||
|
||||
/**
|
||||
* Hook to manage dashboard configuration with backend sync and localStorage fallback
|
||||
* Single-user system: No authentication required
|
||||
*/
|
||||
export function useDashboardConfig() {
|
||||
const [config, setConfig] = useState<DashboardConfig>(DEFAULT_CONFIG)
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
const [layoutId, setLayoutId] = useState<number | null>(null)
|
||||
const isInitialLoadRef = useRef(true)
|
||||
|
||||
// Load config from backend or localStorage
|
||||
useEffect(() => {
|
||||
const loadConfig = async () => {
|
||||
setIsLoading(true)
|
||||
try {
|
||||
// Try to load from backend
|
||||
try {
|
||||
const response = await dashboardLayoutApi.getDefault()
|
||||
const layout = response.data
|
||||
setConfig(layout.config)
|
||||
setLayoutId(layout.id)
|
||||
} catch (error: any) {
|
||||
// No default layout found, try to create one or use localStorage fallback
|
||||
console.log('No default layout found, using localStorage or creating new')
|
||||
const stored = localStorage.getItem(STORAGE_KEY)
|
||||
if (stored) {
|
||||
const parsed = JSON.parse(stored) as DashboardConfig
|
||||
setConfig(parsed)
|
||||
// Save to backend
|
||||
try {
|
||||
const response = await dashboardLayoutApi.create({
|
||||
name: 'default',
|
||||
config: parsed,
|
||||
is_default: true,
|
||||
})
|
||||
setLayoutId(response.data.id)
|
||||
} catch (err) {
|
||||
console.error('Failed to save to backend:', err)
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to load dashboard config:', error)
|
||||
// Fallback to localStorage
|
||||
try {
|
||||
const stored = localStorage.getItem(STORAGE_KEY)
|
||||
if (stored) {
|
||||
setConfig(JSON.parse(stored) as DashboardConfig)
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to load from localStorage:', e)
|
||||
}
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
isInitialLoadRef.current = false
|
||||
}
|
||||
}
|
||||
|
||||
loadConfig()
|
||||
}, [])
|
||||
|
||||
const saveConfig = async () => {
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(config))
|
||||
} catch (error) {
|
||||
console.error('Failed to save to localStorage:', error)
|
||||
}
|
||||
|
||||
// Save to backend
|
||||
try {
|
||||
if (layoutId) {
|
||||
// Update existing layout
|
||||
await dashboardLayoutApi.update(layoutId, {
|
||||
config: config,
|
||||
})
|
||||
} else {
|
||||
// Create new layout
|
||||
const response = await dashboardLayoutApi.create({
|
||||
name: 'default',
|
||||
config: config,
|
||||
is_default: true,
|
||||
})
|
||||
setLayoutId(response.data.id)
|
||||
}
|
||||
return true
|
||||
} catch (error) {
|
||||
console.error('Failed to save to backend:', error)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// Only save to localStorage automatically (no backend saves)
|
||||
useEffect(() => {
|
||||
if (!isInitialLoadRef.current) {
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(config))
|
||||
} catch (error) {
|
||||
console.error('Failed to save to localStorage:', error)
|
||||
}
|
||||
}
|
||||
}, [config])
|
||||
|
||||
|
||||
const addWidget = (widget: WidgetConfig) => {
|
||||
setConfig((prev) => ({
|
||||
...prev,
|
||||
widgets: [...prev.widgets, widget],
|
||||
}))
|
||||
}
|
||||
|
||||
const updateWidget = (id: string, updates: Partial<WidgetConfig>) => {
|
||||
setConfig((prev) => ({
|
||||
...prev,
|
||||
widgets: prev.widgets.map((w) => (w.id === id ? { ...w, ...updates } : w)),
|
||||
}))
|
||||
}
|
||||
|
||||
const removeWidget = (id: string) => {
|
||||
setConfig((prev) => ({
|
||||
...prev,
|
||||
widgets: prev.widgets.filter((w) => w.id !== id),
|
||||
}))
|
||||
}
|
||||
|
||||
const reorderWidgets = (widgets: WidgetConfig[]) => {
|
||||
setConfig((prev) => ({
|
||||
...prev,
|
||||
widgets,
|
||||
}))
|
||||
}
|
||||
|
||||
const resetConfig = () => {
|
||||
setConfig(DEFAULT_CONFIG)
|
||||
}
|
||||
|
||||
const exportConfig = (): string => {
|
||||
return JSON.stringify(config, null, 2)
|
||||
}
|
||||
|
||||
const importConfig = (configOrJson: DashboardConfig | string) => {
|
||||
try {
|
||||
const imported = typeof configOrJson === 'string'
|
||||
? JSON.parse(configOrJson) as DashboardConfig
|
||||
: configOrJson
|
||||
setConfig(imported)
|
||||
} catch (error) {
|
||||
console.error('Failed to import config:', error)
|
||||
throw new Error('Invalid configuration')
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
config,
|
||||
addWidget,
|
||||
updateWidget,
|
||||
removeWidget,
|
||||
reorderWidgets,
|
||||
resetConfig,
|
||||
exportConfig,
|
||||
importConfig,
|
||||
saveConfig,
|
||||
isLoading,
|
||||
}
|
||||
}
|
||||
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 | number
|
||||
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', (event, items) => {
|
||||
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 ? 60000 : false, // Refresh every 60 seconds (reduced frequency)
|
||||
staleTime: 30000, // Consider data fresh for 30 seconds
|
||||
})
|
||||
|
||||
return {
|
||||
data: data || [],
|
||||
isLoading,
|
||||
isFetching,
|
||||
error: error as Error | null,
|
||||
refetch,
|
||||
}
|
||||
}
|
||||
40
frontend/src/hooks/useWellnessState.tsx
Normal file
40
frontend/src/hooks/useWellnessState.tsx
Normal file
@@ -0,0 +1,40 @@
|
||||
import { createContext, useContext, useState, 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
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
126
frontend/src/index.css
Normal file
126
frontend/src/index.css
Normal file
@@ -0,0 +1,126 @@
|
||||
@import "tailwindcss";
|
||||
@plugin "daisyui";
|
||||
|
||||
/* DaisyUI theme configuration */
|
||||
@theme {
|
||||
--dui-themes: light, dark, cupcake, corporate;
|
||||
}
|
||||
|
||||
/* Custom scrollbar styles */
|
||||
::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: rgba(0, 0, 0, 0.2);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
/* Dark mode scrollbar */
|
||||
@media (prefers-color-scheme: dark) {
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: rgba(255, 255, 255, 0.3);
|
||||
}
|
||||
}
|
||||
|
||||
/* Smooth transitions */
|
||||
.btn,
|
||||
.card {
|
||||
transition: all 0.2s ease-in-out;
|
||||
}
|
||||
|
||||
.card:hover {
|
||||
transform: translateY(-2px);
|
||||
}
|
||||
|
||||
/* Animations */
|
||||
@keyframes fadeInUp {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(20px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.stats {
|
||||
animation: fadeInUp 0.5s ease-out;
|
||||
}
|
||||
|
||||
/* Radix UI Dialog/AlertDialog overlays and content */
|
||||
@keyframes overlayShow {
|
||||
from {
|
||||
opacity: 0;
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes contentShow {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translate(-50%, -48%) scale(0.96);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translate(-50%, -50%) scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
/* Dialog/AlertDialog Overlay */
|
||||
[data-radix-dialog-overlay],
|
||||
[data-radix-alert-dialog-overlay] {
|
||||
background-color: rgba(0, 0, 0, 0.5);
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
animation: overlayShow 150ms cubic-bezier(0.16, 1, 0.3, 1);
|
||||
z-index: 50;
|
||||
}
|
||||
|
||||
/* Dialog/AlertDialog Content */
|
||||
[data-radix-dialog-content],
|
||||
[data-radix-alert-dialog-content] {
|
||||
background-color: white;
|
||||
border-radius: 0.5rem;
|
||||
box-shadow: 0 10px 15px -3px rgba(0, 0, 0, 0.1), 0 4px 6px -2px rgba(0, 0, 0, 0.05);
|
||||
position: fixed;
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
width: 90vw;
|
||||
max-width: 450px;
|
||||
max-height: 85vh;
|
||||
padding: 1.5rem;
|
||||
animation: contentShow 150ms cubic-bezier(0.16, 1, 0.3, 1);
|
||||
z-index: 51;
|
||||
}
|
||||
|
||||
[data-radix-dialog-content]:focus,
|
||||
[data-radix-alert-dialog-content]:focus {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
/* Dark mode support for dialogs */
|
||||
@media (prefers-color-scheme: dark) {
|
||||
[data-radix-dialog-content],
|
||||
[data-radix-alert-dialog-content] {
|
||||
background-color: #1f2937;
|
||||
color: white;
|
||||
}
|
||||
}
|
||||
22
frontend/src/lib/api-client.ts
Normal file
22
frontend/src/lib/api-client.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
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 response interceptor for error handling
|
||||
apiClient.interceptors.response.use(
|
||||
(response) => response,
|
||||
(error) => {
|
||||
// Basic error handling - can be extended if needed
|
||||
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>
|
||||
)
|
||||
}
|
||||
284
frontend/src/pages/Dashboard.tsx
Normal file
284
frontend/src/pages/Dashboard.tsx
Normal file
@@ -0,0 +1,284 @@
|
||||
import { useState, useRef, useEffect, useCallback } from 'react'
|
||||
import GridLayout from 'react-grid-layout'
|
||||
import 'react-grid-layout/css/styles.css'
|
||||
import { useDashboardConfig } from '../hooks'
|
||||
import { WidgetContainer } from '../components/widgets'
|
||||
import AddWidgetModal from '../components/AddWidgetModal'
|
||||
import EditWidgetModal from '../components/EditWidgetModal'
|
||||
|
||||
const GRID_COLUMNS = 5
|
||||
const GRID_MARGIN: [number, number] = [8, 6]
|
||||
const ROW_HEIGHT = 90
|
||||
const HEIGHT_PADDING = 0
|
||||
const ROW_UNIT = ROW_HEIGHT + GRID_MARGIN[1]
|
||||
const MAX_AUTO_ROWS = 6
|
||||
|
||||
export default function Dashboard() {
|
||||
const { config, addWidget, removeWidget, updateWidget, exportConfig, importConfig, saveConfig } = useDashboardConfig()
|
||||
const [isModalOpen, setIsModalOpen] = useState(false)
|
||||
const [editingWidget, setEditingWidget] = useState<string | null>(null)
|
||||
const [isSaving, setIsSaving] = useState(false)
|
||||
const [saveStatus, setSaveStatus] = useState<'idle' | 'success' | 'error'>('idle')
|
||||
const [gridWidth, setGridWidth] = useState(() => {
|
||||
if (typeof window !== 'undefined') {
|
||||
return window.innerWidth
|
||||
}
|
||||
return GRID_COLUMNS * (ROW_HEIGHT + GRID_MARGIN[0])
|
||||
})
|
||||
const gridContainerRef = useRef<HTMLDivElement>(null)
|
||||
|
||||
// Update grid width on resize
|
||||
useEffect(() => {
|
||||
const updateWidth = () => {
|
||||
if (gridContainerRef.current) {
|
||||
const rect = gridContainerRef.current.getBoundingClientRect()
|
||||
setGridWidth(rect.width)
|
||||
} else if (typeof window !== 'undefined') {
|
||||
setGridWidth(window.innerWidth)
|
||||
}
|
||||
}
|
||||
|
||||
updateWidth()
|
||||
window.addEventListener('resize', updateWidth)
|
||||
return () => window.removeEventListener('resize', updateWidth)
|
||||
}, [])
|
||||
|
||||
const handleLayoutChange = (newLayout: GridLayout.Layout[]) => {
|
||||
// Update widget positions when layout changes
|
||||
newLayout.forEach((item) => {
|
||||
const widget = config.widgets.find((w) => w.id === item.i)
|
||||
if (widget) {
|
||||
updateWidget(item.i, {
|
||||
position: {
|
||||
x: item.x,
|
||||
y: item.y,
|
||||
w: item.w,
|
||||
h: item.h,
|
||||
},
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
const layout = config.widgets.map((widget) => {
|
||||
const position = widget.position ?? { x: 0, y: Infinity, w: 1, h: 1 }
|
||||
return {
|
||||
i: widget.id,
|
||||
x: position.x ?? 0,
|
||||
y: position.y ?? Infinity,
|
||||
w: Math.max(position.w ?? 1, 1),
|
||||
h: Math.max(position.h ?? 1, 1),
|
||||
minW: 1,
|
||||
minH: 1,
|
||||
maxW: GRID_COLUMNS,
|
||||
}
|
||||
})
|
||||
|
||||
const handleWidgetHeightChange = useCallback(
|
||||
(widgetId: string, contentHeight: number) => {
|
||||
const widget = config.widgets.find((w) => w.id === widgetId)
|
||||
if (!widget) return
|
||||
|
||||
const position = widget.position ?? { x: 0, y: Infinity, w: 1, h: 1 }
|
||||
const currentRows = Math.max(position.h ?? 1, 1)
|
||||
const desiredPixelHeight = contentHeight + HEIGHT_PADDING
|
||||
const targetRows = Math.min(
|
||||
MAX_AUTO_ROWS,
|
||||
Math.max(1, Math.ceil(desiredPixelHeight / ROW_UNIT))
|
||||
)
|
||||
|
||||
if (Math.abs(targetRows - currentRows) >= 1) {
|
||||
updateWidget(widgetId, {
|
||||
position: {
|
||||
...position,
|
||||
h: targetRows,
|
||||
},
|
||||
})
|
||||
}
|
||||
},
|
||||
[config.widgets, updateWidget]
|
||||
)
|
||||
|
||||
const handleExport = () => {
|
||||
const json = exportConfig()
|
||||
const blob = new Blob([json], { type: 'application/json' })
|
||||
const url = URL.createObjectURL(blob)
|
||||
const a = document.createElement('a')
|
||||
a.href = url
|
||||
a.download = `dashboard-config-${new Date().toISOString().split('T')[0]}.json`
|
||||
a.click()
|
||||
URL.revokeObjectURL(url)
|
||||
}
|
||||
|
||||
const handleImport = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0]
|
||||
if (!file) return
|
||||
const reader = new FileReader()
|
||||
reader.onload = (event) => {
|
||||
try {
|
||||
const text = event.target?.result as string
|
||||
const parsed = JSON.parse(text)
|
||||
importConfig(parsed)
|
||||
alert('Dashboard configuration imported successfully!')
|
||||
} catch (error) {
|
||||
alert('Failed to import configuration')
|
||||
console.error(error)
|
||||
}
|
||||
}
|
||||
reader.readAsText(file)
|
||||
}
|
||||
|
||||
const handleSaveDashboard = async () => {
|
||||
setIsSaving(true)
|
||||
setSaveStatus('idle')
|
||||
try {
|
||||
const success = await saveConfig()
|
||||
setSaveStatus(success ? 'success' : 'error')
|
||||
} catch (error) {
|
||||
console.error('Failed to save dashboard configuration:', error)
|
||||
setSaveStatus('error')
|
||||
} finally {
|
||||
setIsSaving(false)
|
||||
setTimeout(() => setSaveStatus('idle'), 3000)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-6 space-y-6">
|
||||
<div className="flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold">Dashboard</h1>
|
||||
<p className="text-base-content/70">
|
||||
Customize your view with modular widgets
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<button
|
||||
className="btn btn-success btn-sm"
|
||||
onClick={handleSaveDashboard}
|
||||
disabled={isSaving}
|
||||
>
|
||||
{isSaving ? (
|
||||
<svg className="h-4 w-4 animate-spin" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
|
||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v4a4 4 0 00-4 4H4z" />
|
||||
</svg>
|
||||
) : (
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
)}
|
||||
Save Dashboard
|
||||
</button>
|
||||
{saveStatus === 'success' && (
|
||||
<span className="text-success text-sm">Saved!</span>
|
||||
)}
|
||||
{saveStatus === 'error' && (
|
||||
<span className="text-error text-sm">Save failed</span>
|
||||
)}
|
||||
<button
|
||||
className="btn btn-outline btn-sm"
|
||||
onClick={handleExport}
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4" />
|
||||
</svg>
|
||||
Export
|
||||
</button>
|
||||
<label className="btn btn-outline btn-sm">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-4 w-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-8l-4-4m0 0L8 8m4-4v12" />
|
||||
</svg>
|
||||
Import
|
||||
<input
|
||||
type="file"
|
||||
accept="application/json"
|
||||
className="hidden"
|
||||
onChange={handleImport}
|
||||
/>
|
||||
</label>
|
||||
<button
|
||||
className="btn btn-primary btn-sm"
|
||||
onClick={() => setIsModalOpen(true)}
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
|
||||
</svg>
|
||||
Add Widget
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{config.widgets.length === 0 ? (
|
||||
<div className="card bg-base-200 shadow-lg">
|
||||
<div className="card-body items-center text-center py-16">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-24 w-24 text-base-content/20" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 19v-6a2 2 0 00-2-2H5a2 2 0 00-2 2v6a2 2 0 002 2h2a2 2 0 002-2zm0 0V9a2 2 0 012-2h2a2 2 0 012 2v10m-6 0a2 2 0 002 2h2a2 2 0 002-2m0 0V5a2 2 0 012-2h2a2 2 0 012 2v14a2 2 0 01-2 2h-2a2 2 0 01-2-2z" />
|
||||
</svg>
|
||||
<h2 className="text-2xl font-bold mt-4">No Widgets Yet</h2>
|
||||
<p className="text-base-content/60 max-w-md">
|
||||
Get started by adding your first widget. Choose from line charts, stat cards, gauges, or AI insights.
|
||||
</p>
|
||||
<button
|
||||
className="btn btn-primary mt-6"
|
||||
onClick={() => setIsModalOpen(true)}
|
||||
>
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
|
||||
</svg>
|
||||
Add Your First Widget
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="w-full" ref={gridContainerRef}>
|
||||
<GridLayout
|
||||
className="layout"
|
||||
layout={layout}
|
||||
cols={GRID_COLUMNS}
|
||||
rowHeight={ROW_HEIGHT}
|
||||
width={gridWidth}
|
||||
onLayoutChange={handleLayoutChange}
|
||||
draggableHandle=".drag-handle"
|
||||
compactType="vertical"
|
||||
preventCollision={false}
|
||||
isResizable={true}
|
||||
isDraggable={true}
|
||||
margin={GRID_MARGIN}
|
||||
containerPadding={[0, 0]}
|
||||
>
|
||||
{config.widgets.map((widget) => (
|
||||
<div key={widget.id} className="h-full">
|
||||
<WidgetContainer
|
||||
config={widget}
|
||||
onRemove={() => removeWidget(widget.id)}
|
||||
onEdit={() => setEditingWidget(widget.id)}
|
||||
onHeightChange={(height: number) => handleWidgetHeightChange(widget.id, height)}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</GridLayout>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<AddWidgetModal
|
||||
isOpen={isModalOpen}
|
||||
onClose={() => setIsModalOpen(false)}
|
||||
onAdd={(widget) => {
|
||||
addWidget(widget)
|
||||
setIsModalOpen(false)
|
||||
}}
|
||||
/>
|
||||
|
||||
<EditWidgetModal
|
||||
isOpen={editingWidget !== null}
|
||||
widget={config.widgets.find((w) => w.id === editingWidget) || null}
|
||||
onClose={() => setEditingWidget(null)}
|
||||
onSave={(widgetId, updates) => {
|
||||
updateWidget(widgetId, updates)
|
||||
setEditingWidget(null)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
187
frontend/src/pages/DeviceDetail.tsx
Normal file
187
frontend/src/pages/DeviceDetail.tsx
Normal file
@@ -0,0 +1,187 @@
|
||||
import { useParams, Link, useNavigate } from 'react-router-dom'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { useState } from 'react'
|
||||
import { devicesApi } from '../api'
|
||||
import DeleteDeviceDialog from '../components/DeleteDeviceDialog'
|
||||
import RenewDialog from '../components/RenewDialog'
|
||||
import RevokeDialog from '../components/RevokeDialog'
|
||||
|
||||
export default function DeviceDetail() {
|
||||
const { id } = useParams<{ id: string }>()
|
||||
const navigate = useNavigate()
|
||||
const [deleteOpen, setDeleteOpen] = useState(false)
|
||||
const [renewOpen, setRenewOpen] = useState(false)
|
||||
const [revokeOpen, setRevokeOpen] = useState(false)
|
||||
|
||||
const { data: device, isLoading } = useQuery({
|
||||
queryKey: ['device', id],
|
||||
queryFn: async () => {
|
||||
const response = await devicesApi.getOne(id!)
|
||||
return response.data
|
||||
},
|
||||
enabled: !!id,
|
||||
})
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-screen">
|
||||
<span className="loading loading-spinner loading-lg"></span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
if (!device) {
|
||||
return (
|
||||
<div className="p-6">
|
||||
<div className="alert alert-error">
|
||||
<span>Device not found</span>
|
||||
</div>
|
||||
<Link to="/devices" className="btn btn-ghost mt-4">
|
||||
Back to Device List
|
||||
</Link>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-6">
|
||||
<div className="mb-6">
|
||||
<Link to="/devices" className="btn btn-ghost btn-sm mb-4">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5 mr-2" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 19l-7-7m0 0l7-7m-7 7h18" />
|
||||
</svg>
|
||||
Back to Devices
|
||||
</Link>
|
||||
<h1 className="text-3xl font-bold">Device Details</h1>
|
||||
</div>
|
||||
|
||||
<div className="card bg-base-100 shadow-xl">
|
||||
<div className="card-body">
|
||||
<h2 className="card-title text-2xl mb-4">{device.name}</h2>
|
||||
|
||||
<div className="overflow-x-auto">
|
||||
<table className="table">
|
||||
<tbody>
|
||||
<tr>
|
||||
<th className="w-1/3">Device ID:</th>
|
||||
<td><code className="bg-base-200 px-3 py-1 rounded">{device.id}</code></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>Location:</th>
|
||||
<td>{device.location || '—'}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>Protocol:</th>
|
||||
<td>
|
||||
<div className="badge badge-info">{device.protocol.toUpperCase()}</div>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>Status:</th>
|
||||
<td>
|
||||
<div className={`badge ${device.is_active ? 'badge-success' : 'badge-ghost'}`}>
|
||||
{device.is_active ? 'Active' : 'Inactive'}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>Created:</th>
|
||||
<td>{new Date(device.created_at).toLocaleString()}</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* Certificate Information for MQTT devices */}
|
||||
{device.protocol === 'mqtt' && device.active_certificate && (
|
||||
<div className="mt-6">
|
||||
<h3 className="text-xl font-bold mb-4">Certificate Information</h3>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="table">
|
||||
<tbody>
|
||||
<tr>
|
||||
<th className="w-1/3">Certificate ID:</th>
|
||||
<td><code className="bg-base-200 px-3 py-1 rounded">{device.active_certificate.id}</code></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>Issued At:</th>
|
||||
<td>{new Date(device.active_certificate.issued_at).toLocaleString()}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>Expires At:</th>
|
||||
<td>{new Date(device.active_certificate.expires_at).toLocaleString()}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>Days Until Expiry:</th>
|
||||
<td>
|
||||
<span className={`font-semibold ${
|
||||
device.active_certificate.days_until_expiry < 30 ? 'text-warning' :
|
||||
device.active_certificate.days_until_expiry < 7 ? 'text-error' :
|
||||
'text-success'
|
||||
}`}>
|
||||
{device.active_certificate.days_until_expiry} days
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th>Status:</th>
|
||||
<td>
|
||||
{device.active_certificate.revoked_at ? (
|
||||
<div className="badge badge-error">Revoked</div>
|
||||
) : device.active_certificate.is_expired ? (
|
||||
<div className="badge badge-error">Expired</div>
|
||||
) : device.active_certificate.is_expiring_soon ? (
|
||||
<div className="badge badge-warning">Expiring Soon</div>
|
||||
) : (
|
||||
<div className="badge badge-success">Active</div>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Action Buttons */}
|
||||
<div className="card-actions justify-end mt-6">
|
||||
{device.protocol === 'mqtt' && (
|
||||
<>
|
||||
<button className="btn btn-outline btn-warning" onClick={() => setRenewOpen(true)}>
|
||||
Renew Certificate
|
||||
</button>
|
||||
<button className="btn btn-outline btn-error" onClick={() => setRevokeOpen(true)}>
|
||||
Revoke Certificate
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
<button className="btn btn-error" onClick={() => setDeleteOpen(true)}>
|
||||
Delete Device
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DeleteDeviceDialog
|
||||
device={device}
|
||||
open={deleteOpen}
|
||||
onOpenChange={(open) => setDeleteOpen(open)}
|
||||
onDeleted={() => navigate('/devices')}
|
||||
/>
|
||||
{device.protocol === 'mqtt' && (
|
||||
<>
|
||||
<RenewDialog
|
||||
device={device}
|
||||
open={renewOpen}
|
||||
onOpenChange={(open) => setRenewOpen(open)}
|
||||
/>
|
||||
<RevokeDialog
|
||||
device={device}
|
||||
open={revokeOpen}
|
||||
onOpenChange={(open) => setRevokeOpen(open)}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
280
frontend/src/pages/DeviceList.tsx
Normal file
280
frontend/src/pages/DeviceList.tsx
Normal file
@@ -0,0 +1,280 @@
|
||||
import { Link } from 'react-router-dom'
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { useState } from 'react'
|
||||
import { dashboardApi, devicesApi } from '../api'
|
||||
import type { DashboardOverview, Device } from '../types/api'
|
||||
import DeleteDeviceDialog from '../components/DeleteDeviceDialog'
|
||||
import RevokeDialog from '../components/RevokeDialog'
|
||||
import RenewDialog from '../components/RenewDialog'
|
||||
|
||||
export default function DeviceList() {
|
||||
const [deleteDevice, setDeleteDevice] = useState<Device | null>(null)
|
||||
const [revokeDevice, setRevokeDevice] = useState<Device | null>(null)
|
||||
const [renewDevice, setRenewDevice] = useState<Device | null>(null)
|
||||
const { data: devicesData, isLoading: devicesLoading } = useQuery({
|
||||
queryKey: ['devices'],
|
||||
queryFn: async () => {
|
||||
const response = await devicesApi.getAll()
|
||||
return response.data
|
||||
},
|
||||
})
|
||||
|
||||
const { data: overview } = useQuery({
|
||||
queryKey: ['dashboard', 'overview'],
|
||||
queryFn: async (): Promise<DashboardOverview> => {
|
||||
const response = await dashboardApi.getOverview()
|
||||
return response.data
|
||||
},
|
||||
staleTime: 5000,
|
||||
})
|
||||
|
||||
const devices = devicesData?.results || []
|
||||
|
||||
if (devicesLoading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center min-h-screen">
|
||||
<span className="loading loading-spinner loading-lg"></span>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-6">
|
||||
<div className="flex justify-between items-center mb-6">
|
||||
<h1 className="text-3xl font-bold">Manage Devices</h1>
|
||||
<Link to="/devices/add" className="btn btn-primary">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-5 w-5 mr-2" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
|
||||
</svg>
|
||||
Add Device
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<section className="mb-8 space-y-4">
|
||||
<h2 className="text-xl font-semibold">System Health</h2>
|
||||
{overview ? (
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-4">
|
||||
<div className="stats shadow">
|
||||
<div className="stat">
|
||||
<div className="stat-figure text-primary">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
className="inline-block w-8 h-8 stroke-current"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth="2"
|
||||
d="M9 12l2 2 4-4m5.618-4.016A11.955 11.955 0 0112 2.944a11.955 11.955 0 01-8.618 3.04A12.02 12.02 0 003 9c0 5.591 3.824 10.29 9 11.622 5.176-1.332 9-6.03 9-11.622 0-1.042-.133-2.052-.382-3.016z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<div className="stat-title">Total Devices</div>
|
||||
<div className="stat-value text-primary">{overview.total_devices}</div>
|
||||
<div className="stat-desc">Registered in system</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="stats shadow">
|
||||
<div className="stat">
|
||||
<div className="stat-figure text-success">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
className="inline-block w-8 h-8 stroke-current"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth="2"
|
||||
d="M9 12l2 2 4-4m6 2a9 9 0 11-18 0 9 9 0 0118 0z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<div className="stat-title">Active Devices</div>
|
||||
<div className="stat-value text-success">{overview.active_devices}</div>
|
||||
<div className="stat-desc">Currently online</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="stats shadow">
|
||||
<div className="stat">
|
||||
<div className="stat-figure text-secondary">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
className="inline-block w-8 h-8 stroke-current"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth="2"
|
||||
d="M8.111 16.404a5.5 5.5 0 017.778 0M12 20h.01m-7.08-7.071c3.904-3.905 10.236-3.905 14.141 0M1.394 9.393c5.857-5.857 15.355-15.857 21.213 0"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<div className="stat-title">MQTT Devices</div>
|
||||
<div className="stat-value text-secondary">{overview.mqtt_devices}</div>
|
||||
<div className="stat-desc">Using mTLS</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="stats shadow">
|
||||
<div className="stat">
|
||||
<div className="stat-figure text-warning">
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
className="inline-block w-8 h-8 stroke-current"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth="2"
|
||||
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<div className="stat-title">Expiring Soon</div>
|
||||
<div className="stat-value text-warning">
|
||||
{overview.certificates_expiring_soon}
|
||||
</div>
|
||||
<div className="stat-desc">Certificates need renewal</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-4">
|
||||
{[1, 2, 3, 4].map((key) => (
|
||||
<div key={key} className="stats shadow animate-pulse">
|
||||
<div className="stat">
|
||||
<div className="stat-figure w-8 h-8 rounded-full bg-base-200"></div>
|
||||
<div className="stat-title bg-base-200 h-4 w-24 rounded"></div>
|
||||
<div className="stat-value bg-base-200 h-6 w-20 rounded mt-2"></div>
|
||||
<div className="stat-desc bg-base-200 h-4 w-28 rounded mt-2"></div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<div className="overflow-x-auto">
|
||||
<table className="table table-zebra w-full">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th>Location</th>
|
||||
<th>Protocol</th>
|
||||
<th>Certificate Status</th>
|
||||
<th>Certificate Expiry</th>
|
||||
<th>Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{devices && devices.length > 0 ? (
|
||||
devices.map((device: Device) => {
|
||||
const expiresAt = device.active_certificate?.expires_at ?? device.certificate_expires_at
|
||||
|
||||
return (
|
||||
<tr key={device.id} className="hover">
|
||||
<td className="font-semibold">{device.name}</td>
|
||||
<td>{device.location || '—'}</td>
|
||||
<td>
|
||||
<div className="badge badge-info">{device.protocol.toUpperCase()}</div>
|
||||
</td>
|
||||
<td>
|
||||
{device.protocol === 'mqtt' ? (
|
||||
<div className={`badge ${
|
||||
device.certificate_status === 'Valid' ? 'badge-success' :
|
||||
device.certificate_status === 'Expiring Soon' ? 'badge-warning' :
|
||||
'badge-error'
|
||||
}`}>
|
||||
{device.certificate_status || 'Unknown'}
|
||||
</div>
|
||||
) : (
|
||||
<span className="badge badge-ghost">N/A</span>
|
||||
)}
|
||||
</td>
|
||||
<td>{expiresAt ? new Date(expiresAt).toLocaleString() : '—'}</td>
|
||||
<td>
|
||||
<div className="flex gap-2">
|
||||
<Link to={`/devices/${device.id}`} className="btn btn-outline btn-info btn-xs">
|
||||
View
|
||||
</Link>
|
||||
<button
|
||||
className="btn btn-error btn-xs"
|
||||
onClick={() => setDeleteDevice(device)}
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
{device.protocol === 'mqtt' && (
|
||||
<>
|
||||
<button
|
||||
className="btn btn-outline btn-warning btn-xs"
|
||||
onClick={() => setRenewDevice(device)}
|
||||
>
|
||||
Renew
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-outline btn-error btn-xs"
|
||||
onClick={() => setRevokeDevice(device)}
|
||||
>
|
||||
Revoke
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
)
|
||||
})
|
||||
) : (
|
||||
<tr>
|
||||
<td colSpan={6} className="text-center py-8">
|
||||
<div className="flex flex-col items-center gap-4">
|
||||
<svg xmlns="http://www.w3.org/2000/svg" className="h-16 w-16 opacity-30" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
|
||||
</svg>
|
||||
<p className="text-lg opacity-60">No devices found.</p>
|
||||
<Link to="/devices/add" className="btn btn-primary btn-sm">
|
||||
Add Your First Device
|
||||
</Link>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* Dialogs */}
|
||||
{deleteDevice && (
|
||||
<DeleteDeviceDialog
|
||||
device={deleteDevice}
|
||||
open={!!deleteDevice}
|
||||
onOpenChange={(open) => !open && setDeleteDevice(null)}
|
||||
/>
|
||||
)}
|
||||
{revokeDevice && (
|
||||
<RevokeDialog
|
||||
device={revokeDevice}
|
||||
open={!!revokeDevice}
|
||||
onOpenChange={(open) => !open && setRevokeDevice(null)}
|
||||
/>
|
||||
)}
|
||||
{renewDevice && (
|
||||
<RenewDialog
|
||||
device={renewDevice}
|
||||
open={!!renewDevice}
|
||||
onOpenChange={(open) => !open && setRenewDevice(null)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
66
frontend/src/types/api.ts
Normal file
66
frontend/src/types/api.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
export interface Device {
|
||||
id: string;
|
||||
name: string;
|
||||
location?: string;
|
||||
protocol: 'mqtt' | 'http' | 'webhook';
|
||||
connection_config?: Record<string, any>;
|
||||
is_active: boolean;
|
||||
created_at: string;
|
||||
certificate_status?: string;
|
||||
certificate_expires_at?: string;
|
||||
active_certificate?: DeviceCertificate;
|
||||
}
|
||||
|
||||
export interface DeviceCertificate {
|
||||
id: string;
|
||||
device_id: string;
|
||||
issued_at: string;
|
||||
expires_at: string;
|
||||
revoked_at?: string;
|
||||
is_revoked: boolean;
|
||||
is_expired: boolean;
|
||||
is_expiring_soon: boolean;
|
||||
is_valid: boolean;
|
||||
days_until_expiry: number;
|
||||
}
|
||||
|
||||
export interface Telemetry {
|
||||
time: string;
|
||||
device_id: string;
|
||||
device_name: string;
|
||||
metric: string;
|
||||
value: number;
|
||||
unit?: string;
|
||||
}
|
||||
|
||||
export interface DeviceRegistrationRequest {
|
||||
name: string;
|
||||
location?: string;
|
||||
protocol?: 'mqtt' | 'http' | 'webhook';
|
||||
connection_config?: Record<string, any>;
|
||||
}
|
||||
|
||||
export interface DeviceRegistrationResponse {
|
||||
device_id: string;
|
||||
protocol: string;
|
||||
certificate_id?: string;
|
||||
ca_certificate_pem?: string;
|
||||
certificate_pem?: string;
|
||||
private_key_pem?: string;
|
||||
expires_at?: string;
|
||||
onboarding_token?: string; // One-time token for secure onboarding (valid 15 min)
|
||||
}
|
||||
|
||||
export interface DashboardOverview {
|
||||
total_devices: number;
|
||||
active_devices: number;
|
||||
mqtt_devices: number;
|
||||
http_devices: number;
|
||||
certificates_expiring_soon: number;
|
||||
recent_telemetry: Telemetry[];
|
||||
devices_with_metrics: {
|
||||
device_id: string;
|
||||
device_name: string;
|
||||
metrics: string[];
|
||||
}[];
|
||||
}
|
||||
64
frontend/src/utils/formatters.ts
Normal file
64
frontend/src/utils/formatters.ts
Normal file
@@ -0,0 +1,64 @@
|
||||
/**
|
||||
* Format metric names for display
|
||||
*/
|
||||
export function formatMetricName(metric: string): string {
|
||||
// Known metric mappings
|
||||
const metricNames: Record<string, string> = {
|
||||
'temperature': 'Temperature',
|
||||
'humidity': 'Humidity',
|
||||
'co2': 'CO₂',
|
||||
'pressure': 'Pressure',
|
||||
'light': 'Light Level',
|
||||
'noise': 'Noise Level',
|
||||
'pm25': 'PM2.5',
|
||||
'voc': 'VOC',
|
||||
}
|
||||
|
||||
// Return mapped name or capitalize the metric
|
||||
return metricNames[metric.toLowerCase()] ||
|
||||
metric.replace(/_/g, ' ')
|
||||
.split(' ')
|
||||
.map(word => word.charAt(0).toUpperCase() + word.slice(1))
|
||||
.join(' ')
|
||||
}
|
||||
|
||||
/**
|
||||
* Format device name for display
|
||||
*/
|
||||
export function formatDeviceName(deviceName: string): string {
|
||||
return deviceName
|
||||
.replace(/_/g, ' ')
|
||||
.replace(/-/g, ' ')
|
||||
.split(' ')
|
||||
.map(word => word.charAt(0).toUpperCase() + word.slice(1))
|
||||
.join(' ')
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a default widget title based on type, device, and metrics
|
||||
*/
|
||||
export function createDefaultWidgetTitle(
|
||||
type: string,
|
||||
deviceName: string | undefined,
|
||||
metrics: string[]
|
||||
): string {
|
||||
const formattedMetrics = metrics.map(formatMetricName).join(' & ')
|
||||
|
||||
switch (type) {
|
||||
case 'line-chart':
|
||||
if (metrics.length > 1) {
|
||||
return `${formattedMetrics}`
|
||||
}
|
||||
return `${formattedMetrics}`
|
||||
case 'stat':
|
||||
return `${formattedMetrics}`
|
||||
case 'gauge':
|
||||
return `${formattedMetrics}`
|
||||
case 'ai-insight':
|
||||
return deviceName
|
||||
? `AI Insights - ${formatDeviceName(deviceName)}`
|
||||
: 'AI Insights'
|
||||
default:
|
||||
return formattedMetrics
|
||||
}
|
||||
}
|
||||
28
frontend/tsconfig.app.json
Normal file
28
frontend/tsconfig.app.json
Normal file
@@ -0,0 +1,28 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
|
||||
"target": "ES2022",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||
"module": "ESNext",
|
||||
"types": ["vite/client"],
|
||||
"skipLibCheck": true,
|
||||
|
||||
/* Bundler mode */
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx",
|
||||
|
||||
/* Linting */
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"erasableSyntaxOnly": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"noUncheckedSideEffectImports": true
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
7
frontend/tsconfig.json
Normal file
7
frontend/tsconfig.json
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"files": [],
|
||||
"references": [
|
||||
{ "path": "./tsconfig.app.json" },
|
||||
{ "path": "./tsconfig.node.json" }
|
||||
]
|
||||
}
|
||||
26
frontend/tsconfig.node.json
Normal file
26
frontend/tsconfig.node.json
Normal file
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
|
||||
"target": "ES2023",
|
||||
"lib": ["ES2023"],
|
||||
"module": "ESNext",
|
||||
"types": ["node"],
|
||||
"skipLibCheck": true,
|
||||
|
||||
/* Bundler mode */
|
||||
"moduleResolution": "bundler",
|
||||
"allowImportingTsExtensions": true,
|
||||
"verbatimModuleSyntax": true,
|
||||
"moduleDetection": "force",
|
||||
"noEmit": true,
|
||||
|
||||
/* Linting */
|
||||
"strict": true,
|
||||
"noUnusedLocals": true,
|
||||
"noUnusedParameters": true,
|
||||
"erasableSyntaxOnly": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"noUncheckedSideEffectImports": true
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
23
frontend/vite.config.ts
Normal file
23
frontend/vite.config.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import { defineConfig } from 'vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
import tailwindcss from '@tailwindcss/vite'
|
||||
import path from 'path'
|
||||
|
||||
// https://vite.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [react(), tailwindcss()],
|
||||
resolve: {
|
||||
alias: {
|
||||
'@': path.resolve(__dirname, './src'),
|
||||
},
|
||||
},
|
||||
server: {
|
||||
port: 5173,
|
||||
proxy: {
|
||||
'/api': {
|
||||
target: 'http://localhost:3000',
|
||||
changeOrigin: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
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)
|
||||
2
infrastructure/.gitignore
vendored
2
infrastructure/.gitignore
vendored
@@ -1,3 +1,5 @@
|
||||
mosquitto/certs/
|
||||
mosquitto/data/
|
||||
mosquitto/logs/
|
||||
.env
|
||||
.env*
|
||||
@@ -8,7 +8,7 @@ services:
|
||||
- redis-data:/data
|
||||
restart: unless-stopped
|
||||
|
||||
mqtt:
|
||||
mosquitto:
|
||||
image: eclipse-mosquitto:2.0
|
||||
ports:
|
||||
- "1883:1883"
|
||||
@@ -30,6 +30,39 @@ services:
|
||||
- timescaledb-data:/var/lib/postgresql/data
|
||||
restart: unless-stopped
|
||||
|
||||
db-write:
|
||||
build:
|
||||
context: ../services/db_write
|
||||
env_file:
|
||||
- .env.dbw
|
||||
depends_on:
|
||||
- timescaledb
|
||||
- redis
|
||||
- mqtt-ingestion
|
||||
restart:
|
||||
unless-stopped
|
||||
|
||||
mqtt-ingestion:
|
||||
build:
|
||||
context: ../services/mqtt_ingestion
|
||||
env_file:
|
||||
- .env.mqi
|
||||
depends_on:
|
||||
- mosquitto
|
||||
- redis
|
||||
restart:
|
||||
unless-stopped
|
||||
|
||||
# gpt-service:
|
||||
# build:
|
||||
# context: ./services/gpt_service
|
||||
# ports:
|
||||
# - "8001:8001"
|
||||
# restart: unless-stopped
|
||||
|
||||
|
||||
|
||||
|
||||
volumes:
|
||||
redis-data:
|
||||
timescaledb-data:
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
PASSWORD='postgres'
|
||||
SECRET_KEY='django_key'
|
||||
CONNECTION_STRING='postgres://postgres:postgres@localhost:5555/example'
|
||||
REDIS_HOST="localhost"
|
||||
MQTT_BROKER="localhost"
|
||||
OPENAI_API_KEY="example"
|
||||
|
||||
WEATHER_API_KEY=your_weather_api_key_here
|
||||
DEVICE_MANAGER_URL=http://localhost:8000
|
||||
|
||||
|
||||
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)),
|
||||
]
|
||||
1141
iotDashboard/api/views.py
Normal file
1141
iotDashboard/api/views.py
Normal file
File diff suppressed because it is too large
Load Diff
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)
|
||||
65
iotDashboard/dashboard_models.py
Normal file
65
iotDashboard/dashboard_models.py
Normal file
@@ -0,0 +1,65 @@
|
||||
"""
|
||||
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
|
||||
from django.utils import timezone
|
||||
|
||||
|
||||
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
|
||||
private_key_pem: Optional[str] = None
|
||||
expires_at: Optional[datetime] = None
|
||||
onboarding_token: Optional[str] = None # One-time token for secure onboarding
|
||||
credential_id: Optional[str] = None
|
||||
api_key: Optional[str] = None
|
||||
webhook_secret: Optional[str] = None
|
||||
@@ -92,6 +93,7 @@ class DeviceManagerClient:
|
||||
certificate_pem=data.get("certificate_pem"),
|
||||
private_key_pem=data.get("private_key_pem"),
|
||||
expires_at=datetime.fromisoformat(data["expires_at"].replace("Z", "+00:00")) if data.get("expires_at") else None,
|
||||
onboarding_token=data.get("onboarding_token"),
|
||||
credential_id=data.get("credential_id"),
|
||||
api_key=data.get("api_key"),
|
||||
webhook_secret=data.get("webhook_secret"),
|
||||
@@ -157,6 +159,26 @@ class DeviceManagerClient:
|
||||
response = self._request("GET", "/crl")
|
||||
return response.text
|
||||
|
||||
def get_device_credentials(self, device_id: str, token: str) -> DeviceRegistrationResponse:
|
||||
"""Fetch device credentials using one-time onboarding token."""
|
||||
response = self._request("GET", f"/devices/{device_id}/credentials", params={"token": token})
|
||||
data = response.json()
|
||||
|
||||
# DeviceCertificateResponse from FastAPI doesn't include protocol
|
||||
# We'll use "mqtt" as default since credentials endpoint is only for MQTT devices
|
||||
return DeviceRegistrationResponse(
|
||||
device_id=data["device_id"],
|
||||
protocol="mqtt", # Credentials endpoint is only for MQTT devices
|
||||
certificate_id=data.get("certificate_id"),
|
||||
ca_certificate_pem=data.get("ca_certificate_pem"),
|
||||
certificate_pem=data.get("certificate_pem"),
|
||||
private_key_pem=data.get("private_key_pem"),
|
||||
expires_at=datetime.fromisoformat(data["expires_at"].replace("Z", "+00:00")) if data.get("expires_at") else None,
|
||||
credential_id=None,
|
||||
api_key=None,
|
||||
webhook_secret=None,
|
||||
)
|
||||
|
||||
def health_check(self) -> bool:
|
||||
try:
|
||||
response = self.session.get(f"{self.base_url}/docs", timeout=2)
|
||||
|
||||
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, timedelta
|
||||
|
||||
|
||||
@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,
|
||||
)
|
||||
|
||||
|
||||
|
||||
|
||||
@@ -10,73 +10,8 @@ class Migration(migrations.Migration):
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name="SensorType",
|
||||
fields=[
|
||||
(
|
||||
"id",
|
||||
models.BigAutoField(
|
||||
auto_created=True,
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
verbose_name="ID",
|
||||
),
|
||||
),
|
||||
("name", models.CharField(max_length=50, unique=True)),
|
||||
("unit", models.CharField(max_length=20)),
|
||||
(
|
||||
"protocol",
|
||||
models.CharField(
|
||||
choices=[("mqtt", "MQTT"), ("http", "HTTP")], max_length=20
|
||||
),
|
||||
),
|
||||
("topic", models.CharField(blank=True, max_length=100, null=True)),
|
||||
("endpoint", models.CharField(blank=True, max_length=100, null=True)),
|
||||
],
|
||||
),
|
||||
migrations.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",
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
# Note: Device model is now managed=False, so we don't modify it in migrations
|
||||
# SensorType and Sensor models are also not in current models.py
|
||||
# This migration is kept for historical reference but operations are removed
|
||||
# to prevent errors with managed=False models
|
||||
]
|
||||
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):
|
||||
"""Time-series telemetry data from devices."""
|
||||
"""Time-series telemetry data from devices (TimescaleDB hypertable).
|
||||
|
||||
time = models.DateTimeField()
|
||||
device = models.ForeignKey(
|
||||
Device, on_delete=models.CASCADE, related_name="telemetry", db_column="device_id"
|
||||
)
|
||||
Note: This table has a composite primary key (time, device_id, metric).
|
||||
Since Django doesn't support composite PKs well, we mark time as the PK
|
||||
but queries should filter by (time, device_id, metric) together.
|
||||
"""
|
||||
|
||||
time = models.DateTimeField(primary_key=True)
|
||||
device_id = models.CharField(max_length=255, db_column="device_id")
|
||||
metric = models.CharField(max_length=255)
|
||||
value = models.FloatField()
|
||||
unit = models.CharField(max_length=50, null=True, blank=True)
|
||||
@@ -165,10 +168,9 @@ class Telemetry(models.Model):
|
||||
class Meta:
|
||||
managed = False
|
||||
db_table = "telemetry"
|
||||
unique_together = [["time", "device", "metric"]]
|
||||
indexes = [
|
||||
models.Index(fields=["device", "time"]),
|
||||
]
|
||||
# Note: The actual database has composite PK (time, device_id, metric)
|
||||
# Django limitation: can only mark one field as PK
|
||||
unique_together = [["time", "device_id", "metric"]]
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.device.name} - {self.metric}: {self.value} at {self.time}"
|
||||
|
||||
333
iotDashboard/run_suitability.py
Normal file
333
iotDashboard/run_suitability.py
Normal file
@@ -0,0 +1,333 @@
|
||||
"""
|
||||
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 Dict, 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 optimal range
|
||||
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}")
|
||||
else:
|
||||
# Calculate hours until next optimal time
|
||||
if current_hour < start_time.hour:
|
||||
hours_until = start_time.hour - current_hour
|
||||
recommendations.append(f"Best time in {hours_until} hours ({start_time.strftime('%I:%M %p')}): {description}")
|
||||
elif current_hour >= end_time.hour:
|
||||
# Next optimal time is tomorrow
|
||||
next_start = RunSuitabilityCalculator.BEST_TIMES[0][0] if start_time == RunSuitabilityCalculator.BEST_TIMES[-1][0] else RunSuitabilityCalculator.BEST_TIMES[1][0]
|
||||
hours_until = (24 - current_hour) + next_start.hour
|
||||
recommendations.append(f"Best time in {hours_until} hours ({next_start.strftime('%I:%M %p')}): {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_PASSWORD = os.getenv("POSTGRES_PASSWORD")
|
||||
|
||||
GPT_SERVICE_URL = os.getenv("GPT_SERVICE_URL", "http://localhost:8001")
|
||||
|
||||
# SECURITY WARNING: don't run with debug turned on in production!
|
||||
DEBUG = True
|
||||
|
||||
ALLOWED_HOSTS = []
|
||||
|
||||
ALLOWED_HOSTS = ['*']
|
||||
|
||||
# Application definition
|
||||
|
||||
@@ -48,11 +49,14 @@ INSTALLED_APPS = [
|
||||
"django.contrib.sessions",
|
||||
"django.contrib.messages",
|
||||
"django.contrib.staticfiles",
|
||||
"rest_framework",
|
||||
"corsheaders",
|
||||
"iotDashboard",
|
||||
]
|
||||
|
||||
MIDDLEWARE = [
|
||||
"django.middleware.security.SecurityMiddleware",
|
||||
"corsheaders.middleware.CorsMiddleware",
|
||||
"django.contrib.sessions.middleware.SessionMiddleware",
|
||||
"django.middleware.common.CommonMiddleware",
|
||||
"django.middleware.csrf.CsrfViewMiddleware",
|
||||
@@ -139,3 +143,29 @@ STATIC_URL = "static/"
|
||||
|
||||
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.authentication.SessionAuthentication', # For admin only
|
||||
],
|
||||
'DEFAULT_PERMISSION_CLASSES': [
|
||||
'rest_framework.permissions.AllowAny', # Single-user system, no auth required
|
||||
],
|
||||
}
|
||||
|
||||
# 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",
|
||||
]
|
||||
|
||||
CORS_ALLOW_CREDENTIALS = True
|
||||
|
||||
@@ -16,12 +16,15 @@ Including another URLconf
|
||||
"""
|
||||
|
||||
from django.contrib import admin
|
||||
from django.urls import path
|
||||
from django.urls import path, include
|
||||
from iotDashboard import views
|
||||
|
||||
urlpatterns = [
|
||||
path("admin/", admin.site.urls),
|
||||
|
||||
# REST API
|
||||
path("api/", include("iotDashboard.api.urls")),
|
||||
|
||||
# Main dashboard
|
||||
path("", views.chart, name="index"),
|
||||
path("chart/", views.chart, name="chart"),
|
||||
|
||||
@@ -261,3 +261,7 @@ def devices_api(request):
|
||||
"""JSON API endpoint for devices."""
|
||||
devices = list(Device.objects.all().values("id", "name", "protocol", "location"))
|
||||
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,6 +7,10 @@ requires-python = ">=3.13"
|
||||
dependencies = [
|
||||
"alembic>=1.17.0",
|
||||
"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",
|
||||
"paho-mqtt>=2.1.0",
|
||||
"psycopg2-binary>=2.9.11",
|
||||
|
||||
@@ -20,6 +20,8 @@ WORKDIR /app
|
||||
|
||||
COPY --from=builder /app/.venv /app/.venv
|
||||
|
||||
COPY --from=builder /app/src/ /app/src/
|
||||
|
||||
COPY --from=builder /app/*.py /app/
|
||||
|
||||
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.
|
||||
|
||||
## 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
|
||||
|
||||
Part of the IoT Dashboard project.
|
||||
|
||||
@@ -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 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.database import get_db_context
|
||||
from app.db_models import Device, DeviceCertificate
|
||||
from app.db_models import Device, DeviceCertificate, DeviceOnboardingToken
|
||||
from app.models import (
|
||||
DeviceCertificateResponse,
|
||||
DeviceRegistrationRequest,
|
||||
DeviceRegistrationResponse,
|
||||
DeviceResponse,
|
||||
@@ -61,6 +64,19 @@ async def register_device(
|
||||
)
|
||||
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(
|
||||
device_id=cert_response.device_id,
|
||||
protocol=request.protocol,
|
||||
@@ -69,6 +85,7 @@ async def register_device(
|
||||
certificate_pem=cert_response.certificate_pem,
|
||||
private_key_pem=cert_response.private_key_pem,
|
||||
expires_at=cert_response.expires_at,
|
||||
onboarding_token=onboarding_token,
|
||||
)
|
||||
|
||||
else:
|
||||
@@ -99,6 +116,63 @@ async def get_ca_certificate() -> str:
|
||||
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}")
|
||||
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)
|
||||
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")
|
||||
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
|
||||
from cryptography import x509
|
||||
|
||||
new_cert = x509.load_pem_x509_certificate(new_cert_pem)
|
||||
new_cert_id = format(new_cert.serial_number, "x")
|
||||
|
||||
|
||||
@@ -74,3 +74,26 @@ class DeviceCredential(Base):
|
||||
|
||||
def __repr__(self):
|
||||
return f"<DeviceCredential(id={self.id}, device_id={self.device_id}, type={self.credential_type})>"
|
||||
|
||||
|
||||
class DeviceOnboardingToken(Base):
|
||||
"""One-time tokens for secure device onboarding via QR code."""
|
||||
|
||||
__tablename__ = "device_onboarding_tokens"
|
||||
|
||||
token = Column(Text, primary_key=True)
|
||||
device_id = Column(
|
||||
Text, ForeignKey("devices.id", ondelete="CASCADE"), nullable=False
|
||||
)
|
||||
certificate_id = Column(Text, nullable=False)
|
||||
created_at = Column(DateTime(timezone=True), nullable=False)
|
||||
expires_at = Column(DateTime(timezone=True), nullable=False)
|
||||
used_at = Column(DateTime(timezone=True))
|
||||
|
||||
__table_args__ = (
|
||||
Index("idx_onboarding_tokens_device_id", "device_id"),
|
||||
Index("idx_onboarding_tokens_expires", "expires_at"),
|
||||
)
|
||||
|
||||
def __repr__(self):
|
||||
return f"<DeviceOnboardingToken(device_id={self.device_id}, used={self.used_at is not None})>"
|
||||
|
||||
@@ -19,6 +19,7 @@ class DeviceRegistrationResponse(BaseModel):
|
||||
certificate_pem: str | None = None
|
||||
private_key_pem: str | None = None
|
||||
expires_at: datetime.datetime | None = None
|
||||
onboarding_token: str | None = None
|
||||
credential_id: str | None = None
|
||||
api_key: str | None = None
|
||||
webhook_secret: str | None = None
|
||||
|
||||
7
services/gpt_service/.dockerignore
Normal file
7
services/gpt_service/.dockerignore
Normal file
@@ -0,0 +1,7 @@
|
||||
.env*
|
||||
.md
|
||||
__pycache__/
|
||||
*.pyc
|
||||
*.pyo
|
||||
*.pyd
|
||||
*.log
|
||||
@@ -1,6 +1,6 @@
|
||||
API_KEY=your_api_key_here
|
||||
PROVIDER_NAME=openai
|
||||
MODEL_NAME=gpt-4.1
|
||||
MODEL_NAME=gpt-4o-mini
|
||||
HOST_URL= http://localhost:8000
|
||||
LOG_LEVEL=INFO
|
||||
|
||||
|
||||
11
services/gpt_service/Dockerfile
Normal file
11
services/gpt_service/Dockerfile
Normal file
@@ -0,0 +1,11 @@
|
||||
FROM ghcr.io/astral-sh/uv:python3.13-alpine
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY pyproject.toml ./
|
||||
|
||||
RUN uv sync
|
||||
|
||||
COPY . .
|
||||
|
||||
CMD ["uv", "run", "uvicorn","main:app", "--host", "0.0.0.0" ,"--port", "8001"]
|
||||
136
services/gpt_service/METRICS_REFERENCE.md
Normal file
136
services/gpt_service/METRICS_REFERENCE.md
Normal file
@@ -0,0 +1,136 @@
|
||||
# Environmental Monitoring Standards Reference
|
||||
|
||||
This document provides the industry-standard optimal ranges used by the GPT service for environmental analysis.
|
||||
|
||||
## Supported Metrics
|
||||
|
||||
### Temperature (°C)
|
||||
- **Optimal Range**: 18-24°C
|
||||
- **Comfort Zone**: 20-22°C
|
||||
- **Critical Low**: <15°C
|
||||
- **Critical High**: >28°C
|
||||
- **Standards**: ASHRAE 55, ISO 7730
|
||||
- **Key Concerns**:
|
||||
- Worker comfort and productivity
|
||||
- Equipment operating conditions
|
||||
- Energy efficiency
|
||||
- HVAC system performance
|
||||
|
||||
### Humidity (%)
|
||||
- **Optimal Range**: 30-60%
|
||||
- **Comfort Zone**: 40-50%
|
||||
- **Critical Low**: <20%
|
||||
- **Critical High**: >70%
|
||||
- **Standards**: ASHRAE 55, WHO guidelines
|
||||
- **Key Concerns**:
|
||||
- Mold and mildew growth (>60%)
|
||||
- Static electricity and equipment damage (<30%)
|
||||
- Respiratory health and comfort
|
||||
- Material degradation
|
||||
|
||||
### CO2 (ppm)
|
||||
- **Optimal Range**: 400-1000ppm
|
||||
- **Comfort Zone**: 400-800ppm
|
||||
- **Critical Low**: <350ppm (unusual indoors)
|
||||
- **Critical High**: >1500ppm
|
||||
- **Standards**: ASHRAE 62.1, WHO Air Quality Guidelines
|
||||
- **Key Concerns**:
|
||||
- Air quality and ventilation effectiveness
|
||||
- Cognitive performance (>1000ppm affects decision-making)
|
||||
- Occupant health and alertness
|
||||
- HVAC system efficiency
|
||||
- **Impact**: Studies show 15% decline in cognitive function at 1400ppm
|
||||
|
||||
### Atmospheric Pressure (hPa)
|
||||
- **Optimal Range**: 1013-1023hPa
|
||||
- **Comfort Zone**: 1013-1020hPa
|
||||
- **Critical Low**: <980hPa
|
||||
- **Critical High**: >1050hPa
|
||||
- **Key Concerns**:
|
||||
- Weather changes and ventilation
|
||||
- Building pressurization
|
||||
- Equipment calibration
|
||||
- Occupant comfort
|
||||
|
||||
### Light / Illuminance (lux)
|
||||
- **Optimal Range**: 300-500 lux
|
||||
- **Comfort Zone**: 400-500 lux
|
||||
- **Critical Low**: <200 lux
|
||||
- **Critical High**: >1000 lux
|
||||
- **Standards**: EN 12464-1, IESNA recommendations
|
||||
- **Key Concerns**:
|
||||
- Visual comfort and eye strain
|
||||
- Productivity and task performance
|
||||
- Energy consumption
|
||||
- Circadian rhythm regulation
|
||||
- **Note**: Higher levels (750-1000 lux) for detailed work
|
||||
|
||||
### Noise (dB)
|
||||
- **Optimal Range**: 30-50dB
|
||||
- **Comfort Zone**: 35-45dB
|
||||
- **Critical Low**: <20dB (unusual indoors)
|
||||
- **Critical High**: >70dB
|
||||
- **Standards**: WHO Noise Guidelines, OSHA
|
||||
- **Key Concerns**:
|
||||
- Acoustic comfort and concentration
|
||||
- Speech intelligibility
|
||||
- Stress and productivity impact
|
||||
- Hearing protection requirements (>85dB)
|
||||
- **Impact**: 40-45dB ideal for office work, <35dB for focused tasks
|
||||
|
||||
### PM2.5 - Fine Particulate Matter (µg/m³)
|
||||
- **Optimal Range**: 0-12 µg/m³
|
||||
- **Comfort Zone**: 0-10 µg/m³
|
||||
- **Critical Low**: 0 µg/m³ (best)
|
||||
- **Critical High**: >35 µg/m³
|
||||
- **Standards**: EPA Air Quality Index, WHO guidelines
|
||||
- **Key Concerns**:
|
||||
- Air quality and health risk
|
||||
- Respiratory system impact
|
||||
- Filter maintenance requirements
|
||||
- Outdoor air quality correlation
|
||||
- **Impact**: >35 µg/m³ = Unhealthy for sensitive groups
|
||||
|
||||
### VOC - Volatile Organic Compounds (ppb)
|
||||
- **Optimal Range**: 0-220ppb
|
||||
- **Comfort Zone**: 0-150ppb
|
||||
- **Critical Low**: 0ppb (best)
|
||||
- **Critical High**: >500ppb
|
||||
- **Standards**: Various indoor air quality standards
|
||||
- **Key Concerns**:
|
||||
- Indoor air quality
|
||||
- Off-gassing from materials
|
||||
- Ventilation effectiveness
|
||||
- Occupant health symptoms (headaches, irritation)
|
||||
|
||||
## Analysis Approach
|
||||
|
||||
The GPT service uses these standards to:
|
||||
|
||||
1. **Assess Current Conditions**: Compare measurements against optimal ranges
|
||||
2. **Identify Issues**: Flag deviations with severity levels
|
||||
3. **Provide Context**: Explain health/productivity impacts
|
||||
4. **Recommend Actions**: Suggest specific interventions (HVAC, ventilation, etc.)
|
||||
5. **Predict Trends**: Forecast potential issues based on patterns
|
||||
|
||||
## Multi-Metric Correlation
|
||||
|
||||
When analyzing multiple metrics together, the service looks for:
|
||||
- **HVAC Performance**: Temperature + Humidity + CO2 trends
|
||||
- **Ventilation Effectiveness**: CO2 + PM2.5 + VOC levels
|
||||
- **Occupancy Impact**: CO2 rise + Temperature increase + Humidity changes
|
||||
- **Seasonal Patterns**: Pressure + Temperature + Humidity correlations
|
||||
|
||||
## References
|
||||
|
||||
- ASHRAE Standard 55: Thermal Environmental Conditions for Human Occupancy
|
||||
- ASHRAE Standard 62.1: Ventilation for Acceptable Indoor Air Quality
|
||||
- WHO Air Quality Guidelines
|
||||
- ISO 7730: Ergonomics of the thermal environment
|
||||
- EN 12464-1: Light and lighting of work places
|
||||
- EPA Air Quality Index
|
||||
- OSHA Occupational Noise Exposure Standards
|
||||
|
||||
## Usage in Prompts
|
||||
|
||||
The service automatically includes relevant standards in analysis prompts based on detected metrics. No manual configuration needed - just send your telemetry data!
|
||||
@@ -0,0 +1,240 @@
|
||||
# GPT Service
|
||||
|
||||
FastAPI microservice for AI-powered environmental telemetry analysis using OpenAI GPT models.
|
||||
|
||||
## Purpose
|
||||
|
||||
Provides intelligent analysis of IoT environmental sensor data to ensure optimal working conditions. The service specializes in monitoring indoor environmental quality metrics (temperature, humidity, CO2, etc.) with context-aware insights based on industry standards (ASHRAE, WHO, OSHA).
|
||||
|
||||
**Goal**: Smart IoT dashboard that maintains healthy, comfortable, and productive work environments through AI-driven insights.
|
||||
|
||||
## Architecture
|
||||
|
||||
- **Input**: Telemetry data + device context from Django
|
||||
- **Processing**: OpenAI GPT API with specialized prompts
|
||||
- **Output**: Actionable insights and analysis
|
||||
- **State**: Stateless - no database, pure text processing
|
||||
|
||||
## Setup
|
||||
|
||||
1. **Install dependencies**:
|
||||
```bash
|
||||
uv sync
|
||||
```
|
||||
|
||||
2. **Configure environment**:
|
||||
```bash
|
||||
cp .env.sample .env
|
||||
# Edit .env with your OpenAI API key
|
||||
```
|
||||
|
||||
3. **Run the service**:
|
||||
```bash
|
||||
uv run uvicorn main:app --reload --port 8001
|
||||
```
|
||||
|
||||
## API Endpoints
|
||||
|
||||
### POST /analyze
|
||||
|
||||
Analyze telemetry data with AI.
|
||||
|
||||
**Request**:
|
||||
```json
|
||||
{
|
||||
"telemetry_data": [
|
||||
{
|
||||
"device_id": "abc123",
|
||||
"metric": "temperature",
|
||||
"value": 23.5,
|
||||
"timestamp": "2024-01-15T10:30:00Z"
|
||||
}
|
||||
],
|
||||
"device_info": {
|
||||
"name": "Sensor-01",
|
||||
"location": "Lab A"
|
||||
},
|
||||
"prompt_type": "trend_summary",
|
||||
"custom_prompt": null
|
||||
}
|
||||
```
|
||||
|
||||
**Prompt Types**:
|
||||
- `trend_summary`: Environmental quality trends with comfort assessment
|
||||
- `anomaly_detection`: Identify deviations from optimal ranges with severity
|
||||
- `custom`: Use custom_prompt field
|
||||
|
||||
**Supported Metrics** (with industry-standard optimal ranges):
|
||||
- `temperature`: Indoor temperature (18-24°C optimal)
|
||||
- `humidity`: Relative humidity (30-60% optimal)
|
||||
- `co2`: Carbon dioxide concentration (400-1000ppm optimal)
|
||||
- `pressure`: Atmospheric pressure (1013-1023hPa optimal)
|
||||
- `light`: Illuminance level (300-500 lux optimal)
|
||||
- `noise`: Sound level (30-50dB optimal)
|
||||
- `pm25`: Fine particulate matter (0-12 µg/m³ optimal)
|
||||
- `voc`: Volatile organic compounds (0-220ppb optimal)
|
||||
|
||||
Each metric includes:
|
||||
- Optimal and comfort ranges
|
||||
- Critical thresholds
|
||||
- Health/productivity impact assessment
|
||||
- Specific concerns (e.g., mold growth for humidity, cognitive impact for CO2)
|
||||
|
||||
**Response**:
|
||||
```json
|
||||
{
|
||||
"analysis": "The temperature data shows...",
|
||||
"prompt_type": "trend_summary",
|
||||
"data_points_analyzed": 100
|
||||
}
|
||||
```
|
||||
|
||||
### GET /health
|
||||
|
||||
Health check endpoint.
|
||||
|
||||
**Response**:
|
||||
```json
|
||||
{
|
||||
"status": "healthy",
|
||||
"service": "gpt_service"
|
||||
}
|
||||
```
|
||||
|
||||
## Environment Variables
|
||||
|
||||
- `API_KEY`: OpenAI API key (required)
|
||||
- `PROVIDER_NAME`: AI provider (default: "openai")
|
||||
- `MODEL_NAME`: OpenAI model (default: "gpt-4o-mini")
|
||||
- `HOST_URL`: Service URL (default: "http://localhost:8001")
|
||||
- `LOG_LEVEL`: Logging level (default: "INFO")
|
||||
|
||||
## Integration with Django
|
||||
|
||||
Django backend should:
|
||||
1. Query telemetry from PostgreSQL/TimescaleDB
|
||||
2. Format data as array of `{device_id, metric, value, timestamp}`
|
||||
3. Add device context in `device_info`
|
||||
4. POST to `/analyze` endpoint
|
||||
5. Return analysis to frontend
|
||||
|
||||
Example Django integration:
|
||||
```python
|
||||
import httpx
|
||||
|
||||
async def get_telemetry_insights(device_id: str, metric: str = None):
|
||||
# Query telemetry
|
||||
telemetry = Telemetry.objects.filter(device_id=device_id)
|
||||
if metric:
|
||||
telemetry = telemetry.filter(metric=metric)
|
||||
|
||||
# Format data
|
||||
data = [
|
||||
{
|
||||
"device_id": t.device_id,
|
||||
"metric": t.metric,
|
||||
"value": t.value,
|
||||
"timestamp": t.timestamp.isoformat()
|
||||
}
|
||||
for t in telemetry[:100] # Limit to last 100 points
|
||||
]
|
||||
|
||||
# Get device info
|
||||
device = Device.objects.get(device_id=device_id)
|
||||
device_info = {
|
||||
"name": device.name,
|
||||
"location": device.location
|
||||
}
|
||||
|
||||
# Call GPT service
|
||||
async with httpx.AsyncClient() as client:
|
||||
response = await client.post(
|
||||
"http://localhost:8001/analyze",
|
||||
json={
|
||||
"telemetry_data": data,
|
||||
"device_info": device_info,
|
||||
"prompt_type": "trend_summary"
|
||||
}
|
||||
)
|
||||
return response.json()
|
||||
```
|
||||
|
||||
## Testing
|
||||
|
||||
```bash
|
||||
# Health check
|
||||
curl http://localhost:8001/health
|
||||
|
||||
# Test analysis
|
||||
curl -X POST http://localhost:8001/analyze \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{
|
||||
"telemetry_data": [
|
||||
{"device_id": "test", "metric": "temperature", "value": 23.5, "timestamp": "2024-01-15T10:30:00Z"}
|
||||
],
|
||||
"prompt_type": "trend_summary"
|
||||
}'
|
||||
```
|
||||
|
||||
## Development
|
||||
|
||||
- **Stateless design** - No database required
|
||||
- **Metric-aware** - Specialized prompts for 8 environmental metrics
|
||||
- **Standards-based** - Uses ASHRAE, WHO, OSHA guidelines
|
||||
- **Context-rich** - Includes optimal ranges, thresholds, and impact assessments
|
||||
- **Async/await** - Non-blocking OpenAI API calls
|
||||
- **Error handling** - Proper HTTP exceptions with logging
|
||||
- **Type safety** - Pydantic models and type hints throughout
|
||||
|
||||
## Analysis Features
|
||||
|
||||
### Metric-Specific Intelligence
|
||||
|
||||
The service automatically detects which metrics are in your telemetry data and provides specialized analysis:
|
||||
|
||||
**Temperature Analysis**:
|
||||
- Optimal range: 18-24°C (comfort zone: 20-22°C)
|
||||
- Assesses impact on worker productivity and equipment
|
||||
- Identifies HVAC performance issues
|
||||
- Recommends energy efficiency improvements
|
||||
|
||||
**Humidity Analysis**:
|
||||
- Optimal range: 30-60% (comfort zone: 40-50%)
|
||||
- Warns about mold risk (>60%) and static electricity (<30%)
|
||||
- Evaluates respiratory health impact
|
||||
- Suggests ventilation adjustments
|
||||
|
||||
**CO2 Analysis**:
|
||||
- Optimal range: 400-1000ppm (comfort zone: 400-800ppm)
|
||||
- Links high CO2 to cognitive performance decline
|
||||
- Assesses ventilation effectiveness
|
||||
- Recommends occupancy adjustments
|
||||
|
||||
**And more** for pressure, light, noise, PM2.5, and VOC metrics.
|
||||
|
||||
### Analysis Types
|
||||
|
||||
1. **Trend Summary** (`prompt_type: "trend_summary"`):
|
||||
- Overall environmental quality rating
|
||||
- Time spent in optimal vs suboptimal ranges
|
||||
- Daily patterns and correlations
|
||||
- Predictive insights and optimization opportunities
|
||||
|
||||
2. **Anomaly Detection** (`prompt_type: "anomaly_detection"`):
|
||||
- Identifies deviations from optimal ranges
|
||||
- Severity assessment (low/medium/high/critical)
|
||||
- Root cause analysis (HVAC, occupancy, external factors)
|
||||
- Prioritized action items
|
||||
|
||||
3. **Custom Analysis** (`prompt_type: "custom"`):
|
||||
- Use your own prompt
|
||||
- Still includes metric context and standards
|
||||
- Flexible for specific use cases
|
||||
|
||||
## Notes
|
||||
|
||||
- Service is stateless by design
|
||||
- Django provides all data context
|
||||
- No direct database access
|
||||
- Focuses on text transformation only
|
||||
- Aligns with microservices architecture pattern
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
from openai import OpenAI
|
||||
from typing import List, Dict, Any
|
||||
from datetime import datetime
|
||||
|
||||
from config import API_KEY, MODEL_NAME, PROVIDER_NAME, HOST_URL, LOG_LEVEL
|
||||
import logging
|
||||
@@ -20,4 +22,597 @@ class GPTService:
|
||||
self.logger.error(f"Unsupported provider: {self.provider_name}")
|
||||
raise ValueError(f"Unsupported provider: {self.provider_name}")
|
||||
|
||||
def _get_metric_specific_context(self, metric: str) -> Dict[str, Any]:
|
||||
"""Get metric-specific optimal ranges and context for environmental monitoring."""
|
||||
|
||||
metric_contexts = {
|
||||
"temperature": {
|
||||
"unit": "°C",
|
||||
"optimal_range": (18, 24),
|
||||
"comfort_range": (20, 22),
|
||||
"critical_low": 15,
|
||||
"critical_high": 28,
|
||||
"context": "indoor environment temperature",
|
||||
"concerns": [
|
||||
"Worker comfort and productivity",
|
||||
"Equipment operating conditions",
|
||||
"Energy efficiency",
|
||||
"HVAC system performance"
|
||||
]
|
||||
},
|
||||
"humidity": {
|
||||
"unit": "%",
|
||||
"optimal_range": (30, 60),
|
||||
"comfort_range": (40, 50),
|
||||
"critical_low": 20,
|
||||
"critical_high": 70,
|
||||
"context": "relative humidity",
|
||||
"concerns": [
|
||||
"Mold and mildew growth (>60%)",
|
||||
"Static electricity and equipment damage (<30%)",
|
||||
"Respiratory health and comfort",
|
||||
"Material degradation"
|
||||
]
|
||||
},
|
||||
"co2": {
|
||||
"unit": "ppm",
|
||||
"optimal_range": (400, 1000),
|
||||
"comfort_range": (400, 800),
|
||||
"critical_low": 350,
|
||||
"critical_high": 1500,
|
||||
"context": "carbon dioxide concentration",
|
||||
"concerns": [
|
||||
"Air quality and ventilation effectiveness",
|
||||
"Cognitive performance (>1000ppm affects decision-making)",
|
||||
"Occupant health and alertness",
|
||||
"HVAC system efficiency"
|
||||
]
|
||||
},
|
||||
"pressure": {
|
||||
"unit": "hPa",
|
||||
"optimal_range": (1013, 1023),
|
||||
"comfort_range": (1013, 1020),
|
||||
"critical_low": 980,
|
||||
"critical_high": 1050,
|
||||
"context": "atmospheric pressure",
|
||||
"concerns": [
|
||||
"Weather changes and ventilation",
|
||||
"Building pressurization",
|
||||
"Equipment calibration",
|
||||
"Occupant comfort"
|
||||
]
|
||||
},
|
||||
"light": {
|
||||
"unit": "lux",
|
||||
"optimal_range": (300, 500),
|
||||
"comfort_range": (400, 500),
|
||||
"critical_low": 200,
|
||||
"critical_high": 1000,
|
||||
"context": "illuminance level",
|
||||
"concerns": [
|
||||
"Visual comfort and eye strain",
|
||||
"Productivity and task performance",
|
||||
"Energy consumption",
|
||||
"Circadian rhythm regulation"
|
||||
]
|
||||
},
|
||||
"noise": {
|
||||
"unit": "dB",
|
||||
"optimal_range": (30, 50),
|
||||
"comfort_range": (35, 45),
|
||||
"critical_low": 20,
|
||||
"critical_high": 70,
|
||||
"context": "noise level",
|
||||
"concerns": [
|
||||
"Acoustic comfort and concentration",
|
||||
"Speech intelligibility",
|
||||
"Stress and productivity impact",
|
||||
"Hearing protection requirements (>85dB)"
|
||||
]
|
||||
},
|
||||
"pm25": {
|
||||
"unit": "µg/m³",
|
||||
"optimal_range": (0, 12),
|
||||
"comfort_range": (0, 10),
|
||||
"critical_low": 0,
|
||||
"critical_high": 35,
|
||||
"context": "fine particulate matter (PM2.5)",
|
||||
"concerns": [
|
||||
"Air quality and health risk",
|
||||
"Respiratory system impact",
|
||||
"Filter maintenance requirements",
|
||||
"Outdoor air quality correlation"
|
||||
]
|
||||
},
|
||||
"voc": {
|
||||
"unit": "ppb",
|
||||
"optimal_range": (0, 220),
|
||||
"comfort_range": (0, 150),
|
||||
"critical_low": 0,
|
||||
"critical_high": 500,
|
||||
"context": "volatile organic compounds",
|
||||
"concerns": [
|
||||
"Indoor air quality",
|
||||
"Off-gassing from materials",
|
||||
"Ventilation effectiveness",
|
||||
"Occupant health symptoms"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
# Default for unknown metrics
|
||||
default = {
|
||||
"unit": "",
|
||||
"optimal_range": None,
|
||||
"comfort_range": None,
|
||||
"critical_low": None,
|
||||
"critical_high": None,
|
||||
"context": f"{metric} measurement",
|
||||
"concerns": ["Monitor for unexpected changes", "Verify sensor accuracy"]
|
||||
}
|
||||
|
||||
return metric_contexts.get(metric.lower(), default)
|
||||
|
||||
def _build_prompt(
|
||||
self,
|
||||
telemetry_data: List[Dict[str, Any]],
|
||||
device_info: Dict[str, Any],
|
||||
prompt_type: str,
|
||||
custom_prompt: str | None = None
|
||||
) -> str:
|
||||
"""Build analysis prompt based on type with metric-specific context."""
|
||||
|
||||
# Format telemetry data for prompt
|
||||
data_summary = self._format_telemetry_summary(telemetry_data)
|
||||
device_context = self._format_device_info(device_info)
|
||||
|
||||
# Get metrics present in data
|
||||
metrics_in_data = set(point.get("metric", "").lower() for point in telemetry_data)
|
||||
|
||||
# Build metric-specific context
|
||||
metric_context_lines = []
|
||||
for metric in metrics_in_data:
|
||||
if metric:
|
||||
ctx = self._get_metric_specific_context(metric)
|
||||
metric_context_lines.append(f"\n**{metric.upper()}** ({ctx['context']}):")
|
||||
if ctx['optimal_range']:
|
||||
metric_context_lines.append(f" - Optimal Range: {ctx['optimal_range'][0]}-{ctx['optimal_range'][1]} {ctx['unit']}")
|
||||
if ctx['comfort_range']:
|
||||
metric_context_lines.append(f" - Comfort Zone: {ctx['comfort_range'][0]}-{ctx['comfort_range'][1]} {ctx['unit']}")
|
||||
if ctx['critical_high']:
|
||||
metric_context_lines.append(f" - Critical Thresholds: <{ctx['critical_low']} or >{ctx['critical_high']} {ctx['unit']}")
|
||||
metric_context_lines.append(" - Key Concerns:")
|
||||
for concern in ctx['concerns']:
|
||||
metric_context_lines.append(f" • {concern}")
|
||||
|
||||
metric_context = "\n".join(metric_context_lines) if metric_context_lines else ""
|
||||
|
||||
prompts = {
|
||||
"anomaly_detection": f"""You are an IoT environmental monitoring specialist. Analyze the telemetry data to detect anomalies and issues.
|
||||
|
||||
{device_context}
|
||||
|
||||
Environmental Standards:
|
||||
{metric_context}
|
||||
|
||||
Recent Measurements:
|
||||
{data_summary}
|
||||
|
||||
Respond in this EXACT JSON format (no markdown, just valid JSON):
|
||||
{{
|
||||
"status": "normal|warning|critical",
|
||||
"summary": "Brief 1-2 sentence overview",
|
||||
"anomalies": [
|
||||
{{
|
||||
"metric": "metric name",
|
||||
"severity": "low|medium|high|critical",
|
||||
"description": "What's wrong",
|
||||
"value": "current value",
|
||||
"expected": "expected range"
|
||||
}}
|
||||
],
|
||||
"impacts": ["Impact 1", "Impact 2"],
|
||||
"actions": ["Action 1", "Action 2"],
|
||||
"root_causes": ["Cause 1", "Cause 2"]
|
||||
}}
|
||||
|
||||
Keep summary under 50 words, each item under 20 words.""",
|
||||
|
||||
"trend_summary": f"""You are an IoT environmental monitoring specialist. Analyze the measurement trends and patterns.
|
||||
|
||||
{device_context}
|
||||
|
||||
Environmental Standards:
|
||||
{metric_context}
|
||||
|
||||
Measurement History:
|
||||
{data_summary}
|
||||
|
||||
Respond in this EXACT JSON format (no markdown, just valid JSON):
|
||||
{{
|
||||
"status": "excellent|good|fair|poor",
|
||||
"summary": "Brief 1-2 sentence overview of conditions",
|
||||
"trends": [
|
||||
{{
|
||||
"metric": "metric name",
|
||||
"direction": "improving|stable|degrading",
|
||||
"description": "What's happening"
|
||||
}}
|
||||
],
|
||||
"comfort_score": {{
|
||||
"rating": 85,
|
||||
"description": "Brief assessment"
|
||||
}},
|
||||
"patterns": ["Pattern 1", "Pattern 2"],
|
||||
"recommendations": ["Recommendation 1", "Recommendation 2"],
|
||||
"forecast": "Brief prediction based on trends"
|
||||
}}
|
||||
|
||||
Keep all text concise: summary under 50 words, each item under 20 words.""",
|
||||
|
||||
"custom": custom_prompt or "Analyze the provided telemetry data."
|
||||
}
|
||||
|
||||
base_prompt = prompts.get(prompt_type, prompts["trend_summary"])
|
||||
|
||||
if prompt_type == "custom" and custom_prompt:
|
||||
base_prompt = f"{custom_prompt}\n\n{device_context}\n\n{metric_context}\n\nTelemetry Data:\n{data_summary}"
|
||||
|
||||
return base_prompt
|
||||
|
||||
def _format_telemetry_summary(self, telemetry_data: List[Dict[str, Any]]) -> str:
|
||||
"""Format telemetry data into readable summary with full data points."""
|
||||
if not telemetry_data:
|
||||
return "No data available"
|
||||
|
||||
# Group by metric
|
||||
metrics = {}
|
||||
for point in telemetry_data:
|
||||
metric = point.get("metric", "unknown")
|
||||
if metric not in metrics:
|
||||
metrics[metric] = []
|
||||
metrics[metric].append({
|
||||
"value": point.get("value"),
|
||||
"timestamp": point.get("timestamp"),
|
||||
"device_id": point.get("device_id")
|
||||
})
|
||||
|
||||
# Build summary with full data
|
||||
lines = []
|
||||
for metric, points in metrics.items():
|
||||
values = [p["value"] for p in points if p["value"] is not None]
|
||||
if values:
|
||||
lines.append(f"\n{metric}:")
|
||||
lines.append(f" Summary Statistics:")
|
||||
lines.append(f" - Data points: {len(values)}")
|
||||
lines.append(f" - Min: {min(values):.2f}")
|
||||
lines.append(f" - Max: {max(values):.2f}")
|
||||
lines.append(f" - Average: {sum(values)/len(values):.2f}")
|
||||
lines.append(f" - Latest: {points[-1]['value']:.2f} at {points[-1]['timestamp']}")
|
||||
|
||||
# Include all individual readings for AI analysis
|
||||
lines.append(f" Full Time Series Data:")
|
||||
for point in points:
|
||||
lines.append(f" - {point['timestamp']}: {point['value']:.2f}")
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
def _format_device_info(self, device_info: Dict[str, Any]) -> str:
|
||||
"""Format device information for prompt."""
|
||||
if not device_info:
|
||||
return "Device Context: Not provided"
|
||||
|
||||
lines = ["Device Context:"]
|
||||
for key, value in device_info.items():
|
||||
lines.append(f" - {key}: {value}")
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
async def analyze(
|
||||
self,
|
||||
telemetry_data: List[Dict[str, Any]],
|
||||
device_info: Dict[str, Any] = None,
|
||||
prompt_type: str = "trend_summary",
|
||||
custom_prompt: str | None = None
|
||||
) -> str:
|
||||
"""
|
||||
Analyze telemetry data using OpenAI GPT model.
|
||||
|
||||
Args:
|
||||
telemetry_data: List of telemetry data points with device_id, metric, value, timestamp
|
||||
device_info: Optional device metadata for context
|
||||
prompt_type: Type of analysis (anomaly_detection, trend_summary, custom)
|
||||
custom_prompt: Custom prompt for 'custom' type
|
||||
|
||||
Returns:
|
||||
Analysis result as string
|
||||
"""
|
||||
try:
|
||||
device_info = device_info or {}
|
||||
|
||||
# Build prompt
|
||||
prompt = self._build_prompt(telemetry_data, device_info, prompt_type, custom_prompt)
|
||||
|
||||
system_prompt = "You are an expert IoT environmental monitoring specialist with deep knowledge of indoor environmental quality standards (ASHRAE, WHO, OSHA guidelines). Your goal is to help maintain optimal working conditions for occupant health, comfort, and productivity. Provide clear, actionable insights with specific metric values and recommendations. Focus on environmental factors that impact human performance and wellbeing."
|
||||
|
||||
# Log the complete prompt for debugging/review
|
||||
self.logger.info("="*80)
|
||||
self.logger.info(f"PROMPT LOGGING - Analysis Type: {prompt_type}")
|
||||
self.logger.info("="*80)
|
||||
self.logger.info("\n[SYSTEM PROMPT]")
|
||||
self.logger.info(system_prompt)
|
||||
self.logger.info("\n" + "-"*80)
|
||||
self.logger.info("[USER PROMPT]")
|
||||
self.logger.info(prompt)
|
||||
self.logger.info("="*80)
|
||||
|
||||
self.logger.info(f"Sending analysis request to {self.model_name}")
|
||||
|
||||
# Call OpenAI API
|
||||
response = self.client.chat.completions.create(
|
||||
model=self.model_name,
|
||||
messages=[
|
||||
{
|
||||
"role": "system",
|
||||
"content": system_prompt
|
||||
},
|
||||
{
|
||||
"role": "user",
|
||||
"content": prompt
|
||||
}
|
||||
],
|
||||
temperature=0.7,
|
||||
max_tokens=1500
|
||||
)
|
||||
|
||||
analysis = response.choices[0].message.content
|
||||
self.logger.info(f"Analysis completed successfully. Tokens used: {response.usage.total_tokens}")
|
||||
|
||||
return analysis
|
||||
|
||||
except Exception as e:
|
||||
self.logger.error(f"Analysis failed: {str(e)}")
|
||||
raise
|
||||
|
||||
async def generate_daily_briefing(
|
||||
self,
|
||||
briefing_type: str,
|
||||
current_time: str,
|
||||
indoor_data: Dict[str, Any] | None = None,
|
||||
outdoor_data: Dict[str, Any] | None = None,
|
||||
health_data: Dict[str, Any] | None = None,
|
||||
calendar_events: List[Dict[str, Any]] | None = 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 (temp, humidity, co2, etc.)
|
||||
outdoor_data: Weather and air quality data
|
||||
health_data: Health/fitness metrics (steps, heart rate, etc.)
|
||||
calendar_events: List of upcoming calendar events
|
||||
|
||||
Returns:
|
||||
Dict with status_line, status_emoji, insights, recommendations, context
|
||||
"""
|
||||
try:
|
||||
# Build context sections
|
||||
context_sections = []
|
||||
|
||||
# Current time context
|
||||
try:
|
||||
dt = datetime.fromisoformat(current_time.replace('Z', '+00:00'))
|
||||
time_str = dt.strftime("%A, %B %d at %I:%M %p")
|
||||
except:
|
||||
time_str = current_time
|
||||
context_sections.append(f"Current Time: {time_str}")
|
||||
|
||||
# Indoor environment
|
||||
if indoor_data:
|
||||
indoor_lines = ["Indoor Environment:"]
|
||||
for key, value in indoor_data.items():
|
||||
if value is not None:
|
||||
indoor_lines.append(f" - {key}: {value}")
|
||||
context_sections.append("\n".join(indoor_lines))
|
||||
|
||||
# Outdoor conditions
|
||||
if outdoor_data:
|
||||
outdoor_lines = ["Outdoor Conditions:"]
|
||||
for key, value in outdoor_data.items():
|
||||
if value is not None:
|
||||
outdoor_lines.append(f" - {key}: {value}")
|
||||
context_sections.append("\n".join(outdoor_lines))
|
||||
|
||||
# Health metrics
|
||||
if health_data:
|
||||
health_lines = ["Health & Activity:"]
|
||||
for key, value in health_data.items():
|
||||
if value is not None:
|
||||
health_lines.append(f" - {key}: {value}")
|
||||
context_sections.append("\n".join(health_lines))
|
||||
|
||||
# Calendar events
|
||||
if calendar_events:
|
||||
cal_lines = ["Upcoming Schedule:"]
|
||||
for event in calendar_events[:10]: # Limit to 10 events
|
||||
start = event.get('start', 'TBD')
|
||||
summary = event.get('summary', 'Untitled')
|
||||
location = event.get('location', '')
|
||||
loc_str = f" at {location}" if location else ""
|
||||
cal_lines.append(f" - {start}: {summary}{loc_str}")
|
||||
context_sections.append("\n".join(cal_lines))
|
||||
|
||||
context = "\n\n".join(context_sections)
|
||||
|
||||
# Build briefing-specific prompts
|
||||
prompts = {
|
||||
"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.
|
||||
|
||||
{context}
|
||||
|
||||
Environmental thresholds for reference:
|
||||
- CO2 > 1000ppm: cognitive performance drops, ventilate before focused work
|
||||
- PM2.5 > 35: poor outdoor air, avoid outdoor activities
|
||||
- Temperature 20-24°C: optimal for focus work
|
||||
- Steps goal: 10,000/day, take breaks every 2 hours
|
||||
|
||||
Respond in this EXACT JSON format (no markdown, just valid JSON):
|
||||
{{
|
||||
"status_emoji": "🟢|🟡|🔴",
|
||||
"status_line": "One sentence summary of schedule/activity outlook",
|
||||
"insights": [
|
||||
"Actionable insight tied to specific time or event",
|
||||
"Another insight about activity or scheduling",
|
||||
"Insight about outdoor activity timing based on conditions"
|
||||
],
|
||||
"recommendations": [
|
||||
"Primary recommendation with specific timing",
|
||||
"Secondary recommendation"
|
||||
]
|
||||
}}
|
||||
|
||||
Guidelines:
|
||||
- Tie insights to SPECIFIC upcoming events or time slots
|
||||
- Suggest activity breaks during schedule gaps
|
||||
- Recommend outdoor activity timing based on air quality
|
||||
- Keep each insight under 25 words
|
||||
- Be specific about times (e.g., "before your 2pm meeting" not "later today")
|
||||
- Focus on productivity, movement, and work-life balance""",
|
||||
|
||||
"environment": f"""You are a smart wellness coach for office workers (software engineers, tech/finance professionals).
|
||||
Generate an Environment Briefing focused on indoor workspace conditions.
|
||||
|
||||
{context}
|
||||
|
||||
Environmental thresholds and their impacts:
|
||||
- CO2 400-800ppm: optimal | 800-1000ppm: acceptable | >1000ppm: ventilate (affects cognition)
|
||||
- Temperature 20-22°C: optimal | 18-24°C: acceptable | outside: adjust
|
||||
- Humidity 40-60%: optimal | <30%: too dry | >70%: too humid
|
||||
- PM2.5 0-12: good | 12-35: moderate | >35: unhealthy
|
||||
- Light 400-500 lux: optimal for desk work
|
||||
|
||||
Respond in this EXACT JSON format (no markdown, just valid JSON):
|
||||
{{
|
||||
"status_emoji": "🟢|🟡|🔴",
|
||||
"status_line": "One sentence summary of workspace conditions",
|
||||
"insights": [
|
||||
"Insight about most important environmental factor with action",
|
||||
"Insight about another condition affecting work",
|
||||
"Insight about ventilation or air quality"
|
||||
],
|
||||
"recommendations": [
|
||||
"Primary action to improve workspace",
|
||||
"Secondary recommendation"
|
||||
]
|
||||
}}
|
||||
|
||||
Guidelines:
|
||||
- If CO2 is high, mention opening windows BEFORE the next meeting/focused work
|
||||
- Tie environmental actions to upcoming activities when possible
|
||||
- Be specific about what to do (e.g., "open window" not "improve ventilation")
|
||||
- Mention how conditions affect cognitive performance
|
||||
- Keep each insight under 25 words""",
|
||||
|
||||
"full": f"""You are a smart wellness coach for office workers (software engineers, tech/finance professionals).
|
||||
Generate a comprehensive Daily Briefing combining schedule, environment, and health.
|
||||
|
||||
{context}
|
||||
|
||||
Environmental thresholds:
|
||||
- CO2 > 1000ppm: cognitive performance drops
|
||||
- PM2.5 > 35: poor outdoor air quality
|
||||
- Temperature 20-24°C: optimal for focus
|
||||
|
||||
Health targets:
|
||||
- 10,000 steps/day
|
||||
- Movement break every 2 hours
|
||||
- Heart rate zones for activity planning
|
||||
|
||||
Respond in this EXACT JSON format (no markdown, just valid JSON):
|
||||
{{
|
||||
"status_emoji": "🟢|🟡|🔴",
|
||||
"status_line": "One sentence overall summary",
|
||||
"insights": [
|
||||
"Most important cross-domain insight (environment + schedule)",
|
||||
"Health/activity insight tied to schedule",
|
||||
"Environmental condition affecting upcoming work",
|
||||
"Outdoor activity timing recommendation"
|
||||
],
|
||||
"recommendations": [
|
||||
"Primary recommendation combining multiple factors",
|
||||
"Secondary recommendation"
|
||||
]
|
||||
}}
|
||||
|
||||
Guidelines:
|
||||
- Connect environment to schedule (e.g., "ventilate before your debugging session")
|
||||
- Suggest activity timing based on air quality AND schedule gaps
|
||||
- Prioritize actionable insights over status reports
|
||||
- Be specific about times and actions
|
||||
- Keep each insight under 25 words"""
|
||||
}
|
||||
|
||||
prompt = prompts.get(briefing_type, prompts["full"])
|
||||
|
||||
system_prompt = """You are an expert wellness coach for knowledge workers. You help office workers (especially software engineers and tech/finance professionals) maintain optimal productivity and health by providing actionable, timing-specific advice.
|
||||
|
||||
Your insights should:
|
||||
1. Be tied to specific times or upcoming events
|
||||
2. Explain WHY something matters (e.g., "CO2 affects focus")
|
||||
3. Give specific actions (e.g., "open the window now" not "improve air")
|
||||
4. Consider work patterns (deep work, meetings, breaks)
|
||||
|
||||
Always respond with valid JSON only, no markdown formatting."""
|
||||
|
||||
self.logger.info(f"Generating {briefing_type} briefing")
|
||||
|
||||
response = self.client.chat.completions.create(
|
||||
model=self.model_name,
|
||||
messages=[
|
||||
{"role": "system", "content": system_prompt},
|
||||
{"role": "user", "content": prompt}
|
||||
],
|
||||
temperature=0.7,
|
||||
max_tokens=1000
|
||||
)
|
||||
|
||||
result_text = response.choices[0].message.content
|
||||
self.logger.info(f"Briefing generated. Tokens used: {response.usage.total_tokens}")
|
||||
|
||||
# Parse JSON response
|
||||
import json
|
||||
# Clean potential markdown wrapping
|
||||
if result_text.startswith("```"):
|
||||
result_text = result_text.split("```")[1]
|
||||
if result_text.startswith("json"):
|
||||
result_text = result_text[4:]
|
||||
result_text = result_text.strip()
|
||||
|
||||
result = json.loads(result_text)
|
||||
|
||||
return {
|
||||
"status_emoji": result.get("status_emoji", "🟢"),
|
||||
"status_line": result.get("status_line", "Briefing generated"),
|
||||
"insights": result.get("insights", []),
|
||||
"recommendations": result.get("recommendations", []),
|
||||
"briefing_type": briefing_type,
|
||||
"generated_at": current_time,
|
||||
}
|
||||
|
||||
except json.JSONDecodeError as e:
|
||||
self.logger.error(f"Failed to parse briefing JSON: {e}")
|
||||
self.logger.error(f"Raw response: {result_text}")
|
||||
return {
|
||||
"status_emoji": "⚠️",
|
||||
"status_line": "Briefing generated with formatting issues",
|
||||
"insights": [result_text[:200] if result_text else "Unable to generate insights"],
|
||||
"recommendations": [],
|
||||
"briefing_type": briefing_type,
|
||||
"generated_at": current_time,
|
||||
}
|
||||
except Exception as e:
|
||||
self.logger.error(f"Briefing generation failed: {str(e)}")
|
||||
raise
|
||||
|
||||
@@ -1,10 +1,157 @@
|
||||
from fastapi import FastAPI, HTTPException
|
||||
from pydantic import BaseModel, Field
|
||||
from typing import List, Dict, Any, Literal
|
||||
from contextlib import asynccontextmanager
|
||||
from gpt_service import GPTService
|
||||
import logging
|
||||
|
||||
logging.basicConfig(level=logging.INFO)
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
gpt_service = None
|
||||
|
||||
|
||||
def main():
|
||||
GPTService()
|
||||
print("Hello from gpt-service!")
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
"""Lifespan event handler for startup and shutdown."""
|
||||
global gpt_service
|
||||
# Startup
|
||||
logger.info("Initializing GPT Service...")
|
||||
gpt_service = GPTService()
|
||||
logger.info("GPT Service initialized successfully")
|
||||
yield
|
||||
# Shutdown (cleanup if needed)
|
||||
logger.info("Shutting down GPT Service...")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
app = FastAPI(lifespan=lifespan)
|
||||
|
||||
|
||||
class TelemetryDataPoint(BaseModel):
|
||||
device_id: str
|
||||
metric: str
|
||||
value: float
|
||||
timestamp: str
|
||||
|
||||
class AnalyzeRequest(BaseModel):
|
||||
telemetry_data: List[TelemetryDataPoint] = Field(..., description="Array of telemetry data points")
|
||||
device_info: Dict[str, Any] = Field(default_factory=dict, description="Device metadata")
|
||||
prompt_type: Literal["anomaly_detection", "trend_summary", "custom"] = Field(
|
||||
default="trend_summary",
|
||||
description="Type of analysis to perform"
|
||||
)
|
||||
custom_prompt: str | None = Field(None, description="Custom prompt for 'custom' type")
|
||||
|
||||
class AnalyzeResponse(BaseModel):
|
||||
analysis: str
|
||||
prompt_type: str
|
||||
data_points_analyzed: int
|
||||
|
||||
|
||||
class CalendarEvent(BaseModel):
|
||||
summary: str
|
||||
start: str
|
||||
end: str | None = None
|
||||
location: str | None = None
|
||||
|
||||
|
||||
class DailyBriefingRequest(BaseModel):
|
||||
briefing_type: Literal["schedule", "environment", "full"] = Field(
|
||||
default="full",
|
||||
description="Type of briefing to generate"
|
||||
)
|
||||
current_time: str = Field(..., description="Current time in ISO format")
|
||||
indoor_data: Dict[str, Any] | None = Field(None, description="Indoor environment readings")
|
||||
outdoor_data: Dict[str, Any] | None = Field(None, description="Weather and air quality data")
|
||||
health_data: Dict[str, Any] | None = Field(None, description="Health/fitness metrics")
|
||||
calendar_events: List[CalendarEvent] | None = Field(None, description="Upcoming calendar events")
|
||||
|
||||
|
||||
class DailyBriefingResponse(BaseModel):
|
||||
status_emoji: str
|
||||
status_line: str
|
||||
insights: List[str]
|
||||
recommendations: List[str]
|
||||
briefing_type: str
|
||||
generated_at: str
|
||||
|
||||
|
||||
@app.get("/health")
|
||||
async def health_check():
|
||||
return {"status": "healthy", "service": "gpt_service"}
|
||||
|
||||
|
||||
@app.post("/analyze", response_model=AnalyzeResponse)
|
||||
async def analyze_telemetry(request: AnalyzeRequest):
|
||||
"""
|
||||
Analyze telemetry data using GPT model.
|
||||
Receives formatted telemetry data from Django and returns AI insights.
|
||||
"""
|
||||
try:
|
||||
if not gpt_service:
|
||||
raise HTTPException(status_code=503, detail="GPT service not initialized")
|
||||
|
||||
if not request.telemetry_data:
|
||||
raise HTTPException(status_code=400, detail="No telemetry data provided")
|
||||
|
||||
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]
|
||||
|
||||
# Call GPT service analysis
|
||||
analysis_result = await gpt_service.analyze(
|
||||
telemetry_data=telemetry_dicts,
|
||||
device_info=request.device_info,
|
||||
prompt_type=request.prompt_type,
|
||||
custom_prompt=request.custom_prompt
|
||||
)
|
||||
|
||||
return AnalyzeResponse(
|
||||
analysis=analysis_result,
|
||||
prompt_type=request.prompt_type,
|
||||
data_points_analyzed=len(request.telemetry_data)
|
||||
)
|
||||
|
||||
except ValueError as e:
|
||||
logger.error(f"Validation error: {str(e)}")
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
except Exception as e:
|
||||
logger.error(f"Analysis failed: {str(e)}")
|
||||
raise HTTPException(status_code=500, detail=f"Analysis failed: {str(e)}")
|
||||
|
||||
|
||||
@app.post("/daily-briefing", response_model=DailyBriefingResponse)
|
||||
async def generate_daily_briefing(request: DailyBriefingRequest):
|
||||
"""
|
||||
Generate a daily briefing for office workers.
|
||||
Combines environment, schedule, and health data into actionable insights.
|
||||
"""
|
||||
try:
|
||||
if not gpt_service:
|
||||
raise HTTPException(status_code=503, detail="GPT service not initialized")
|
||||
|
||||
logger.info(f"Generating {request.briefing_type} briefing")
|
||||
|
||||
# Convert calendar events to dicts
|
||||
calendar_events = None
|
||||
if request.calendar_events:
|
||||
calendar_events = [event.model_dump() for event in request.calendar_events]
|
||||
|
||||
result = await gpt_service.generate_daily_briefing(
|
||||
briefing_type=request.briefing_type,
|
||||
current_time=request.current_time,
|
||||
indoor_data=request.indoor_data,
|
||||
outdoor_data=request.outdoor_data,
|
||||
health_data=request.health_data,
|
||||
calendar_events=calendar_events,
|
||||
)
|
||||
|
||||
return DailyBriefingResponse(**result)
|
||||
|
||||
except ValueError as e:
|
||||
logger.error(f"Validation error: {str(e)}")
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
except Exception as e:
|
||||
logger.error(f"Briefing generation failed: {str(e)}")
|
||||
raise HTTPException(status_code=500, detail=f"Briefing generation failed: {str(e)}")
|
||||
@@ -5,6 +5,9 @@ description = "Add your description here"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.13"
|
||||
dependencies = [
|
||||
"fastapi>=0.121.0",
|
||||
"openai>=2.6.1",
|
||||
"pydantic>=2.12.3",
|
||||
"uvicorn[standard]>=0.24.0",
|
||||
"python-dotenv>=1.2.1",
|
||||
]
|
||||
|
||||
@@ -20,6 +20,8 @@ WORKDIR /app
|
||||
|
||||
COPY --from=builder /app/.venv /app/.venv
|
||||
|
||||
COPY --from=builder /app/src/ /app/src/
|
||||
|
||||
COPY --from=builder /app/*.py /app/
|
||||
|
||||
RUN adduser -D -u 1000 appuser && \
|
||||
|
||||
318
uv.lock
generated
318
uv.lock
generated
@@ -24,6 +24,15 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/44/1f/38e29b06bfed7818ebba1f84904afdc8153ef7b6c7e0d8f3bc6643f5989c/alembic-1.17.0-py3-none-any.whl", hash = "sha256:80523bc437d41b35c5db7e525ad9d908f79de65c27d6a5a5eab6df348a352d99", size = 247449, upload-time = "2025-10-11T18:40:16.288Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "annotated-doc"
|
||||
version = "0.0.3"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/d7/a6/dc46877b911e40c00d395771ea710d5e77b6de7bacd5fdcd78d70cc5a48f/annotated_doc-0.0.3.tar.gz", hash = "sha256:e18370014c70187422c33e945053ff4c286f453a984eba84d0dbfa0c935adeda", size = 5535, upload-time = "2025-10-24T14:57:10.718Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/02/b7/cf592cb5de5cb3bade3357f8d2cf42bf103bbe39f459824b4939fd212911/annotated_doc-0.0.3-py3-none-any.whl", hash = "sha256:348ec6664a76f1fd3be81f43dffbee4c7e8ce931ba71ec67cc7f4ade7fbbb580", size = 5488, upload-time = "2025-10-24T14:57:09.462Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "annotated-types"
|
||||
version = "0.7.0"
|
||||
@@ -105,6 +114,18 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/0a/4c/925909008ed5a988ccbb72dcc897407e5d6d3bd72410d69e051fc0c14647/charset_normalizer-3.4.4-py3-none-any.whl", hash = "sha256:7a32c560861a02ff789ad905a2fe94e3f840803362c84fecf1851cb4cf3dc37f", size = 53402, upload-time = "2025-10-14T04:42:31.76Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "click"
|
||||
version = "8.3.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "colorama", marker = "sys_platform == 'win32'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/3d/fa/656b739db8587d7b5dfa22e22ed02566950fbfbcdc20311993483657a5c0/click-8.3.1.tar.gz", hash = "sha256:12ff4785d337a1bb490bb7e9c2b1ee5da3112e94a8622f26a6c77f5d2fc6842a", size = 295065, upload-time = "2025-11-15T20:45:42.706Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/98/78/01c019cdb5d6498122777c1a43056ebb3ebfeef2076d9d026bfe15583b2b/click-8.3.1-py3-none-any.whl", hash = "sha256:981153a64e25f12d547d3426c367a4857371575ee7ad18df2a6183ab0545b2a6", size = 108274, upload-time = "2025-11-15T20:45:41.139Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "colorama"
|
||||
version = "0.4.6"
|
||||
@@ -157,19 +178,89 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/8f/ef/81f3372b5dd35d8d354321155d1a38894b2b766f576d0abffac4d8ae78d9/django-5.2.7-py3-none-any.whl", hash = "sha256:59a13a6515f787dec9d97a0438cd2efac78c8aca1c80025244b0fe507fe0754b", size = 8307145, upload-time = "2025-10-01T14:22:49.476Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "django-cors-headers"
|
||||
version = "4.9.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "asgiref" },
|
||||
{ name = "django" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/21/39/55822b15b7ec87410f34cd16ce04065ff390e50f9e29f31d6d116fc80456/django_cors_headers-4.9.0.tar.gz", hash = "sha256:fe5d7cb59fdc2c8c646ce84b727ac2bca8912a247e6e68e1fb507372178e59e8", size = 21458, upload-time = "2025-09-18T10:40:52.326Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/30/d8/19ed1e47badf477d17fb177c1c19b5a21da0fd2d9f093f23be3fb86c5fab/django_cors_headers-4.9.0-py3-none-any.whl", hash = "sha256:15c7f20727f90044dcee2216a9fd7303741a864865f0c3657e28b7056f61b449", size = 12809, upload-time = "2025-09-18T10:40:50.843Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "django-rest"
|
||||
version = "0.8.7"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "django" },
|
||||
{ name = "six" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/d6/b4/1cdc1632448e252e37335427ec2a8dce470ef0c8dae066c3e3809484b2ac/django-rest-0.8.7.tar.gz", hash = "sha256:24a0eca6aa53864affcab5a880173f701e5387ad4e5885e12c81184432d6e15b", size = 58364, upload-time = "2021-04-20T22:15:40.004Z" }
|
||||
|
||||
[[package]]
|
||||
name = "djangorestframework"
|
||||
version = "3.16.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "django" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/8a/95/5376fe618646fde6899b3cdc85fd959716bb67542e273a76a80d9f326f27/djangorestframework-3.16.1.tar.gz", hash = "sha256:166809528b1aced0a17dc66c24492af18049f2c9420dbd0be29422029cfc3ff7", size = 1089735, upload-time = "2025-08-06T17:50:53.251Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/b0/ce/bf8b9d3f415be4ac5588545b5fcdbbb841977db1c1d923f7568eeabe1689/djangorestframework-3.16.1-py3-none-any.whl", hash = "sha256:33a59f47fb9c85ede792cbf88bde71893bcda0667bc573f784649521f1102cec", size = 1080442, upload-time = "2025-08-06T17:50:50.667Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "djangorestframework-simplejwt"
|
||||
version = "5.5.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "django" },
|
||||
{ name = "djangorestframework" },
|
||||
{ name = "pyjwt" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/a8/27/2874a325c11112066139769f7794afae238a07ce6adf96259f08fd37a9d7/djangorestframework_simplejwt-5.5.1.tar.gz", hash = "sha256:e72c5572f51d7803021288e2057afcbd03f17fe11d484096f40a460abc76e87f", size = 101265, upload-time = "2025-07-21T16:52:25.026Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/60/94/fdfb7b2f0b16cd3ed4d4171c55c1c07a2d1e3b106c5978c8ad0c15b4a48b/djangorestframework_simplejwt-5.5.1-py3-none-any.whl", hash = "sha256:2c30f3707053d384e9f315d11c2daccfcb548d4faa453111ca19a542b732e469", size = 107674, upload-time = "2025-07-21T16:52:07.493Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "fastapi"
|
||||
version = "0.121.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "annotated-doc" },
|
||||
{ name = "pydantic" },
|
||||
{ name = "starlette" },
|
||||
{ name = "typing-extensions" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/8c/e3/77a2df0946703973b9905fd0cde6172c15e0781984320123b4f5079e7113/fastapi-0.121.0.tar.gz", hash = "sha256:06663356a0b1ee93e875bbf05a31fb22314f5bed455afaaad2b2dad7f26e98fa", size = 342412, upload-time = "2025-11-03T10:25:54.818Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/dd/2c/42277afc1ba1a18f8358561eee40785d27becab8f80a1f945c0a3051c6eb/fastapi-0.121.0-py3-none-any.whl", hash = "sha256:8bdf1b15a55f4e4b0d6201033da9109ea15632cb76cf156e7b8b4019f2172106", size = 109183, upload-time = "2025-11-03T10:25:53.27Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "gpt-service"
|
||||
version = "0.1.0"
|
||||
source = { virtual = "services/gpt_service" }
|
||||
dependencies = [
|
||||
{ name = "fastapi" },
|
||||
{ name = "openai" },
|
||||
{ name = "pydantic" },
|
||||
{ name = "python-dotenv" },
|
||||
{ name = "uvicorn", extra = ["standard"] },
|
||||
]
|
||||
|
||||
[package.metadata]
|
||||
requires-dist = [
|
||||
{ name = "fastapi", specifier = ">=0.121.0" },
|
||||
{ name = "openai", specifier = ">=2.6.1" },
|
||||
{ name = "pydantic", specifier = ">=2.12.3" },
|
||||
{ name = "python-dotenv", specifier = ">=1.2.1" },
|
||||
{ name = "uvicorn", extras = ["standard"], specifier = ">=0.24.0" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -186,6 +277,8 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/ee/43/3cecdc0349359e1a527cbf2e3e28e5f8f06d3343aaf82ca13437a9aa290f/greenlet-3.2.4-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:23768528f2911bcd7e475210822ffb5254ed10d71f4028387e5a99b4c6699671", size = 610497, upload-time = "2025-08-07T13:18:31.636Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b8/19/06b6cf5d604e2c382a6f31cafafd6f33d5dea706f4db7bdab184bad2b21d/greenlet-3.2.4-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:00fadb3fedccc447f517ee0d3fd8fe49eae949e1cd0f6a611818f4f6fb7dc83b", size = 1121662, upload-time = "2025-08-07T13:42:41.117Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a2/15/0d5e4e1a66fab130d98168fe984c509249c833c1a3c16806b90f253ce7b9/greenlet-3.2.4-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:d25c5091190f2dc0eaa3f950252122edbbadbb682aa7b1ef2f8af0f8c0afefae", size = 1149210, upload-time = "2025-08-07T13:18:24.072Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1c/53/f9c440463b3057485b8594d7a638bed53ba531165ef0ca0e6c364b5cc807/greenlet-3.2.4-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:6e343822feb58ac4d0a1211bd9399de2b3a04963ddeec21530fc426cc121f19b", size = 1564759, upload-time = "2025-11-04T12:42:19.395Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/47/e4/3bb4240abdd0a8d23f4f88adec746a3099f0d86bfedb623f063b2e3b4df0/greenlet-3.2.4-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ca7f6f1f2649b89ce02f6f229d7c19f680a6238af656f61e0115b24857917929", size = 1634288, upload-time = "2025-11-04T12:42:21.174Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0b/55/2321e43595e6801e105fcfdee02b34c0f996eb71e6ddffca6b10b7e1d771/greenlet-3.2.4-cp313-cp313-win_amd64.whl", hash = "sha256:554b03b6e73aaabec3745364d6239e9e012d64c68ccd0b8430c64ccc14939a8b", size = 299685, upload-time = "2025-08-07T13:24:38.824Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/22/5c/85273fd7cc388285632b0498dbbab97596e04b154933dfe0f3e68156c68c/greenlet-3.2.4-cp314-cp314-macosx_11_0_universal2.whl", hash = "sha256:49a30d5fda2507ae77be16479bdb62a660fa51b1eb4928b524975b3bde77b3c0", size = 273586, upload-time = "2025-08-07T13:16:08.004Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d1/75/10aeeaa3da9332c2e761e4c50d4c3556c21113ee3f0afa2cf5769946f7a3/greenlet-3.2.4-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:299fd615cd8fc86267b47597123e3f43ad79c9d8a22bebdce535e53550763e2f", size = 686346, upload-time = "2025-08-07T13:42:59.944Z" },
|
||||
@@ -193,6 +286,8 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/dc/8b/29aae55436521f1d6f8ff4e12fb676f3400de7fcf27fccd1d4d17fd8fecd/greenlet-3.2.4-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:b4a1870c51720687af7fa3e7cda6d08d801dae660f75a76f3845b642b4da6ee1", size = 694659, upload-time = "2025-08-07T13:53:17.759Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/92/2e/ea25914b1ebfde93b6fc4ff46d6864564fba59024e928bdc7de475affc25/greenlet-3.2.4-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:061dc4cf2c34852b052a8620d40f36324554bc192be474b9e9770e8c042fd735", size = 695355, upload-time = "2025-08-07T13:18:34.517Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/72/60/fc56c62046ec17f6b0d3060564562c64c862948c9d4bc8aa807cf5bd74f4/greenlet-3.2.4-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:44358b9bf66c8576a9f57a590d5f5d6e72fa4228b763d0e43fee6d3b06d3a337", size = 657512, upload-time = "2025-08-07T13:18:33.969Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/23/6e/74407aed965a4ab6ddd93a7ded3180b730d281c77b765788419484cdfeef/greenlet-3.2.4-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:2917bdf657f5859fbf3386b12d68ede4cf1f04c90c3a6bc1f013dd68a22e2269", size = 1612508, upload-time = "2025-11-04T12:42:23.427Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0d/da/343cd760ab2f92bac1845ca07ee3faea9fe52bee65f7bcb19f16ad7de08b/greenlet-3.2.4-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:015d48959d4add5d6c9f6c5210ee3803a830dce46356e3bc326d6776bde54681", size = 1680760, upload-time = "2025-11-04T12:42:25.341Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e3/a5/6ddab2b4c112be95601c13428db1d8b6608a8b6039816f2ba09c346c08fc/greenlet-3.2.4-cp314-cp314-win_amd64.whl", hash = "sha256:e37ab26028f12dbb0ff65f29a8d3d44a765c61e729647bf2ddfbbed621726f01", size = 303425, upload-time = "2025-08-07T13:32:27.59Z" },
|
||||
]
|
||||
|
||||
@@ -218,6 +313,28 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "httptools"
|
||||
version = "0.7.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/b5/46/120a669232c7bdedb9d52d4aeae7e6c7dfe151e99dc70802e2fc7a5e1993/httptools-0.7.1.tar.gz", hash = "sha256:abd72556974f8e7c74a259655924a717a2365b236c882c3f6f8a45fe94703ac9", size = 258961, upload-time = "2025-10-10T03:55:08.559Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/09/8f/c77b1fcbfd262d422f12da02feb0d218fa228d52485b77b953832105bb90/httptools-0.7.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:6babce6cfa2a99545c60bfef8bee0cc0545413cb0018f617c8059a30ad985de3", size = 202889, upload-time = "2025-10-10T03:54:47.089Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0a/1a/22887f53602feaa066354867bc49a68fc295c2293433177ee90870a7d517/httptools-0.7.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:601b7628de7504077dd3dcb3791c6b8694bbd967148a6d1f01806509254fb1ca", size = 108180, upload-time = "2025-10-10T03:54:48.052Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/32/6a/6aaa91937f0010d288d3d124ca2946d48d60c3a5ee7ca62afe870e3ea011/httptools-0.7.1-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:04c6c0e6c5fb0739c5b8a9eb046d298650a0ff38cf42537fc372b28dc7e4472c", size = 478596, upload-time = "2025-10-10T03:54:48.919Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6d/70/023d7ce117993107be88d2cbca566a7c1323ccbaf0af7eabf2064fe356f6/httptools-0.7.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:69d4f9705c405ae3ee83d6a12283dc9feba8cc6aaec671b412917e644ab4fa66", size = 473268, upload-time = "2025-10-10T03:54:49.993Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/32/4d/9dd616c38da088e3f436e9a616e1d0cc66544b8cdac405cc4e81c8679fc7/httptools-0.7.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:44c8f4347d4b31269c8a9205d8a5ee2df5322b09bbbd30f8f862185bb6b05346", size = 455517, upload-time = "2025-10-10T03:54:51.066Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1d/3a/a6c595c310b7df958e739aae88724e24f9246a514d909547778d776799be/httptools-0.7.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:465275d76db4d554918aba40bf1cbebe324670f3dfc979eaffaa5d108e2ed650", size = 458337, upload-time = "2025-10-10T03:54:52.196Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fd/82/88e8d6d2c51edc1cc391b6e044c6c435b6aebe97b1abc33db1b0b24cd582/httptools-0.7.1-cp313-cp313-win_amd64.whl", hash = "sha256:322d00c2068d125bd570f7bf78b2d367dad02b919d8581d7476d8b75b294e3e6", size = 85743, upload-time = "2025-10-10T03:54:53.448Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/34/50/9d095fcbb6de2d523e027a2f304d4551855c2f46e0b82befd718b8b20056/httptools-0.7.1-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:c08fe65728b8d70b6923ce31e3956f859d5e1e8548e6f22ec520a962c6757270", size = 203619, upload-time = "2025-10-10T03:54:54.321Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/07/f0/89720dc5139ae54b03f861b5e2c55a37dba9a5da7d51e1e824a1f343627f/httptools-0.7.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:7aea2e3c3953521c3c51106ee11487a910d45586e351202474d45472db7d72d3", size = 108714, upload-time = "2025-10-10T03:54:55.163Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b3/cb/eea88506f191fb552c11787c23f9a405f4c7b0c5799bf73f2249cd4f5228/httptools-0.7.1-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:0e68b8582f4ea9166be62926077a3334064d422cf08ab87d8b74664f8e9058e1", size = 472909, upload-time = "2025-10-10T03:54:56.056Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e0/4a/a548bdfae6369c0d078bab5769f7b66f17f1bfaa6fa28f81d6be6959066b/httptools-0.7.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:df091cf961a3be783d6aebae963cc9b71e00d57fa6f149025075217bc6a55a7b", size = 470831, upload-time = "2025-10-10T03:54:57.219Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4d/31/14df99e1c43bd132eec921c2e7e11cda7852f65619bc0fc5bdc2d0cb126c/httptools-0.7.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:f084813239e1eb403ddacd06a30de3d3e09a9b76e7894dcda2b22f8a726e9c60", size = 452631, upload-time = "2025-10-10T03:54:58.219Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/22/d2/b7e131f7be8d854d48cb6d048113c30f9a46dca0c9a8b08fcb3fcd588cdc/httptools-0.7.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:7347714368fb2b335e9063bc2b96f2f87a9ceffcd9758ac295f8bbcd3ffbc0ca", size = 452910, upload-time = "2025-10-10T03:54:59.366Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/53/cf/878f3b91e4e6e011eff6d1fa9ca39f7eb17d19c9d7971b04873734112f30/httptools-0.7.1-cp314-cp314-win_amd64.whl", hash = "sha256:cfabda2a5bb85aa2a904ce06d974a3f30fb36cc63d7feaddec05d2050acede96", size = 88205, upload-time = "2025-10-10T03:55:00.389Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "httpx"
|
||||
version = "0.28.1"
|
||||
@@ -249,6 +366,10 @@ source = { virtual = "." }
|
||||
dependencies = [
|
||||
{ name = "alembic" },
|
||||
{ name = "django" },
|
||||
{ name = "django-cors-headers" },
|
||||
{ name = "django-rest" },
|
||||
{ name = "djangorestframework" },
|
||||
{ name = "djangorestframework-simplejwt" },
|
||||
{ name = "openai" },
|
||||
{ name = "paho-mqtt" },
|
||||
{ name = "psycopg2-binary" },
|
||||
@@ -266,6 +387,10 @@ dev = [
|
||||
requires-dist = [
|
||||
{ name = "alembic", specifier = ">=1.17.0" },
|
||||
{ name = "django", specifier = ">=5.2.7" },
|
||||
{ name = "django-cors-headers", specifier = ">=4.9.0" },
|
||||
{ name = "django-rest", specifier = ">=0.8.7" },
|
||||
{ name = "djangorestframework", specifier = ">=3.16.1" },
|
||||
{ name = "djangorestframework-simplejwt", specifier = ">=5.5.1" },
|
||||
{ name = "openai", specifier = ">=2.6.1" },
|
||||
{ name = "paho-mqtt", specifier = ">=2.1.0" },
|
||||
{ name = "psycopg2-binary", specifier = ">=2.9.11" },
|
||||
@@ -514,6 +639,15 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/8a/ac/9fc61b4f9d079482a290afe8d206b8f490e9fd32d4fc03ed4fc698214e01/pydantic_core-2.41.4-cp314-cp314t-win_arm64.whl", hash = "sha256:d34f950ae05a83e0ede899c595f312ca976023ea1db100cd5aa188f7005e3ab0", size = 1973897, upload-time = "2025-10-14T10:22:13.444Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pyjwt"
|
||||
version = "2.10.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/e7/46/bd74733ff231675599650d3e47f361794b22ef3e3770998dda30d3b63726/pyjwt-2.10.1.tar.gz", hash = "sha256:3cc5772eb20009233caf06e9d8a0577824723b44e6648ee0a2aedb6cf9381953", size = 87785, upload-time = "2024-11-28T03:43:29.933Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/61/ad/689f02752eeec26aed679477e80e632ef1b682313be70793d798c1d5fc8f/PyJWT-2.10.1-py3-none-any.whl", hash = "sha256:dcdd193e30abefd5debf142f9adfcdd2b58004e644f25406ffaebd50bd98dacb", size = 22997, upload-time = "2024-11-28T03:43:27.893Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "python-dotenv"
|
||||
version = "1.2.1"
|
||||
@@ -523,6 +657,42 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/14/1b/a298b06749107c305e1fe0f814c6c74aea7b2f1e10989cb30f544a1b3253/python_dotenv-1.2.1-py3-none-any.whl", hash = "sha256:b81ee9561e9ca4004139c6cbba3a238c32b03e4894671e181b671e8cb8425d61", size = 21230, upload-time = "2025-10-26T15:12:09.109Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pyyaml"
|
||||
version = "6.0.3"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/05/8e/961c0007c59b8dd7729d542c61a4d537767a59645b82a0b521206e1e25c2/pyyaml-6.0.3.tar.gz", hash = "sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f", size = 130960, upload-time = "2025-09-25T21:33:16.546Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/d1/11/0fd08f8192109f7169db964b5707a2f1e8b745d4e239b784a5a1dd80d1db/pyyaml-6.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8", size = 181669, upload-time = "2025-09-25T21:32:23.673Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b1/16/95309993f1d3748cd644e02e38b75d50cbc0d9561d21f390a76242ce073f/pyyaml-6.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1", size = 173252, upload-time = "2025-09-25T21:32:25.149Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/50/31/b20f376d3f810b9b2371e72ef5adb33879b25edb7a6d072cb7ca0c486398/pyyaml-6.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c", size = 767081, upload-time = "2025-09-25T21:32:26.575Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/49/1e/a55ca81e949270d5d4432fbbd19dfea5321eda7c41a849d443dc92fd1ff7/pyyaml-6.0.3-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5", size = 841159, upload-time = "2025-09-25T21:32:27.727Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/74/27/e5b8f34d02d9995b80abcef563ea1f8b56d20134d8f4e5e81733b1feceb2/pyyaml-6.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6", size = 801626, upload-time = "2025-09-25T21:32:28.878Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f9/11/ba845c23988798f40e52ba45f34849aa8a1f2d4af4b798588010792ebad6/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6", size = 753613, upload-time = "2025-09-25T21:32:30.178Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/3d/e0/7966e1a7bfc0a45bf0a7fb6b98ea03fc9b8d84fa7f2229e9659680b69ee3/pyyaml-6.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be", size = 794115, upload-time = "2025-09-25T21:32:31.353Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/de/94/980b50a6531b3019e45ddeada0626d45fa85cbe22300844a7983285bed3b/pyyaml-6.0.3-cp313-cp313-win32.whl", hash = "sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26", size = 137427, upload-time = "2025-09-25T21:32:32.58Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/97/c9/39d5b874e8b28845e4ec2202b5da735d0199dbe5b8fb85f91398814a9a46/pyyaml-6.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c", size = 154090, upload-time = "2025-09-25T21:32:33.659Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/73/e8/2bdf3ca2090f68bb3d75b44da7bbc71843b19c9f2b9cb9b0f4ab7a5a4329/pyyaml-6.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb", size = 140246, upload-time = "2025-09-25T21:32:34.663Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9d/8c/f4bd7f6465179953d3ac9bc44ac1a8a3e6122cf8ada906b4f96c60172d43/pyyaml-6.0.3-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac", size = 181814, upload-time = "2025-09-25T21:32:35.712Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bd/9c/4d95bb87eb2063d20db7b60faa3840c1b18025517ae857371c4dd55a6b3a/pyyaml-6.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310", size = 173809, upload-time = "2025-09-25T21:32:36.789Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/92/b5/47e807c2623074914e29dabd16cbbdd4bf5e9b2db9f8090fa64411fc5382/pyyaml-6.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7", size = 766454, upload-time = "2025-09-25T21:32:37.966Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/02/9e/e5e9b168be58564121efb3de6859c452fccde0ab093d8438905899a3a483/pyyaml-6.0.3-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788", size = 836355, upload-time = "2025-09-25T21:32:39.178Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/88/f9/16491d7ed2a919954993e48aa941b200f38040928474c9e85ea9e64222c3/pyyaml-6.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5", size = 794175, upload-time = "2025-09-25T21:32:40.865Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/dd/3f/5989debef34dc6397317802b527dbbafb2b4760878a53d4166579111411e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764", size = 755228, upload-time = "2025-09-25T21:32:42.084Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d7/ce/af88a49043cd2e265be63d083fc75b27b6ed062f5f9fd6cdc223ad62f03e/pyyaml-6.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35", size = 789194, upload-time = "2025-09-25T21:32:43.362Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/23/20/bb6982b26a40bb43951265ba29d4c246ef0ff59c9fdcdf0ed04e0687de4d/pyyaml-6.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac", size = 156429, upload-time = "2025-09-25T21:32:57.844Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f4/f4/a4541072bb9422c8a883ab55255f918fa378ecf083f5b85e87fc2b4eda1b/pyyaml-6.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3", size = 143912, upload-time = "2025-09-25T21:32:59.247Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7c/f9/07dd09ae774e4616edf6cda684ee78f97777bdd15847253637a6f052a62f/pyyaml-6.0.3-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3", size = 189108, upload-time = "2025-09-25T21:32:44.377Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4e/78/8d08c9fb7ce09ad8c38ad533c1191cf27f7ae1effe5bb9400a46d9437fcf/pyyaml-6.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba", size = 183641, upload-time = "2025-09-25T21:32:45.407Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7b/5b/3babb19104a46945cf816d047db2788bcaf8c94527a805610b0289a01c6b/pyyaml-6.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c", size = 831901, upload-time = "2025-09-25T21:32:48.83Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8b/cc/dff0684d8dc44da4d22a13f35f073d558c268780ce3c6ba1b87055bb0b87/pyyaml-6.0.3-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702", size = 861132, upload-time = "2025-09-25T21:32:50.149Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b1/5e/f77dc6b9036943e285ba76b49e118d9ea929885becb0a29ba8a7c75e29fe/pyyaml-6.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c", size = 839261, upload-time = "2025-09-25T21:32:51.808Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ce/88/a9db1376aa2a228197c58b37302f284b5617f56a5d959fd1763fb1675ce6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065", size = 805272, upload-time = "2025-09-25T21:32:52.941Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/da/92/1446574745d74df0c92e6aa4a7b0b3130706a4142b2d1a5869f2eaa423c6/pyyaml-6.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65", size = 829923, upload-time = "2025-09-25T21:32:54.537Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f0/7a/1c7270340330e575b92f397352af856a8c06f230aa3e76f86b39d01b416a/pyyaml-6.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9", size = 174062, upload-time = "2025-09-25T21:32:55.767Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "redis"
|
||||
version = "7.0.1"
|
||||
@@ -573,6 +743,15 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/2e/5d/aa883766f8ef9ffbe6aa24f7192fb71632f31a30e77eb39aa2b0dc4290ac/ruff-0.14.2-py3-none-win_arm64.whl", hash = "sha256:ea9d635e83ba21569fbacda7e78afbfeb94911c9434aff06192d9bc23fd5495a", size = 12554956, upload-time = "2025-10-23T19:36:58.714Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "six"
|
||||
version = "1.17.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/94/e7/b2c673351809dca68a0e064b6af791aa332cf192da575fd474ed7d6f16a2/six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81", size = 34031, upload-time = "2024-12-04T17:35:28.174Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sniffio"
|
||||
version = "1.3.1"
|
||||
@@ -612,6 +791,18 @@ wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/a9/5c/bfd6bd0bf979426d405cc6e71eceb8701b148b16c21d2dc3c261efc61c7b/sqlparse-0.5.3-py3-none-any.whl", hash = "sha256:cf2196ed3418f3ba5de6af7e82c694a9fbdbfecccdfc72e281548517081f16ca", size = 44415, upload-time = "2024-12-10T12:05:27.824Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "starlette"
|
||||
version = "0.49.3"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "anyio" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/de/1a/608df0b10b53b0beb96a37854ee05864d182ddd4b1156a22f1ad3860425a/starlette-0.49.3.tar.gz", hash = "sha256:1c14546f299b5901a1ea0e34410575bc33bbd741377a10484a54445588d00284", size = 2655031, upload-time = "2025-11-01T15:12:26.13Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/a3/e0/021c772d6a662f43b63044ab481dc6ac7592447605b5b35a957785363122/starlette-0.49.3-py3-none-any.whl", hash = "sha256:b579b99715fdc2980cf88c8ec96d3bf1ce16f5a8051a7c2b84ef9b1cdecaea2f", size = 74340, upload-time = "2025-11-01T15:12:24.387Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "tqdm"
|
||||
version = "4.67.1"
|
||||
@@ -662,3 +853,130 @@ sdist = { url = "https://files.pythonhosted.org/packages/15/22/9ee70a2574a4f4599
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/a7/c2/fe1e52489ae3122415c51f387e221dd0773709bad6c6cdaa599e8a2c5185/urllib3-2.5.0-py3-none-any.whl", hash = "sha256:e6b01673c0fa6a13e374b50871808eb3bf7046c4b125b216f6bf1cc604cff0dc", size = 129795, upload-time = "2025-06-18T14:07:40.39Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "uvicorn"
|
||||
version = "0.38.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "click" },
|
||||
{ name = "h11" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/cb/ce/f06b84e2697fef4688ca63bdb2fdf113ca0a3be33f94488f2cadb690b0cf/uvicorn-0.38.0.tar.gz", hash = "sha256:fd97093bdd120a2609fc0d3afe931d4d4ad688b6e75f0f929fde1bc36fe0e91d", size = 80605, upload-time = "2025-10-18T13:46:44.63Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/ee/d9/d88e73ca598f4f6ff671fb5fde8a32925c2e08a637303a1d12883c7305fa/uvicorn-0.38.0-py3-none-any.whl", hash = "sha256:48c0afd214ceb59340075b4a052ea1ee91c16fbc2a9b1469cca0e54566977b02", size = 68109, upload-time = "2025-10-18T13:46:42.958Z" },
|
||||
]
|
||||
|
||||
[package.optional-dependencies]
|
||||
standard = [
|
||||
{ name = "colorama", marker = "sys_platform == 'win32'" },
|
||||
{ name = "httptools" },
|
||||
{ name = "python-dotenv" },
|
||||
{ name = "pyyaml" },
|
||||
{ name = "uvloop", marker = "platform_python_implementation != 'PyPy' and sys_platform != 'cygwin' and sys_platform != 'win32'" },
|
||||
{ name = "watchfiles" },
|
||||
{ name = "websockets" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "uvloop"
|
||||
version = "0.22.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/06/f0/18d39dbd1971d6d62c4629cc7fa67f74821b0dc1f5a77af43719de7936a7/uvloop-0.22.1.tar.gz", hash = "sha256:6c84bae345b9147082b17371e3dd5d42775bddce91f885499017f4607fdaf39f", size = 2443250, upload-time = "2025-10-16T22:17:19.342Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/89/8c/182a2a593195bfd39842ea68ebc084e20c850806117213f5a299dfc513d9/uvloop-0.22.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:561577354eb94200d75aca23fbde86ee11be36b00e52a4eaf8f50fb0c86b7705", size = 1358611, upload-time = "2025-10-16T22:16:36.833Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d2/14/e301ee96a6dc95224b6f1162cd3312f6d1217be3907b79173b06785f2fe7/uvloop-0.22.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:1cdf5192ab3e674ca26da2eada35b288d2fa49fdd0f357a19f0e7c4e7d5077c8", size = 751811, upload-time = "2025-10-16T22:16:38.275Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b7/02/654426ce265ac19e2980bfd9ea6590ca96a56f10c76e63801a2df01c0486/uvloop-0.22.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6e2ea3d6190a2968f4a14a23019d3b16870dd2190cd69c8180f7c632d21de68d", size = 4288562, upload-time = "2025-10-16T22:16:39.375Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/15/c0/0be24758891ef825f2065cd5db8741aaddabe3e248ee6acc5e8a80f04005/uvloop-0.22.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0530a5fbad9c9e4ee3f2b33b148c6a64d47bbad8000ea63704fa8260f4cf728e", size = 4366890, upload-time = "2025-10-16T22:16:40.547Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d2/53/8369e5219a5855869bcee5f4d317f6da0e2c669aecf0ef7d371e3d084449/uvloop-0.22.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:bc5ef13bbc10b5335792360623cc378d52d7e62c2de64660616478c32cd0598e", size = 4119472, upload-time = "2025-10-16T22:16:41.694Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f8/ba/d69adbe699b768f6b29a5eec7b47dd610bd17a69de51b251126a801369ea/uvloop-0.22.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:1f38ec5e3f18c8a10ded09742f7fb8de0108796eb673f30ce7762ce1b8550cad", size = 4239051, upload-time = "2025-10-16T22:16:43.224Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/90/cd/b62bdeaa429758aee8de8b00ac0dd26593a9de93d302bff3d21439e9791d/uvloop-0.22.1-cp314-cp314-macosx_10_13_universal2.whl", hash = "sha256:3879b88423ec7e97cd4eba2a443aa26ed4e59b45e6b76aabf13fe2f27023a142", size = 1362067, upload-time = "2025-10-16T22:16:44.503Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/0d/f8/a132124dfda0777e489ca86732e85e69afcd1ff7686647000050ba670689/uvloop-0.22.1-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:4baa86acedf1d62115c1dc6ad1e17134476688f08c6efd8a2ab076e815665c74", size = 752423, upload-time = "2025-10-16T22:16:45.968Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a3/94/94af78c156f88da4b3a733773ad5ba0b164393e357cc4bd0ab2e2677a7d6/uvloop-0.22.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:297c27d8003520596236bdb2335e6b3f649480bd09e00d1e3a99144b691d2a35", size = 4272437, upload-time = "2025-10-16T22:16:47.451Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b5/35/60249e9fd07b32c665192cec7af29e06c7cd96fa1d08b84f012a56a0b38e/uvloop-0.22.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:c1955d5a1dd43198244d47664a5858082a3239766a839b2102a269aaff7a4e25", size = 4292101, upload-time = "2025-10-16T22:16:49.318Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/02/62/67d382dfcb25d0a98ce73c11ed1a6fba5037a1a1d533dcbb7cab033a2636/uvloop-0.22.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:b31dc2fccbd42adc73bc4e7cdbae4fc5086cf378979e53ca5d0301838c5682c6", size = 4114158, upload-time = "2025-10-16T22:16:50.517Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f0/7a/f1171b4a882a5d13c8b7576f348acfe6074d72eaf52cccef752f748d4a9f/uvloop-0.22.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:93f617675b2d03af4e72a5333ef89450dfaa5321303ede6e67ba9c9d26878079", size = 4177360, upload-time = "2025-10-16T22:16:52.646Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/79/7b/b01414f31546caf0919da80ad57cbfe24c56b151d12af68cee1b04922ca8/uvloop-0.22.1-cp314-cp314t-macosx_10_13_universal2.whl", hash = "sha256:37554f70528f60cad66945b885eb01f1bb514f132d92b6eeed1c90fd54ed6289", size = 1454790, upload-time = "2025-10-16T22:16:54.355Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d4/31/0bb232318dd838cad3fa8fb0c68c8b40e1145b32025581975e18b11fab40/uvloop-0.22.1-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:b76324e2dc033a0b2f435f33eb88ff9913c156ef78e153fb210e03c13da746b3", size = 796783, upload-time = "2025-10-16T22:16:55.906Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/42/38/c9b09f3271a7a723a5de69f8e237ab8e7803183131bc57c890db0b6bb872/uvloop-0.22.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:badb4d8e58ee08dad957002027830d5c3b06aea446a6a3744483c2b3b745345c", size = 4647548, upload-time = "2025-10-16T22:16:57.008Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c1/37/945b4ca0ac27e3dc4952642d4c900edd030b3da6c9634875af6e13ae80e5/uvloop-0.22.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b91328c72635f6f9e0282e4a57da7470c7350ab1c9f48546c0f2866205349d21", size = 4467065, upload-time = "2025-10-16T22:16:58.206Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/97/cc/48d232f33d60e2e2e0b42f4e73455b146b76ebe216487e862700457fbf3c/uvloop-0.22.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:daf620c2995d193449393d6c62131b3fbd40a63bf7b307a1527856ace637fe88", size = 4328384, upload-time = "2025-10-16T22:16:59.36Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e4/16/c1fd27e9549f3c4baf1dc9c20c456cd2f822dbf8de9f463824b0c0357e06/uvloop-0.22.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:6cde23eeda1a25c75b2e07d39970f3374105d5eafbaab2a4482be82f272d5a5e", size = 4296730, upload-time = "2025-10-16T22:17:00.744Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "watchfiles"
|
||||
version = "1.1.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "anyio" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/c2/c9/8869df9b2a2d6c59d79220a4db37679e74f807c559ffe5265e08b227a210/watchfiles-1.1.1.tar.gz", hash = "sha256:a173cb5c16c4f40ab19cecf48a534c409f7ea983ab8fed0741304a1c0a31b3f2", size = 94440, upload-time = "2025-10-14T15:06:21.08Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/bb/f4/f750b29225fe77139f7ae5de89d4949f5a99f934c65a1f1c0b248f26f747/watchfiles-1.1.1-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:130e4876309e8686a5e37dba7d5e9bc77e6ed908266996ca26572437a5271e18", size = 404321, upload-time = "2025-10-14T15:05:02.063Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2b/f9/f07a295cde762644aa4c4bb0f88921d2d141af45e735b965fb2e87858328/watchfiles-1.1.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:5f3bde70f157f84ece3765b42b4a52c6ac1a50334903c6eaf765362f6ccca88a", size = 391783, upload-time = "2025-10-14T15:05:03.052Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/bc/11/fc2502457e0bea39a5c958d86d2cb69e407a4d00b85735ca724bfa6e0d1a/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:14e0b1fe858430fc0251737ef3824c54027bedb8c37c38114488b8e131cf8219", size = 449279, upload-time = "2025-10-14T15:05:04.004Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e3/1f/d66bc15ea0b728df3ed96a539c777acfcad0eb78555ad9efcaa1274688f0/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f27db948078f3823a6bb3b465180db8ebecf26dd5dae6f6180bd87383b6b4428", size = 459405, upload-time = "2025-10-14T15:05:04.942Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/be/90/9f4a65c0aec3ccf032703e6db02d89a157462fbb2cf20dd415128251cac0/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:059098c3a429f62fc98e8ec62b982230ef2c8df68c79e826e37b895bc359a9c0", size = 488976, upload-time = "2025-10-14T15:05:05.905Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/37/57/ee347af605d867f712be7029bb94c8c071732a4b44792e3176fa3c612d39/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:bfb5862016acc9b869bb57284e6cb35fdf8e22fe59f7548858e2f971d045f150", size = 595506, upload-time = "2025-10-14T15:05:06.906Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a8/78/cc5ab0b86c122047f75e8fc471c67a04dee395daf847d3e59381996c8707/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:319b27255aacd9923b8a276bb14d21a5f7ff82564c744235fc5eae58d95422ae", size = 474936, upload-time = "2025-10-14T15:05:07.906Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/62/da/def65b170a3815af7bd40a3e7010bf6ab53089ef1b75d05dd5385b87cf08/watchfiles-1.1.1-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c755367e51db90e75b19454b680903631d41f9e3607fbd941d296a020c2d752d", size = 456147, upload-time = "2025-10-14T15:05:09.138Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/57/99/da6573ba71166e82d288d4df0839128004c67d2778d3b566c138695f5c0b/watchfiles-1.1.1-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:c22c776292a23bfc7237a98f791b9ad3144b02116ff10d820829ce62dff46d0b", size = 630007, upload-time = "2025-10-14T15:05:10.117Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a8/51/7439c4dd39511368849eb1e53279cd3454b4a4dbace80bab88feeb83c6b5/watchfiles-1.1.1-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:3a476189be23c3686bc2f4321dd501cb329c0a0469e77b7b534ee10129ae6374", size = 622280, upload-time = "2025-10-14T15:05:11.146Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/95/9c/8ed97d4bba5db6fdcdb2b298d3898f2dd5c20f6b73aee04eabe56c59677e/watchfiles-1.1.1-cp313-cp313-win32.whl", hash = "sha256:bf0a91bfb5574a2f7fc223cf95eeea79abfefa404bf1ea5e339c0c1560ae99a0", size = 272056, upload-time = "2025-10-14T15:05:12.156Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1f/f3/c14e28429f744a260d8ceae18bf58c1d5fa56b50d006a7a9f80e1882cb0d/watchfiles-1.1.1-cp313-cp313-win_amd64.whl", hash = "sha256:52e06553899e11e8074503c8e716d574adeeb7e68913115c4b3653c53f9bae42", size = 288162, upload-time = "2025-10-14T15:05:13.208Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/dc/61/fe0e56c40d5cd29523e398d31153218718c5786b5e636d9ae8ae79453d27/watchfiles-1.1.1-cp313-cp313-win_arm64.whl", hash = "sha256:ac3cc5759570cd02662b15fbcd9d917f7ecd47efe0d6b40474eafd246f91ea18", size = 277909, upload-time = "2025-10-14T15:05:14.49Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/79/42/e0a7d749626f1e28c7108a99fb9bf524b501bbbeb9b261ceecde644d5a07/watchfiles-1.1.1-cp313-cp313t-macosx_10_12_x86_64.whl", hash = "sha256:563b116874a9a7ce6f96f87cd0b94f7faf92d08d0021e837796f0a14318ef8da", size = 403389, upload-time = "2025-10-14T15:05:15.777Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/15/49/08732f90ce0fbbc13913f9f215c689cfc9ced345fb1bcd8829a50007cc8d/watchfiles-1.1.1-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:3ad9fe1dae4ab4212d8c91e80b832425e24f421703b5a42ef2e4a1e215aff051", size = 389964, upload-time = "2025-10-14T15:05:16.85Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/27/0d/7c315d4bd5f2538910491a0393c56bf70d333d51bc5b34bee8e68e8cea19/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ce70f96a46b894b36eba678f153f052967a0d06d5b5a19b336ab0dbbd029f73e", size = 448114, upload-time = "2025-10-14T15:05:17.876Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c3/24/9e096de47a4d11bc4df41e9d1e61776393eac4cb6eb11b3e23315b78b2cc/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:cb467c999c2eff23a6417e58d75e5828716f42ed8289fe6b77a7e5a91036ca70", size = 460264, upload-time = "2025-10-14T15:05:18.962Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/cc/0f/e8dea6375f1d3ba5fcb0b3583e2b493e77379834c74fd5a22d66d85d6540/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:836398932192dae4146c8f6f737d74baeac8b70ce14831a239bdb1ca882fc261", size = 487877, upload-time = "2025-10-14T15:05:20.094Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ac/5b/df24cfc6424a12deb41503b64d42fbea6b8cb357ec62ca84a5a3476f654a/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:743185e7372b7bc7c389e1badcc606931a827112fbbd37f14c537320fca08620", size = 595176, upload-time = "2025-10-14T15:05:21.134Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8f/b5/853b6757f7347de4e9b37e8cc3289283fb983cba1ab4d2d7144694871d9c/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:afaeff7696e0ad9f02cbb8f56365ff4686ab205fcf9c4c5b6fdfaaa16549dd04", size = 473577, upload-time = "2025-10-14T15:05:22.306Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e1/f7/0a4467be0a56e80447c8529c9fce5b38eab4f513cb3d9bf82e7392a5696b/watchfiles-1.1.1-cp313-cp313t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:3f7eb7da0eb23aa2ba036d4f616d46906013a68caf61b7fdbe42fc8b25132e77", size = 455425, upload-time = "2025-10-14T15:05:23.348Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8e/e0/82583485ea00137ddf69bc84a2db88bd92ab4a6e3c405e5fb878ead8d0e7/watchfiles-1.1.1-cp313-cp313t-musllinux_1_1_aarch64.whl", hash = "sha256:831a62658609f0e5c64178211c942ace999517f5770fe9436be4c2faeba0c0ef", size = 628826, upload-time = "2025-10-14T15:05:24.398Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/28/9a/a785356fccf9fae84c0cc90570f11702ae9571036fb25932f1242c82191c/watchfiles-1.1.1-cp313-cp313t-musllinux_1_1_x86_64.whl", hash = "sha256:f9a2ae5c91cecc9edd47e041a930490c31c3afb1f5e6d71de3dc671bfaca02bf", size = 622208, upload-time = "2025-10-14T15:05:25.45Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c3/f4/0872229324ef69b2c3edec35e84bd57a1289e7d3fe74588048ed8947a323/watchfiles-1.1.1-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:d1715143123baeeaeadec0528bb7441103979a1d5f6fd0e1f915383fea7ea6d5", size = 404315, upload-time = "2025-10-14T15:05:26.501Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/7b/22/16d5331eaed1cb107b873f6ae1b69e9ced582fcf0c59a50cd84f403b1c32/watchfiles-1.1.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:39574d6370c4579d7f5d0ad940ce5b20db0e4117444e39b6d8f99db5676c52fd", size = 390869, upload-time = "2025-10-14T15:05:27.649Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b2/7e/5643bfff5acb6539b18483128fdc0ef2cccc94a5b8fbda130c823e8ed636/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7365b92c2e69ee952902e8f70f3ba6360d0d596d9299d55d7d386df84b6941fb", size = 449919, upload-time = "2025-10-14T15:05:28.701Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/51/2e/c410993ba5025a9f9357c376f48976ef0e1b1aefb73b97a5ae01a5972755/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:bfff9740c69c0e4ed32416f013f3c45e2ae42ccedd1167ef2d805c000b6c71a5", size = 460845, upload-time = "2025-10-14T15:05:30.064Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8e/a4/2df3b404469122e8680f0fcd06079317e48db58a2da2950fb45020947734/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b27cf2eb1dda37b2089e3907d8ea92922b673c0c427886d4edc6b94d8dfe5db3", size = 489027, upload-time = "2025-10-14T15:05:31.064Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ea/84/4587ba5b1f267167ee715b7f66e6382cca6938e0a4b870adad93e44747e6/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:526e86aced14a65a5b0ec50827c745597c782ff46b571dbfe46192ab9e0b3c33", size = 595615, upload-time = "2025-10-14T15:05:32.074Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6a/0f/c6988c91d06e93cd0bb3d4a808bcf32375ca1904609835c3031799e3ecae/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:04e78dd0b6352db95507fd8cb46f39d185cf8c74e4cf1e4fbad1d3df96faf510", size = 474836, upload-time = "2025-10-14T15:05:33.209Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/b4/36/ded8aebea91919485b7bbabbd14f5f359326cb5ec218cd67074d1e426d74/watchfiles-1.1.1-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:5c85794a4cfa094714fb9c08d4a218375b2b95b8ed1666e8677c349906246c05", size = 455099, upload-time = "2025-10-14T15:05:34.189Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/98/e0/8c9bdba88af756a2fce230dd365fab2baf927ba42cd47521ee7498fd5211/watchfiles-1.1.1-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:74d5012b7630714b66be7b7b7a78855ef7ad58e8650c73afc4c076a1f480a8d6", size = 630626, upload-time = "2025-10-14T15:05:35.216Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2a/84/a95db05354bf2d19e438520d92a8ca475e578c647f78f53197f5a2f17aaf/watchfiles-1.1.1-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:8fbe85cb3201c7d380d3d0b90e63d520f15d6afe217165d7f98c9c649654db81", size = 622519, upload-time = "2025-10-14T15:05:36.259Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1d/ce/d8acdc8de545de995c339be67711e474c77d643555a9bb74a9334252bd55/watchfiles-1.1.1-cp314-cp314-win32.whl", hash = "sha256:3fa0b59c92278b5a7800d3ee7733da9d096d4aabcfabb9a928918bd276ef9b9b", size = 272078, upload-time = "2025-10-14T15:05:37.63Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c4/c9/a74487f72d0451524be827e8edec251da0cc1fcf111646a511ae752e1a3d/watchfiles-1.1.1-cp314-cp314-win_amd64.whl", hash = "sha256:c2047d0b6cea13b3316bdbafbfa0c4228ae593d995030fda39089d36e64fc03a", size = 287664, upload-time = "2025-10-14T15:05:38.95Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/df/b8/8ac000702cdd496cdce998c6f4ee0ca1f15977bba51bdf07d872ebdfc34c/watchfiles-1.1.1-cp314-cp314-win_arm64.whl", hash = "sha256:842178b126593addc05acf6fce960d28bc5fae7afbaa2c6c1b3a7b9460e5be02", size = 277154, upload-time = "2025-10-14T15:05:39.954Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/47/a8/e3af2184707c29f0f14b1963c0aace6529f9d1b8582d5b99f31bbf42f59e/watchfiles-1.1.1-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:88863fbbc1a7312972f1c511f202eb30866370ebb8493aef2812b9ff28156a21", size = 403820, upload-time = "2025-10-14T15:05:40.932Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/c0/ec/e47e307c2f4bd75f9f9e8afbe3876679b18e1bcec449beca132a1c5ffb2d/watchfiles-1.1.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:55c7475190662e202c08c6c0f4d9e345a29367438cf8e8037f3155e10a88d5a5", size = 390510, upload-time = "2025-10-14T15:05:41.945Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d5/a0/ad235642118090f66e7b2f18fd5c42082418404a79205cdfca50b6309c13/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3f53fa183d53a1d7a8852277c92b967ae99c2d4dcee2bfacff8868e6e30b15f7", size = 448408, upload-time = "2025-10-14T15:05:43.385Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/df/85/97fa10fd5ff3332ae17e7e40e20784e419e28521549780869f1413742e9d/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:6aae418a8b323732fa89721d86f39ec8f092fc2af67f4217a2b07fd3e93c6101", size = 458968, upload-time = "2025-10-14T15:05:44.404Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/47/c2/9059c2e8966ea5ce678166617a7f75ecba6164375f3b288e50a40dc6d489/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f096076119da54a6080e8920cbdaac3dbee667eb91dcc5e5b78840b87415bd44", size = 488096, upload-time = "2025-10-14T15:05:45.398Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/94/44/d90a9ec8ac309bc26db808a13e7bfc0e4e78b6fc051078a554e132e80160/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:00485f441d183717038ed2e887a7c868154f216877653121068107b227a2f64c", size = 596040, upload-time = "2025-10-14T15:05:46.502Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/95/68/4e3479b20ca305cfc561db3ed207a8a1c745ee32bf24f2026a129d0ddb6e/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a55f3e9e493158d7bfdb60a1165035f1cf7d320914e7b7ea83fe22c6023b58fc", size = 473847, upload-time = "2025-10-14T15:05:47.484Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/4f/55/2af26693fd15165c4ff7857e38330e1b61ab8c37d15dc79118cdba115b7a/watchfiles-1.1.1-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8c91ed27800188c2ae96d16e3149f199d62f86c7af5f5f4d2c61a3ed8cd3666c", size = 455072, upload-time = "2025-10-14T15:05:48.928Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/66/1d/d0d200b10c9311ec25d2273f8aad8c3ef7cc7ea11808022501811208a750/watchfiles-1.1.1-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:311ff15a0bae3714ffb603e6ba6dbfba4065ab60865d15a6ec544133bdb21099", size = 629104, upload-time = "2025-10-14T15:05:49.908Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e3/bd/fa9bb053192491b3867ba07d2343d9f2252e00811567d30ae8d0f78136fe/watchfiles-1.1.1-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:a916a2932da8f8ab582f242c065f5c81bed3462849ca79ee357dd9551b0e9b01", size = 622112, upload-time = "2025-10-14T15:05:50.941Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "websockets"
|
||||
version = "15.0.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/21/e6/26d09fab466b7ca9c7737474c52be4f76a40301b08362eb2dbc19dcc16c1/websockets-15.0.1.tar.gz", hash = "sha256:82544de02076bafba038ce055ee6412d68da13ab47f0c60cab827346de828dee", size = 177016, upload-time = "2025-03-05T20:03:41.606Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/cb/9f/51f0cf64471a9d2b4d0fc6c534f323b664e7095640c34562f5182e5a7195/websockets-15.0.1-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:ee443ef070bb3b6ed74514f5efaa37a252af57c90eb33b956d35c8e9c10a1931", size = 175440, upload-time = "2025-03-05T20:02:36.695Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/8a/05/aa116ec9943c718905997412c5989f7ed671bc0188ee2ba89520e8765d7b/websockets-15.0.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5a939de6b7b4e18ca683218320fc67ea886038265fd1ed30173f5ce3f8e85675", size = 173098, upload-time = "2025-03-05T20:02:37.985Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ff/0b/33cef55ff24f2d92924923c99926dcce78e7bd922d649467f0eda8368923/websockets-15.0.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:746ee8dba912cd6fc889a8147168991d50ed70447bf18bcda7039f7d2e3d9151", size = 173329, upload-time = "2025-03-05T20:02:39.298Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/31/1d/063b25dcc01faa8fada1469bdf769de3768b7044eac9d41f734fd7b6ad6d/websockets-15.0.1-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:595b6c3969023ecf9041b2936ac3827e4623bfa3ccf007575f04c5a6aa318c22", size = 183111, upload-time = "2025-03-05T20:02:40.595Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/93/53/9a87ee494a51bf63e4ec9241c1ccc4f7c2f45fff85d5bde2ff74fcb68b9e/websockets-15.0.1-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3c714d2fc58b5ca3e285461a4cc0c9a66bd0e24c5da9911e30158286c9b5be7f", size = 182054, upload-time = "2025-03-05T20:02:41.926Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ff/b2/83a6ddf56cdcbad4e3d841fcc55d6ba7d19aeb89c50f24dd7e859ec0805f/websockets-15.0.1-cp313-cp313-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0f3c1e2ab208db911594ae5b4f79addeb3501604a165019dd221c0bdcabe4db8", size = 182496, upload-time = "2025-03-05T20:02:43.304Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/98/41/e7038944ed0abf34c45aa4635ba28136f06052e08fc2168520bb8b25149f/websockets-15.0.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:229cf1d3ca6c1804400b0a9790dc66528e08a6a1feec0d5040e8b9eb14422375", size = 182829, upload-time = "2025-03-05T20:02:48.812Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e0/17/de15b6158680c7623c6ef0db361da965ab25d813ae54fcfeae2e5b9ef910/websockets-15.0.1-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:756c56e867a90fb00177d530dca4b097dd753cde348448a1012ed6c5131f8b7d", size = 182217, upload-time = "2025-03-05T20:02:50.14Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/33/2b/1f168cb6041853eef0362fb9554c3824367c5560cbdaad89ac40f8c2edfc/websockets-15.0.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:558d023b3df0bffe50a04e710bc87742de35060580a293c2a984299ed83bc4e4", size = 182195, upload-time = "2025-03-05T20:02:51.561Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/86/eb/20b6cdf273913d0ad05a6a14aed4b9a85591c18a987a3d47f20fa13dcc47/websockets-15.0.1-cp313-cp313-win32.whl", hash = "sha256:ba9e56e8ceeeedb2e080147ba85ffcd5cd0711b89576b83784d8605a7df455fa", size = 176393, upload-time = "2025-03-05T20:02:53.814Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1b/6c/c65773d6cab416a64d191d6ee8a8b1c68a09970ea6909d16965d26bfed1e/websockets-15.0.1-cp313-cp313-win_amd64.whl", hash = "sha256:e09473f095a819042ecb2ab9465aee615bd9c2028e4ef7d933600a8401c79561", size = 176837, upload-time = "2025-03-05T20:02:55.237Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fa/a8/5b41e0da817d64113292ab1f8247140aac61cbf6cfd085d6a0fa77f4984f/websockets-15.0.1-py3-none-any.whl", hash = "sha256:f7a866fbc1e97b5c617ee4116daaa09b722101d4a3c170c787450ba409f9736f", size = 169743, upload-time = "2025-03-05T20:03:39.41Z" },
|
||||
]
|
||||
|
||||
Reference in New Issue
Block a user