diff --git a/Dockerfile b/Dockerfile index e322a6f..36eb166 100644 --- a/Dockerfile +++ b/Dockerfile @@ -5,36 +5,28 @@ WORKDIR /app ENV UV_COMPILE_BYTECODE=1 -# Copy dependency files COPY pyproject.toml uv.lock ./ -# Install dependencies RUN uv sync --frozen --no-dev --no-install-project -# Copy application code COPY iotDashboard/ ./iotDashboard/ COPY manage.py ./ COPY create_user.py ./ -# Sync the project RUN uv sync --frozen --no-dev -# Stage 2: Runtime FROM python:3.13-alpine WORKDIR /app -# Install runtime dependencies RUN apk add --no-cache postgresql-client -# Copy virtual environment and application COPY --from=builder /app/.venv /app/.venv COPY --from=builder /app/iotDashboard/ /app/iotDashboard/ COPY --from=builder /app/manage.py /app/ COPY --from=builder /app/create_user.py /app/ -# Create non-root user RUN adduser -D -u 1000 appuser && \ chown -R appuser:appuser /app @@ -45,5 +37,4 @@ ENV PYTHONUNBUFFERED=1 EXPOSE 3000 -# Run Django with uvicorn for ASGI CMD ["python", "-m", "uvicorn", "iotDashboard.asgi:application", "--host", "0.0.0.0", "--port", "3000"] diff --git a/db_migrations/Dockerfile b/db_migrations/Dockerfile index 4b777c8..885e0a8 100644 --- a/db_migrations/Dockerfile +++ b/db_migrations/Dockerfile @@ -4,7 +4,7 @@ WORKDIR /app COPY pyproject.toml ./ -RUN uv sync +RUN uv sync --no-cache COPY . . diff --git a/db_migrations/alembic/versions/20251215_2124_7c71d43d53e3_add_users_table.py b/db_migrations/alembic/versions/20251215_2124_7c71d43d53e3_add_users_table.py deleted file mode 100644 index b9fbdb4..0000000 --- a/db_migrations/alembic/versions/20251215_2124_7c71d43d53e3_add_users_table.py +++ /dev/null @@ -1,83 +0,0 @@ -"""add users table - -Revision ID: 7c71d43d53e3 -Revises: 4b84a36e13f5 -Create Date: 2025-12-15 21:24:36.718471+00:00 - -""" -from typing import Sequence, Union - -from alembic import op -import sqlalchemy as sa -from sqlalchemy.dialects import postgresql - -# revision identifiers, used by Alembic. -revision: str = '7c71d43d53e3' -down_revision: Union[str, Sequence[str], None] = '4b84a36e13f5' -branch_labels: Union[str, Sequence[str], None] = None -depends_on: Union[str, Sequence[str], None] = None - - -def upgrade() -> None: - """Upgrade schema.""" - # ### commands auto generated by Alembic - please adjust! ### - op.create_table('users', - sa.Column('id', sa.Text(), nullable=False), - sa.Column('username', sa.Text(), nullable=False), - sa.Column('email', sa.Text(), nullable=False), - sa.Column('password_hash', sa.Text(), nullable=False), - sa.Column('is_active', sa.Boolean(), nullable=True), - sa.Column('created_at', sa.DateTime(timezone=True), server_default=sa.text('now()'), nullable=True), - sa.PrimaryKeyConstraint('id'), - sa.UniqueConstraint('email'), - sa.UniqueConstraint('username') - ) - op.create_index('idx_users_email', 'users', ['email'], unique=False) - op.create_index('idx_users_username', 'users', ['username'], unique=False) - op.drop_index(op.f('dashboard_l_is_defa_033b71_idx'), table_name='dashboard_layouts') - op.drop_index(op.f('dashboard_l_name_c36020_idx'), table_name='dashboard_layouts') - op.drop_index(op.f('dashboard_layouts_name_349f3640_like'), table_name='dashboard_layouts', postgresql_ops={'name': 'varchar_pattern_ops'}) - op.drop_table('dashboard_layouts') - op.drop_table('django_migrations') - op.drop_table('iotDashboard_device') - op.drop_constraint(op.f('telemetry_device_id_fkey'), 'telemetry', type_='foreignkey') - op.create_foreign_key(None, 'telemetry', 'devices', ['device_id'], ['id']) - # ### end Alembic commands ### - - -def downgrade() -> None: - """Downgrade schema.""" - # ### commands auto generated by Alembic - please adjust! ### - op.drop_constraint(None, 'telemetry', type_='foreignkey') - op.create_foreign_key(op.f('telemetry_device_id_fkey'), 'telemetry', 'devices', ['device_id'], ['id'], ondelete='CASCADE') - op.create_table('iotDashboard_device', - sa.Column('id', sa.BIGINT(), sa.Identity(always=False, start=1, increment=1, minvalue=1, maxvalue=9223372036854775807, cycle=False, cache=1), autoincrement=True, nullable=False), - sa.Column('name', sa.VARCHAR(length=50), autoincrement=False, nullable=False), - sa.Column('ip', sa.VARCHAR(length=20), autoincrement=False, nullable=False), - sa.Column('protocol', sa.VARCHAR(length=20), autoincrement=False, nullable=False), - sa.PrimaryKeyConstraint('id', name=op.f('iotDashboard_device_pkey')) - ) - op.create_table('django_migrations', - sa.Column('id', sa.BIGINT(), sa.Identity(always=False, start=1, increment=1, minvalue=1, maxvalue=9223372036854775807, cycle=False, cache=1), autoincrement=True, nullable=False), - sa.Column('app', sa.VARCHAR(length=255), autoincrement=False, nullable=False), - sa.Column('name', sa.VARCHAR(length=255), autoincrement=False, nullable=False), - sa.Column('applied', postgresql.TIMESTAMP(timezone=True), autoincrement=False, nullable=False), - sa.PrimaryKeyConstraint('id', name=op.f('django_migrations_pkey')) - ) - op.create_table('dashboard_layouts', - sa.Column('id', sa.BIGINT(), sa.Identity(always=False, start=1, increment=1, minvalue=1, maxvalue=9223372036854775807, cycle=False, cache=1), autoincrement=True, nullable=False), - sa.Column('name', sa.VARCHAR(length=255), autoincrement=False, nullable=False), - sa.Column('config', postgresql.JSONB(astext_type=sa.Text()), autoincrement=False, nullable=False), - sa.Column('is_default', sa.BOOLEAN(), autoincrement=False, nullable=False), - sa.Column('created_at', postgresql.TIMESTAMP(timezone=True), autoincrement=False, nullable=False), - sa.Column('updated_at', postgresql.TIMESTAMP(timezone=True), autoincrement=False, nullable=False), - sa.PrimaryKeyConstraint('id', name=op.f('dashboard_layouts_pkey')), - sa.UniqueConstraint('name', name=op.f('dashboard_layouts_name_key'), postgresql_include=[], postgresql_nulls_not_distinct=False) - ) - op.create_index(op.f('dashboard_layouts_name_349f3640_like'), 'dashboard_layouts', ['name'], unique=False, postgresql_ops={'name': 'varchar_pattern_ops'}) - op.create_index(op.f('dashboard_l_name_c36020_idx'), 'dashboard_layouts', ['name'], unique=False) - op.create_index(op.f('dashboard_l_is_defa_033b71_idx'), 'dashboard_layouts', ['is_default'], unique=False) - op.drop_index('idx_users_username', table_name='users') - op.drop_index('idx_users_email', table_name='users') - op.drop_table('users') - # ### end Alembic commands ### diff --git a/db_migrations/alembic/versions/20251215_2156_1dfb0bb45f93_add_cascade_delete_to_telemetry_foreign_.py b/db_migrations/alembic/versions/20251215_2156_1dfb0bb45f93_add_cascade_delete_to_telemetry_foreign_.py deleted file mode 100644 index 7b4326c..0000000 --- a/db_migrations/alembic/versions/20251215_2156_1dfb0bb45f93_add_cascade_delete_to_telemetry_foreign_.py +++ /dev/null @@ -1,45 +0,0 @@ -"""add cascade delete to telemetry foreign key - -Revision ID: 1dfb0bb45f93 -Revises: 7c71d43d53e3 -Create Date: 2025-12-15 21:56:13.260281+00:00 - -""" -from typing import Sequence, Union - -from alembic import op -import sqlalchemy as sa -from sqlalchemy.dialects import postgresql - -# revision identifiers, used by Alembic. -revision: str = '1dfb0bb45f93' -down_revision: Union[str, Sequence[str], None] = '7c71d43d53e3' -branch_labels: Union[str, Sequence[str], None] = None -depends_on: Union[str, Sequence[str], None] = None - - -def upgrade() -> None: - """Upgrade schema - only update telemetry foreign key.""" - # Drop old foreign key constraint - op.drop_constraint('telemetry_device_id_fkey', 'telemetry', type_='foreignkey') - - # Add new foreign key constraint with CASCADE delete - op.create_foreign_key( - 'telemetry_device_id_fkey', - 'telemetry', 'devices', - ['device_id'], ['id'], - ondelete='CASCADE' - ) - - -def downgrade() -> None: - """Downgrade schema - revert foreign key change.""" - # Drop CASCADE foreign key - op.drop_constraint('telemetry_device_id_fkey', 'telemetry', type_='foreignkey') - - # Add back original foreign key without CASCADE - op.create_foreign_key( - 'telemetry_device_id_fkey', - 'telemetry', 'devices', - ['device_id'], ['id'] - ) diff --git a/db_migrations/pyproject.toml b/db_migrations/pyproject.toml index f22bc29..0bd1f87 100644 --- a/db_migrations/pyproject.toml +++ b/db_migrations/pyproject.toml @@ -6,5 +6,7 @@ readme = "README.md" requires-python = ">=3.13" dependencies = [ "alembic>=1.17.0", + "dotenv>=0.9.9", "sqlalchemy>=2.0.44", + "psycopg2-binary>=2.9.10", ] diff --git a/frontend/Dockerfile b/frontend/Dockerfile index a1ed40b..a1a1eff 100644 --- a/frontend/Dockerfile +++ b/frontend/Dockerfile @@ -8,6 +8,16 @@ RUN npm ci COPY . . +ARG VITE_API_URL=/api +ARG VITE_DEVICE_MANAGER_URL +ARG VITE_MQTT_BROKER +ARG VITE_MQTT_PORT=8883 + +ENV VITE_API_URL=$VITE_API_URL +ENV VITE_DEVICE_MANAGER_URL=$VITE_DEVICE_MANAGER_URL +ENV VITE_MQTT_BROKER=$VITE_MQTT_BROKER +ENV VITE_MQTT_PORT=$VITE_MQTT_PORT + RUN npm run build diff --git a/frontend/nginx.conf b/frontend/nginx.conf index 0ec0f59..4c94f06 100644 --- a/frontend/nginx.conf +++ b/frontend/nginx.conf @@ -15,19 +15,6 @@ server { try_files $uri $uri/ /index.html; } - # Proxy API requests to Django backend - location /api/ { - proxy_pass http://django:3000; - proxy_http_version 1.1; - proxy_set_header Upgrade $http_upgrade; - proxy_set_header Connection 'upgrade'; - proxy_set_header Host $host; - proxy_set_header X-Real-IP $remote_addr; - proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - proxy_set_header X-Forwarded-Proto $scheme; - proxy_cache_bypass $http_upgrade; - } - # Cache static assets location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ { expires 1y; diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index f92559b..c4c20f9 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,6 +1,7 @@ import { QueryClient, QueryClientProvider } from '@tanstack/react-query' import { BrowserRouter, Routes, Route, Link, NavLink, Navigate } from 'react-router-dom' import { Toaster } from 'react-hot-toast' +import { useState } from 'react' import { WellnessStateProvider } from './hooks/useWellnessState' import { AuthProvider, useAuth } from './contexts/AuthContext' import Dashboard from './pages/Dashboard' @@ -19,11 +20,12 @@ function ProtectedRoute({ children }: { children: React.ReactNode }) { function AppLayout({ children }: { children: React.ReactNode }) { const { logout } = useAuth() + const [sidebarCollapsed, setSidebarCollapsed] = useState(false) return ( -
+
-
+
{/* Navbar */}
@@ -44,7 +46,7 @@ function AppLayout({ children }: { children: React.ReactNode }) {
{/* Page content */} -
+
{children}
@@ -52,48 +54,71 @@ function AppLayout({ children }: { children: React.ReactNode }) { {/* Sidebar */}
- diff --git a/frontend/src/api/index.ts b/frontend/src/api/index.ts index f19970c..50f6754 100644 --- a/frontend/src/api/index.ts +++ b/frontend/src/api/index.ts @@ -97,6 +97,7 @@ export const telemetryApi = { analyze: (data: { device_id: string; metric?: string; + metrics?: string[]; // Support multiple metrics hours?: number; limit?: number; prompt_type?: 'anomaly_detection' | 'trend_summary' | 'custom'; diff --git a/frontend/src/components/CredentialsViewer.tsx b/frontend/src/components/CredentialsViewer.tsx index 1f4f6ae..14d6b4a 100644 --- a/frontend/src/components/CredentialsViewer.tsx +++ b/frontend/src/components/CredentialsViewer.tsx @@ -86,6 +86,87 @@ export default function CredentialsViewer({ credentials, deviceId }: Credentials
)} + {/* MQTT Connection Information */} +
+
+ + + +
+

Connection Configuration

+
+
+ MQTT Broker: + {mqttBroker} +
+
+ Port (TLS): + {mqttPort} +
+
+ Device ID: + {resolvedDeviceId} +
+
+
+
+
+ + {/* Publishing Guide */} +
+
+ + + +
+

How to Publish Data

+
+
+ Topic Format: + + devices/{resolvedDeviceId}/{''} + +
+ +
+ Payload: +

Send numeric value as plain text (no JSON)

+ 23.5 +
+ +
+ Supported Metrics: +
+ {['temperature', 'humidity', 'co2', 'pm25', 'pm10', 'pressure', 'light', 'noise'].map(metric => ( + {metric} + ))} +
+
+ +
+ +
+ Example (mosquitto_pub): + + mosquitto_pub --cafile ca.crt --cert {resolvedDeviceId}.crt --key {resolvedDeviceId}.key \
+   -h {mqttBroker} -p {mqttPort} \
+   -t "devices/{resolvedDeviceId}/temperature" -m "23.5" +
+ +
+
+
+
+
+ {(credentials.certificate_id || expiresAt) && (
diff --git a/frontend/src/components/widgets/AiInsightWidget.tsx b/frontend/src/components/widgets/AiInsightWidget.tsx index bf28216..eba2d2b 100644 --- a/frontend/src/components/widgets/AiInsightWidget.tsx +++ b/frontend/src/components/widgets/AiInsightWidget.tsx @@ -45,7 +45,7 @@ export default function AiInsightWidget({ config }: AiInsightWidgetProps) { const [showAnalysis, setShowAnalysis] = useState(false) const deviceId = deviceIds[0] - const metric = metricIds[0] + const metrics = metricIds // Use ALL selected metrics const { data: analysis, @@ -53,11 +53,11 @@ export default function AiInsightWidget({ config }: AiInsightWidgetProps) { refetch, error, } = useQuery({ - queryKey: ['ai-insight', deviceId, metric, promptType, timeframe], + queryKey: ['ai-insight', deviceId, metrics, promptType, timeframe], queryFn: async () => { const response = await telemetryApi.analyze({ device_id: deviceId, - metric, + metrics: metrics, // Send all metrics hours: timeframe.hours || 240, limit: 200, prompt_type: promptType, diff --git a/frontend/src/components/widgets/AirQualityWidget.tsx b/frontend/src/components/widgets/AirQualityWidget.tsx index 9d952b9..832f495 100644 --- a/frontend/src/components/widgets/AirQualityWidget.tsx +++ b/frontend/src/components/widgets/AirQualityWidget.tsx @@ -17,7 +17,7 @@ export default function AirQualityWidget({ config }: AirQualityWidgetProps) { const response = await weatherApi.getAirQuality(city) return response.data }, - refetchInterval: 300000, // Refresh every 5 minutes + refetchInterval: 5000, // Refresh every 5 seconds staleTime: 240000, // Consider fresh for 4 minutes }) diff --git a/frontend/src/components/widgets/ComfortIndexWidget.tsx b/frontend/src/components/widgets/ComfortIndexWidget.tsx index f589b5a..351c89b 100644 --- a/frontend/src/components/widgets/ComfortIndexWidget.tsx +++ b/frontend/src/components/widgets/ComfortIndexWidget.tsx @@ -69,7 +69,7 @@ export default function ComfortIndexWidget({ config }: ComfortIndexWidgetProps) const response = await devicesApi.getComfortIndex(deviceId) return response.data }, - refetchInterval: 60000, // Refresh every minute + refetchInterval: 5000, // Refresh every 5 seconds enabled: !!deviceId, }) diff --git a/frontend/src/components/widgets/HealthStatsWidget.tsx b/frontend/src/components/widgets/HealthStatsWidget.tsx index dbb7f39..ea4e2cf 100644 --- a/frontend/src/components/widgets/HealthStatsWidget.tsx +++ b/frontend/src/components/widgets/HealthStatsWidget.tsx @@ -24,7 +24,7 @@ export default function HealthStatsWidget({ config }: HealthStatsWidgetProps) { const response = await wellnessApi.getHealthInsights(deviceId, widgetCity || undefined) return response.data }, - refetchInterval: 60000, // Refresh every minute + refetchInterval: 5000, // Refresh every 5 seconds enabled: !!deviceId, }) diff --git a/frontend/src/components/widgets/RunSuitabilityWidget.tsx b/frontend/src/components/widgets/RunSuitabilityWidget.tsx index 44942e1..5524fd3 100644 --- a/frontend/src/components/widgets/RunSuitabilityWidget.tsx +++ b/frontend/src/components/widgets/RunSuitabilityWidget.tsx @@ -36,7 +36,7 @@ export default function RunSuitabilityWidget({ config }: RunSuitabilityWidgetPro const response = await wellnessApi.getRunSuitability(deviceId, widgetCity) return response.data }, - refetchInterval: 300000, // Refresh every 5 minutes + refetchInterval: 5000, // Refresh every 5 seconds enabled: !!deviceId && !!widgetCity, }) diff --git a/frontend/src/components/widgets/WeatherWidget.tsx b/frontend/src/components/widgets/WeatherWidget.tsx index 948990e..4d2458b 100644 --- a/frontend/src/components/widgets/WeatherWidget.tsx +++ b/frontend/src/components/widgets/WeatherWidget.tsx @@ -127,7 +127,7 @@ export default function WeatherWidget({ config }: WeatherWidgetProps) { const response = await weatherApi.getCurrent({ city }) return response.data }, - refetchInterval: 300000, // Refresh every 5 minutes + refetchInterval: 5000, // Refresh every 5 seconds staleTime: 240000, // Consider fresh for 4 minutes }) diff --git a/frontend/src/hooks/useDashboardConfig.ts b/frontend/src/hooks/useDashboardConfig.ts index 84ffdf4..41e0b22 100644 --- a/frontend/src/hooks/useDashboardConfig.ts +++ b/frontend/src/hooks/useDashboardConfig.ts @@ -145,16 +145,41 @@ export function useDashboardConfig() { } } - // Only save to localStorage automatically (no backend saves) + // Auto-save to localStorage and debounced backend save useEffect(() => { if (!isInitialLoadRef.current) { + // Save to localStorage immediately try { localStorage.setItem(STORAGE_KEY, JSON.stringify(config)) } catch (error) { console.error('Failed to save to localStorage:', error) } + + // Auto-save to backend with debounce (2 seconds after last change) + const timeoutId = setTimeout(async () => { + try { + if (layoutId) { + await dashboardLayoutApi.update(layoutId, { + config: config, + }) + console.log('Dashboard auto-saved to backend') + } else { + const response = await dashboardLayoutApi.create({ + name: 'default', + config: config, + is_default: true, + }) + setLayoutId(response.data.id) + console.log('Dashboard created and auto-saved to backend') + } + } catch (error) { + console.error('Failed to auto-save to backend:', error) + } + }, 2000) + + return () => clearTimeout(timeoutId) } - }, [config]) + }, [config, layoutId]) const addWidget = (widget: WidgetConfig) => { diff --git a/frontend/src/hooks/useTelemetrySeries.ts b/frontend/src/hooks/useTelemetrySeries.ts index d6d11eb..3ec93a4 100644 --- a/frontend/src/hooks/useTelemetrySeries.ts +++ b/frontend/src/hooks/useTelemetrySeries.ts @@ -62,8 +62,8 @@ export function useTelemetrySeries({ return response.data as Telemetry[] }, enabled, - refetchInterval: enabled ? 60000 : false, // Refresh every 60 seconds (reduced frequency) - staleTime: 30000, // Consider data fresh for 30 seconds + refetchInterval: enabled ? 5000 : false, // Refresh every 5 seconds + staleTime: 3000, // Consider data fresh for 3 seconds }) return { diff --git a/frontend/src/index.css b/frontend/src/index.css index 270ea7f..4752309 100644 --- a/frontend/src/index.css +++ b/frontend/src/index.css @@ -6,6 +6,14 @@ --dui-themes: light, dark, cupcake, corporate; } +/* Ensure full viewport height */ +html, +body, +#root { + height: 100%; + min-height: 100vh; +} + /* Custom scrollbar styles */ ::-webkit-scrollbar { width: 8px; diff --git a/frontend/src/pages/Dashboard.tsx b/frontend/src/pages/Dashboard.tsx index 36cfaa8..5371480 100644 --- a/frontend/src/pages/Dashboard.tsx +++ b/frontend/src/pages/Dashboard.tsx @@ -144,7 +144,7 @@ export default function Dashboard() { } return ( -
+

Dashboard

@@ -153,8 +153,14 @@ export default function Dashboard() {

+
+ + + + Auto-save enabled +
{saveStatus === 'success' && ( Saved! diff --git a/infrastructure/compose.prod.yml b/infrastructure/compose.prod.yml new file mode 100644 index 0000000..5690da5 --- /dev/null +++ b/infrastructure/compose.prod.yml @@ -0,0 +1,129 @@ +services: + + redis: + image: redis:8 + ports: + - "6379:6379" + volumes: + - redis-data:/data + restart: unless-stopped + + mosquitto: + image: eclipse-mosquitto:2.0 + ports: + - "1883:1883" + - "9001:9001" + - "8883:8883" + volumes: + - ./mosquitto/:/mosquitto/:Z + restart: unless-stopped + + timescaledb: + image: timescale/timescaledb:latest-pg17 + environment: + POSTGRES_USER: ${POSTGRES_USER:-postgres} + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-example} + POSTGRES_DB: ${POSTGRES_DB:-iot_data} + ports: + - "5432:5432" + volumes: + - timescaledb-data:/var/lib/postgresql/data + restart: unless-stopped + healthcheck: + test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-postgres}"] + interval: 10s + timeout: 5s + retries: 5 + + device-manager: + image: git.ferdzo.xyz/ferdzo/lyncis/device-manager:latest + ports: + - "8000:8000" + environment: + - DATABASE_URL=postgresql://${POSTGRES_USER:-postgres}:${POSTGRES_PASSWORD:-example}@timescaledb:5432/${POSTGRES_DB:-iot_data} + - CA_CERT_PATH=/app/certs/ca.crt + - CA_KEY_PATH=/app/certs/ca.key + - CRL_PATH=/app/certs/ca.crl + volumes: + - ./mosquitto/certs:/app/certs:z + depends_on: + timescaledb: + condition: service_healthy + restart: unless-stopped + + mqtt-ingestion: + image: git.ferdzo.xyz/ferdzo/lyncis/mqtt-ingestion:latest + environment: + - REDIS_HOST=redis + - REDIS_PORT=6379 + - REDIS_DB=0 + - MQTT_BROKER=mosquitto + - MQTT_PORT=1883 + - MQTT_USER=${MQTT_USER:-} + - MQTT_PASS=${MQTT_PASS:-} + depends_on: + - mosquitto + - redis + restart: unless-stopped + + db-write: + image: git.ferdzo.xyz/ferdzo/lyncis/db-write:latest + environment: + - REDIS_HOST=redis + - REDIS_PORT=6379 + - REDIS_DB=0 + - DATABASE_URL=postgresql://${POSTGRES_USER:-postgres}:${POSTGRES_PASSWORD:-example}@timescaledb:5432/${POSTGRES_DB:-iot_data} + - CONSUMER_GROUP_NAME=db_writer + - CONSUMER_NAME=worker-01 + - BATCH_SIZE=100 + - BATCH_TIMEOUT_SEC=5 + - STREAM_PATTERN=mqtt_stream:* + depends_on: + timescaledb: + condition: service_healthy + redis: + condition: service_started + mqtt-ingestion: + condition: service_started + restart: unless-stopped + + backend: + image: git.ferdzo.xyz/ferdzo/lyncis/backend:latest + ports: + - "3000:3000" + environment: + - POSTGRES_HOST=timescaledb + - POSTGRES_PORT=5432 + - POSTGRES_USER=${POSTGRES_USER:-postgres} + - POSTGRES_PASSWORD=${POSTGRES_PASSWORD:-example} + - POSTGRES_DB=${POSTGRES_DB:-iot_data} + - DEVICE_MANAGER_URL=http://device-manager:8000 + - SECRET_KEY=${SECRET_KEY:-change-me-in-production} + - DEBUG=False + - ALLOWED_HOSTS=* + depends_on: + timescaledb: + condition: service_healthy + device-manager: + condition: service_started + restart: unless-stopped + + frontend: + image: git.ferdzo.xyz/ferdzo/lyncis/frontend:latest + ports: + - "80:80" + depends_on: + - backend + restart: unless-stopped + + gpt-service: + image: git.ferdzo.xyz/ferdzo/lyncis-gpt-service:latest + environment: + - OPENAI_API_KEY=${OPENAI_API_KEY} + ports: + - "8001:8001" + restart: unless-stopped + +volumes: + redis-data: + timescaledb-data: diff --git a/infrastructure/compose.yml b/infrastructure/compose.yml index 80a35a7..96062bd 100644 --- a/infrastructure/compose.yml +++ b/infrastructure/compose.yml @@ -15,7 +15,7 @@ services: - "9001:9001" - "8883:8883" volumes: - - ./mosquitto/:/mosquitto/:Z + - ./mosquitto/:/mosquitto/ restart: unless-stopped timescaledb: @@ -29,19 +29,26 @@ services: volumes: - timescaledb-data:/var/lib/postgresql/data restart: unless-stopped + healthcheck: + test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-postgres}"] + interval: 10s + timeout: 5s + retries: 5 - db-write: - build: - context: ../services/db_write + device-manager: + build: + context: ../services/device_manager + ports: + - "8000:8000" env_file: - - .env.dbw + - ../services/device_manager/.env + volumes: + - ./mosquitto/certs:/app/certs:z depends_on: - - timescaledb - - redis - - mqtt-ingestion - restart: - unless-stopped - + timescaledb: + condition: service_healthy + restart: unless-stopped + mqtt-ingestion: build: context: ../services/mqtt_ingestion @@ -50,18 +57,66 @@ services: depends_on: - mosquitto - redis - restart: - unless-stopped + restart: unless-stopped - # gpt-service: - # build: - # context: ./services/gpt_service - # ports: - # - "8001:8001" - # restart: unless-stopped + db-write: + build: + context: ../services/db_write + env_file: + - .env.dbw + depends_on: + timescaledb: + condition: service_healthy + redis: + condition: service_started + mqtt-ingestion: + condition: service_started + restart: unless-stopped + backend: + image: git.ferdzo.xyz/ferdzo/lyncis/backend:latest + ports: + - "3000:3000" + environment: + - POSTGRES_HOST=timescaledb + - POSTGRES_PORT=5432 + - POSTGRES_USER=${POSTGRES_USER:-postgres} + - POSTGRES_PASSWORD=${POSTGRES_PASSWORD:-example} + - POSTGRES_DB=${POSTGRES_DB:-iot_data} + - DEVICE_MANAGER_URL=http://device-manager:8000 + - SECRET_KEY=${SECRET_KEY:-change-me-in-production}6 + - DEBUG=False + - ALLOWED_HOSTS=* + depends_on: + timescaledb: + condition: service_healthy + device-manager: + condition: service_started + restart: unless-stopped + + frontend: + build: + context: ../frontend + dockerfile: Dockerfile + args: + VITE_API_URL: /api + VITE_DEVICE_MANAGER_URL: ${VITE_DEVICE_MANAGER_URL:-http://localhost/api} + VITE_MQTT_BROKER: ${VITE_MQTT_BROKER:-localhost} + VITE_MQTT_PORT: ${VITE_MQTT_PORT:-8883} + ports: + - "80:80" + depends_on: + - django + restart: unless-stopped + + gpt-service: + image: git.ferdzo.xyz/ferdzo/lyncis-gpt-service:latest + environment: + - OPENAI_API_KEY=${OPENAI_API_KEY} + ports: + - "8001:8001" + restart: unless-stopped - volumes: redis-data: diff --git a/iotDashboard/api/views.py b/iotDashboard/api/views.py index d8fad9b..e740b63 100644 --- a/iotDashboard/api/views.py +++ b/iotDashboard/api/views.py @@ -408,7 +408,14 @@ class TelemetryViewSet(viewsets.ReadOnlyModelViewSet): time__gte=timezone.now() - timedelta(hours=hours) ) - if metric: + # Support multiple metrics (comma-separated) + metrics = request.data.get('metrics') + if metrics: + # Multiple metrics provided as list + metric_list = metrics if isinstance(metrics, list) else [m.strip() for m in metrics.split(',')] + queryset = queryset.filter(metric__in=metric_list) + elif metric: + # Single metric (backward compatible) queryset = queryset.filter(metric=metric) telemetry = queryset.order_by('-time')[:limit] diff --git a/iotDashboard/run_suitability.py b/iotDashboard/run_suitability.py index 7f1321a..9e11dc8 100644 --- a/iotDashboard/run_suitability.py +++ b/iotDashboard/run_suitability.py @@ -211,20 +211,36 @@ class RunSuitabilityCalculator: current_hour = current_time.hour recommendations = [] - # Check if current time is in optimal range + # Check if current time is in an optimal range + in_optimal_time = False for start_time, end_time, description in RunSuitabilityCalculator.BEST_TIMES: if start_time.hour <= current_hour < end_time.hour: recommendations.append(f"Current time is ideal: {description}") - else: - # Calculate hours until next optimal time + in_optimal_time = True + break + + if not in_optimal_time: + # Find next optimal time window + next_window = None + for start_time, end_time, description in RunSuitabilityCalculator.BEST_TIMES: if current_hour < start_time.hour: + # This window is later today hours_until = start_time.hour - current_hour - 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}") + next_window = (hours_until, start_time, description, "today") + break + + # If no window found later today, next window is tomorrow morning + if next_window is None: + first_start, _, first_desc = RunSuitabilityCalculator.BEST_TIMES[0] + hours_until = (24 - current_hour) + first_start.hour + next_window = (hours_until, first_start, first_desc, "tomorrow") + + if next_window: + hours_until, next_start, description, when = next_window + recommendations.append( + f"Next optimal time: {next_start.strftime('%I:%M %p')} {when} " + f"(in {hours_until} hours) - {description}" + ) return recommendations diff --git a/iotDashboard/settings.py b/iotDashboard/settings.py index b665490..37fe6ac 100644 --- a/iotDashboard/settings.py +++ b/iotDashboard/settings.py @@ -177,6 +177,8 @@ CORS_ALLOWED_ORIGINS = [ "http://127.0.0.1:5173", "http://localhost:3000", "http://127.0.0.1:3000", + "https://lyncis.ferdzo.xyz", + "http://lyncis.ferdzo.xyz", ] CORS_ALLOW_CREDENTIALS = True diff --git a/iotDashboard/urls.py b/iotDashboard/urls.py index 96d863e..ba485ae 100644 --- a/iotDashboard/urls.py +++ b/iotDashboard/urls.py @@ -30,24 +30,16 @@ urlpatterns = [ # REST API path("api/", include("iotDashboard.api.urls")), - # Main dashboard - path("", views.chart, name="index"), - path("chart/", views.chart, name="chart"), - - # Device management - path("devices/", views.device_list, name="device_list"), - path("devices/add/", views.add_device, name="add_device"), - path("devices//", views.view_device, name="view_device"), - path("devices//delete/", views.delete_device, name="delete_device"), - - # Certificate management (MQTT devices only) - path("devices//certificate/revoke/", views.revoke_certificate, name="revoke_certificate"), - path("devices//certificate/renew/", views.renew_certificate, name="renew_certificate"), - - # Telemetry data API - path("fetch_device_data/", views.fetch_device_data, name="fetch_device_data"), - - # Legacy/utility endpoints - path("devices_api/", views.devices_api, name="devices_api"), - path("logout/", views.logout_view, name="logout"), + # Legacy template views - DISABLED (using React frontend) + # path("", views.chart, name="index"), + # path("chart/", views.chart, name="chart"), + # path("devices/", views.device_list, name="device_list"), + # path("devices/add/", views.add_device, name="add_device"), + # path("devices//", views.view_device, name="view_device"), + # path("devices//delete/", views.delete_device, name="delete_device"), + # path("devices//certificate/revoke/", views.revoke_certificate, name="revoke_certificate"), + # path("devices//certificate/renew/", views.renew_certificate, name="renew_certificate"), + # path("fetch_device_data/", views.fetch_device_data, name="fetch_device_data"), + # path("devices_api/", views.devices_api, name="devices_api"), + # path("logout/", views.logout_view, name="logout"), ] diff --git a/pyproject.toml b/pyproject.toml index 0a16b60..dcac400 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -17,6 +17,7 @@ dependencies = [ "python-dotenv>=1.2.1", "redis>=7.0.1", "requests>=2.32.5", + "uvicorn>=0.38.0", ] [dependency-groups] diff --git a/uv.lock b/uv.lock index 24d1635..6d8eccc 100644 --- a/uv.lock +++ b/uv.lock @@ -146,12 +146,16 @@ version = "0.1.0" source = { virtual = "db_migrations" } dependencies = [ { name = "alembic" }, + { name = "dotenv" }, + { name = "psycopg2-binary" }, { name = "sqlalchemy" }, ] [package.metadata] requires-dist = [ { name = "alembic", specifier = ">=1.17.0" }, + { name = "dotenv", specifier = ">=0.9.9" }, + { name = "psycopg2-binary", specifier = ">=2.9.10" }, { name = "sqlalchemy", specifier = ">=2.0.44" }, ] @@ -227,6 +231,17 @@ 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 = "dotenv" +version = "0.9.9" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "python-dotenv" }, +] +wheels = [ + { url = "https://files.pythonhosted.org/packages/b2/b7/545d2c10c1fc15e48653c91efde329a790f2eecfbbf2bd16003b5db2bab0/dotenv-0.9.9-py2.py3-none-any.whl", hash = "sha256:29cf74a087b31dafdb5a446b6d7e11cbce8ed2741540e2339c69fbef92c94ce9", size = 1892, upload-time = "2025-02-19T22:15:01.647Z" }, +] + [[package]] name = "fastapi" version = "0.121.0" @@ -376,6 +391,7 @@ dependencies = [ { name = "python-dotenv" }, { name = "redis" }, { name = "requests" }, + { name = "uvicorn" }, ] [package.dev-dependencies] @@ -397,6 +413,7 @@ requires-dist = [ { name = "python-dotenv", specifier = ">=1.2.1" }, { name = "redis", specifier = ">=7.0.1" }, { name = "requests", specifier = ">=2.32.5" }, + { name = "uvicorn", specifier = ">=0.38.0" }, ] [package.metadata.requires-dev]